feat(auth): load Cloudflare JWT user profile and show welcome name in…#97
feat(auth): load Cloudflare JWT user profile and show welcome name in…#97PhilipWoulfe merged 1 commit intomainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Implements Issue #65 by extracting a user display name (from Cloudflare-provided identity/JWT claims) and surfacing it in the Blazor UI (header + home), with API support for a “current user” endpoint.
Changes:
- Web: load
IUserSessionat startup and use it to render “Welcome, {Name}” and personalize the Home page. - Web: extend the
Usermodel and add aClaimTypes.Nameclaim when a display name is available. - API: enhance Cloudflare middleware and
GetMeendpoint to deriveemail/namefromCf-Access-Jwt-Assertionwhen needed.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/F1.Web/Services/CustomAuthenticationStateProvider.cs | Adds ClaimTypes.Name based on session name/email for UI identity display. |
| src/F1.Web/Program.cs | Initializes IUserSession during app startup. |
| src/F1.Web/Pages/Home.razor | Personalizes greeting using session-derived display name. |
| src/F1.Web/Models/User.cs | Extends user model to include name/auth/id fields. |
| src/F1.Web/Layout/MainLayout.razor | Updates header to show “Welcome, …” and removes per-layout session init. |
| src/F1.Api/Middleware/CloudflareAccessMiddleware.cs | Falls back to parsing Cf-Access-Jwt-Assertion for email/name claims. |
| src/F1.Api/Controllers/UsersController.cs | Adjusts /me endpoint to read identity from JWT/header and returns UserDto. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var jwtAssertion = Request.Headers["Cf-Access-Jwt-Assertion"].FirstOrDefault(); | ||
| var nameFromJwt = GetJwtClaim(jwtAssertion, "name"); | ||
| var emailFromJwt = GetJwtClaim(jwtAssertion, "email"); | ||
|
|
||
| if (string.IsNullOrEmpty(email)) | ||
| var email = emailFromJwt ?? Request.Headers["Cf-Access-Authenticated-User-Email"].FirstOrDefault(); | ||
| var name = nameFromJwt ?? email?.Split('@')[0]; | ||
|
|
||
| if (string.IsNullOrWhiteSpace(email) && string.IsNullOrWhiteSpace(name)) | ||
| { | ||
| return Unauthorized(); | ||
| } | ||
|
|
||
| return Ok(new UserDto | ||
| { | ||
| Name = email.Split('@')[0], | ||
| Email = email, | ||
| Name = name ?? string.Empty, | ||
| Email = email ?? string.Empty, | ||
| IsAuthenticated = true, | ||
| Id = Request.Headers["Cf-Access-Authenticated-User-Id"].FirstOrDefault() ?? string.Empty | ||
| }); | ||
| } |
There was a problem hiding this comment.
GetJwtClaim is duplicated here and in CloudflareAccessMiddleware, and this controller re-parses headers/JWT even though the middleware already populates HttpContext.User with ClaimTypes.Email/ClaimTypes.Name. Consider reading User.FindFirstValue(...) in this endpoint and centralizing JWT claim extraction in one place to avoid divergence and double parsing.
| var email = context.Request.Headers["Cf-Access-Authenticated-User-Email"].FirstOrDefault(); | ||
| if (string.IsNullOrWhiteSpace(email)) | ||
| { | ||
| var jwtAssertion = context.Request.Headers["Cf-Access-Jwt-Assertion"].FirstOrDefault(); | ||
| email = GetJwtClaim(jwtAssertion, "email"); | ||
| } | ||
|
|
||
| if (string.IsNullOrWhiteSpace(email)) | ||
| { | ||
| context.Response.StatusCode = StatusCodes.Status401Unauthorized; | ||
| await context.Response.WriteAsync("Unauthorized: Cf-Access-Authenticated-User-Email header is missing."); | ||
| await context.Response.WriteAsync("Unauthorized: Cloudflare Access identity headers are missing."); | ||
| return; | ||
| } | ||
|
|
||
| var email = emailValues.ToString(); | ||
| var claims = new List<Claim> | ||
| { | ||
| new Claim(ClaimTypes.Email, email) | ||
| }; | ||
|
|
||
| var name = GetJwtClaim(context.Request.Headers["Cf-Access-Jwt-Assertion"].FirstOrDefault(), "name"); | ||
| if (!string.IsNullOrWhiteSpace(name)) | ||
| { | ||
| claims.Add(new Claim(ClaimTypes.Name, name)); | ||
| } | ||
|
|
There was a problem hiding this comment.
New behavior reads email/name from Cf-Access-Jwt-Assertion, but the existing middleware tests only cover the Cf-Access-Authenticated-User-Email header. Please add/extend tests to cover: (1) email resolved from JWT when the email header is missing, and (2) ClaimTypes.Name being added when the JWT contains a name claim, so regressions in the base64url parsing don’t silently break auth.
| using (var scope = host.Services.CreateScope()) | ||
| { | ||
| var userSession = scope.ServiceProvider.GetRequiredService<IUserSession>(); | ||
| await userSession.InitializeAsync(); | ||
| } |
There was a problem hiding this comment.
In Blazor WebAssembly, creating a new scope here will typically create a different scoped IUserSession instance than the one the app later injects, so the initialization may not actually prime the session used by AuthorizeView/pages. Resolve IUserSession from host.Services directly (or register it as a singleton) instead of using CreateScope() for this startup initialization.
| using (var scope = host.Services.CreateScope()) | |
| { | |
| var userSession = scope.ServiceProvider.GetRequiredService<IUserSession>(); | |
| await userSession.InitializeAsync(); | |
| } | |
| var userSession = host.Services.GetRequiredService<IUserSession>(); | |
| await userSession.InitializeAsync(); |
| private string DisplayName => | ||
| !string.IsNullOrWhiteSpace(UserSession.User?.Name) | ||
| ? UserSession.User!.Name! | ||
| : UserSession.User?.Email?.Split('@')[0] ?? "Driver"; |
There was a problem hiding this comment.
DisplayName can become an empty string when UserSession.User?.Email is present but blank (or doesn’t contain a usable local-part), because the Split('@')[0] result is "" and won’t trigger the ?? "Driver" fallback. Consider using a whitespace check on the computed local-part (or using IndexOf('@') + Substring) and falling back to "Driver" when the result is empty.
| private string DisplayName => | |
| !string.IsNullOrWhiteSpace(UserSession.User?.Name) | |
| ? UserSession.User!.Name! | |
| : UserSession.User?.Email?.Split('@')[0] ?? "Driver"; | |
| private string DisplayName | |
| { | |
| get | |
| { | |
| if (!string.IsNullOrWhiteSpace(UserSession.User?.Name)) | |
| { | |
| return UserSession.User!.Name!; | |
| } | |
| var localPart = UserSession.User?.Email?.Split('@')[0]; | |
| return string.IsNullOrWhiteSpace(localPart) ? "Driver" : localPart; | |
| } | |
| } |
| private string DisplayName => | ||
| !string.IsNullOrWhiteSpace(UserSession.User?.Name) | ||
| ? UserSession.User!.Name! | ||
| : UserSession.User?.Email ?? "User"; |
There was a problem hiding this comment.
DisplayName falls back to UserSession.User?.Email without a whitespace/empty check, so if the API returns an empty string (or a partially-populated user), the header will render Welcome, with a blank name. Consider using string.IsNullOrWhiteSpace(UserSession.User?.Email) in the fallback and/or normalizing UserSession.User.Email to null when empty.
| : UserSession.User?.Email ?? "User"; | |
| : !string.IsNullOrWhiteSpace(UserSession.User?.Email) | |
| ? UserSession.User!.Email! | |
| : "User"; |
| [Route("user")] | ||
| public class UsersController : ControllerBase | ||
| { | ||
| [HttpGet("me")] // Final: /users/me (when accessed directly or via Nginx stripping /api/) | ||
| [HttpGet("me")] | ||
| public ActionResult<UserDto> GetMe() | ||
| { | ||
| var email = Request.Headers["Cf-Access-Authenticated-User-Email"].FirstOrDefault(); | ||
| var jwtAssertion = Request.Headers["Cf-Access-Jwt-Assertion"].FirstOrDefault(); | ||
| var nameFromJwt = GetJwtClaim(jwtAssertion, "name"); | ||
| var emailFromJwt = GetJwtClaim(jwtAssertion, "email"); | ||
|
|
||
| if (string.IsNullOrEmpty(email)) | ||
| var email = emailFromJwt ?? Request.Headers["Cf-Access-Authenticated-User-Email"].FirstOrDefault(); | ||
| var name = nameFromJwt ?? email?.Split('@')[0]; |
There was a problem hiding this comment.
The API route was changed to [Route("user")] (so /api/user/me), but the Web app currently calls users/me (see F1.Web/Services/UserSession.cs), which will 404 and leave the UI unauthenticated. Please align the API route and the Web MeEndpoint so they match (either keep /user/me everywhere or use /users/me everywhere).
… header/home (#65)