Skip to content

Commit 533f527

Browse files
iammukeshmclaude
andcommitted
Refactor Blazor auth to cookie-based BFF pattern with JWT API calls
Replaces complex token management with simpler cookie-based authentication for Blazor Server SSR. Login now uses HTML form POST to BFF endpoint that calls identity API, stores JWT token in cookie claims, and attaches it to API requests via delegating handler. Key changes: - Add SimpleBffAuth with /api/auth/login and /api/auth/logout endpoints - Add CookieAuthenticationStateProvider (extends ServerAuthenticationStateProvider) - Add AuthorizationHeaderHandler to attach JWT Bearer tokens to API requests - Add SimpleLogin.razor with HTML form POST (not AJAX) - Add ThemeStateFactory for SSR-compatible tenant theme caching - Remove old BffAuth, TokenAccessor, TokenSessionAccessor, and circuit handler - Update PlaygroundLayout, UsersPage, UserDetailPage to use AuthenticationStateProvider Fixes login flow and API authorization (401 errors resolved). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent dc4e36d commit 533f527

19 files changed

+525
-573
lines changed

.claude/settings.local.json

Lines changed: 0 additions & 14 deletions
This file was deleted.

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,4 +492,6 @@ docs/
492492
spec-os/
493493
/PLAN.md
494494
**/nul
495-
**/wwwroot/uploads/*
495+
**/wwwroot/uploads/*
496+
/agent_docs/blazor.md
497+
/.claude/settings.local.json

src/Playground/Playground.Blazor/Components/App.razor

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<base href="/" />
88
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
99
<link href=@Assets["_content/MudBlazor/MudBlazor.min.css"] rel="stylesheet" />
10-
<link href="@Assets["_content/FSH.Framework.Blazor.UI/fsh-theme.css"]" rel="stylesheet" />
10+
<link href="@Assets["_content/FSH.Framework.Blazor.UI/css/fsh-theme.css"]" rel="stylesheet" />
1111
<ImportMap />
1212
<link rel="icon" type="image/ico" href="favicon.ico" />
1313
<HeadOutlet @rendermode="InteractiveServer" />

src/Playground/Playground.Blazor/Components/Layout/PlaygroundLayout.razor

Lines changed: 47 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,16 @@
77
@using FSH.Playground.Blazor.Components.Pages
88
@using Microsoft.AspNetCore.WebUtilities
99
@using FSH.Playground.Blazor.Services
10+
@using Microsoft.AspNetCore.Components.Authorization
11+
@using System.Security.Claims
12+
@inject IHttpContextAccessor HttpContextAccessor
13+
@inject AuthenticationStateProvider AuthenticationStateProvider
1014
@inject IHttpClientFactory HttpClientFactory
1115
@inject NavigationManager Navigation
1216
@inject ISnackbar Snackbar
1317
@inject MudTheme FshTheme
1418
@inject ITenantThemeState TenantThemeState
15-
@inject FSH.Playground.Blazor.Services.Api.ITokenSessionAccessor TokenSessionAccessor
16-
@inject FSH.Playground.Blazor.Services.Api.ITokenAccessor TokenAccessor
19+
@inject IThemeStateFactory ThemeStateFactory
1720
@inject FSH.Playground.Blazor.ApiClient.IIdentityClient IdentityClient
1821

1922
<MudThemeProvider Theme="@_theme" IsDarkMode="_isDarkMode" />
@@ -31,7 +34,7 @@
3134
}
3235
else if (!_isAuthenticated)
3336
{
34-
<Login />
37+
<SimpleLogin />
3538
}
3639
else
3740
{
@@ -79,12 +82,44 @@ else
7982
private string? _userRole;
8083
private string? _avatarUrl;
8184

82-
protected override void OnInitialized()
85+
protected override async Task OnInitializedAsync()
8386
{
84-
base.OnInitialized();
85-
_theme = TenantThemeState.Theme;
86-
_isDarkMode = TenantThemeState.IsDarkMode;
87+
await base.OnInitializedAsync();
88+
89+
// SSR-friendly authentication check via HttpContext
90+
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
91+
_isAuthenticated = authState.User?.Identity?.IsAuthenticated ?? false;
92+
93+
if (_isAuthenticated)
94+
{
95+
// Extract user info from claims (available in SSR)
96+
var user = authState.User;
97+
_userName = user.FindFirst(ClaimTypes.Name)?.Value ?? user.FindFirst(ClaimTypes.Email)?.Value ?? "User";
98+
_userEmail = user.FindFirst(ClaimTypes.Email)?.Value;
99+
_userRole = user.FindFirst(ClaimTypes.Role)?.Value;
100+
101+
// Load theme from cache (fast, SSR-compatible)
102+
var httpContext = HttpContextAccessor.HttpContext;
103+
if (httpContext is not null)
104+
{
105+
var tenantId = httpContext.Request.Cookies["fsh_tenant"] ?? "root";
106+
var themeSettings = await ThemeStateFactory.GetThemeAsync(tenantId);
107+
_theme = themeSettings.ToMudTheme();
108+
// Dark mode preference is user-specific, default to false for SSR
109+
_isDarkMode = false;
110+
}
111+
}
112+
else
113+
{
114+
// Use default theme for non-authenticated users
115+
_theme = TenantThemeState.Theme;
116+
_isDarkMode = false;
117+
}
118+
119+
// Subscribe to theme changes (for Interactive mode)
87120
TenantThemeState.OnThemeChanged += HandleThemeChanged;
121+
122+
_authStatusLoaded = true;
88123
}
89124

90125
private void HandleThemeChanged()
@@ -103,29 +138,12 @@ else
103138
{
104139
await base.OnAfterRenderAsync(firstRender);
105140

106-
if (firstRender && !_authStatusLoaded)
141+
if (firstRender && _isAuthenticated)
107142
{
108-
var client = HttpClientFactory.CreateClient();
109-
var uri = Navigation.ToAbsoluteUri("/auth/status");
110-
111-
try
112-
{
113-
var response = await client.GetAsync(uri);
114-
_isAuthenticated = response.IsSuccessStatusCode;
115-
if (_isAuthenticated)
116-
{
117-
await HydrateSessionAsync(client);
118-
// Load tenant theme after authentication
119-
await TenantThemeState.LoadThemeAsync();
120-
// Load user profile
121-
await LoadUserProfileAsync();
122-
}
123-
}
124-
catch
125-
{
126-
_isAuthenticated = false;
127-
}
143+
// Load full profile in Interactive mode
144+
await LoadUserProfileAsync();
128145

146+
// Handle toast notifications
129147
var currentUri = new Uri(Navigation.Uri);
130148
var query = QueryHelpers.ParseQuery(currentUri.Query);
131149
if (query.TryGetValue("toast", out var toastValues))
@@ -144,15 +162,14 @@ else
144162
Navigation.NavigateTo(cleanUri, false);
145163
}
146164

147-
_authStatusLoaded = true;
148165
StateHasChanged();
149166
}
150167
}
151168

152169
private async Task LogoutAsync()
153170
{
154171
var client = HttpClientFactory.CreateClient();
155-
var uri = Navigation.ToAbsoluteUri("/auth/logout");
172+
var uri = Navigation.ToAbsoluteUri("/api/auth/logout");
156173
try
157174
{
158175
var response = await client.PostAsync(uri, content: null);
@@ -191,34 +208,6 @@ else
191208
false => Icons.Material.Outlined.DarkMode,
192209
};
193210

194-
private async Task HydrateSessionAsync(HttpClient client)
195-
{
196-
try
197-
{
198-
var sessionResponse = await client.GetAsync(Navigation.ToAbsoluteUri("/auth/session"));
199-
if (!sessionResponse.IsSuccessStatusCode)
200-
{
201-
return;
202-
}
203-
204-
var sessionInfo = await sessionResponse.Content.ReadFromJsonAsync<SessionInfoResult>();
205-
if (sessionInfo is null || string.IsNullOrWhiteSpace(sessionInfo.SessionId))
206-
{
207-
return;
208-
}
209-
210-
TokenSessionAccessor.SessionId = sessionInfo.SessionId;
211-
TokenAccessor.AccessToken = sessionInfo.AccessToken;
212-
TokenAccessor.RefreshToken = sessionInfo.RefreshToken;
213-
TokenAccessor.AccessTokenExpiresAt = sessionInfo.AccessTokenExpiresAt;
214-
TokenAccessor.RefreshTokenExpiresAt = sessionInfo.RefreshTokenExpiresAt;
215-
}
216-
catch
217-
{
218-
// swallow; fall back to fresh login if session cannot be hydrated
219-
}
220-
}
221-
222211
private async Task LoadUserProfileAsync()
223212
{
224213
try

src/Playground/Playground.Blazor/Components/Pages/Dashboard/DashboardPage.razor

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@page "/dashboard"
22
@page "/"
3+
@attribute [StreamRendering(true)]
34
@using System.Linq
45
@inherits ComponentBase
56
@inject FSH.Playground.Blazor.ApiClient.IV1Client V1Client

src/Playground/Playground.Blazor/Components/Pages/Login.razor

Lines changed: 0 additions & 117 deletions
This file was deleted.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
@page "/login"
2+
@using FSH.Framework.Shared.Multitenancy
3+
@using FSH.Playground.Blazor.Services
4+
@inject NavigationManager Navigation
5+
@inject IHttpClientFactory HttpClientFactory
6+
7+
<MudContainer MaxWidth="MaxWidth.False"
8+
Class="d-flex justify-center align-center"
9+
Style="min-height:100vh; background-color:#F3F4F6;">
10+
<MudPaper Class="pa-6" Elevation="1"
11+
Style="max-width:420px; width:100%; background-color:#FFFFFF; border-radius:1rem; border:1px solid #E5E7EB;">
12+
<form method="post" action="/api/auth/login">
13+
<MudStack Spacing="2">
14+
<MudText Typo="Typo.overline" Color="Color.Primary">FSH Playground</MudText>
15+
<MudText Typo="Typo.h5">Sign in</MudText>
16+
<MudText Typo="Typo.body2" Class="mb-2" Color="Color.Secondary">
17+
Use your FSH credentials for the <b>root</b> tenant.
18+
</MudText>
19+
20+
<input type="hidden" name="Tenant" value="root" />
21+
22+
<MudTextField @bind-Value="_email"
23+
Label="Email"
24+
name="Email"
25+
Variant="Variant.Outlined"
26+
Class="mb-3" />
27+
28+
<MudTextField @bind-Value="_password"
29+
Label="Password"
30+
name="Password"
31+
InputType="InputType.Password"
32+
Variant="Variant.Outlined"
33+
Class="mb-4" />
34+
35+
<MudButton ButtonType="ButtonType.Submit"
36+
Color="Color.Primary"
37+
Variant="Variant.Filled"
38+
FullWidth="true"
39+
Class="mb-2">
40+
Sign in
41+
</MudButton>
42+
</MudStack>
43+
</form>
44+
</MudPaper>
45+
</MudContainer>
46+
47+
@code {
48+
private string _email = MultitenancyConstants.Root.EmailAddress;
49+
private string _password = MultitenancyConstants.DefaultPassword;
50+
}

0 commit comments

Comments
 (0)