-
Notifications
You must be signed in to change notification settings - Fork 0
feat(auth): load Cloudflare JWT user profile and show welcome name in… #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,30 +1,78 @@ | ||
| using F1.Core.Dtos; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using System.Text; | ||
| using System.Text.Json; | ||
|
|
||
|
|
||
| namespace F1.Api.Controllers; | ||
|
|
||
| [ApiController] | ||
| [Route("[controller]")] | ||
| [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]; | ||
|
|
||
| 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 | ||
| }); | ||
| } | ||
|
Comment on lines
+16
to
35
|
||
|
|
||
| private static string? GetJwtClaim(string? jwt, string claimName) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(jwt)) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| var parts = jwt.Split('.'); | ||
| if (parts.Length < 2) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| var payload = parts[1] | ||
| .Replace('-', '+') | ||
| .Replace('_', '/'); | ||
|
|
||
| var mod4 = payload.Length % 4; | ||
| if (mod4 > 0) | ||
| { | ||
| payload = payload.PadRight(payload.Length + (4 - mod4), '='); | ||
| } | ||
|
|
||
| var json = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); | ||
| using var doc = JsonDocument.Parse(json); | ||
|
|
||
| if (doc.RootElement.TryGetProperty(claimName, out var claimValue) && claimValue.ValueKind == JsonValueKind.String) | ||
| { | ||
| return claimValue.GetString(); | ||
| } | ||
| } | ||
| catch | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,6 @@ | ||
| using System.Security.Claims; | ||
| using System.Text; | ||
| using System.Text.Json; | ||
|
|
||
| namespace F1.Api.Middleware | ||
| { | ||
|
|
@@ -26,19 +28,31 @@ public async Task InvokeAsync(HttpContext context) | |
| } | ||
| } | ||
|
|
||
| if (!context.Request.Headers.TryGetValue("Cf-Access-Authenticated-User-Email", out var emailValues)) | ||
| 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)); | ||
| } | ||
|
|
||
|
Comment on lines
+31
to
+55
|
||
| if (string.Equals(email, AdminEmail, System.StringComparison.OrdinalIgnoreCase)) | ||
| { | ||
| claims.Add(new Claim(ClaimTypes.Role, "Admin")); | ||
|
|
@@ -49,5 +63,46 @@ public async Task InvokeAsync(HttpContext context) | |
|
|
||
| await _next(context); | ||
| } | ||
|
|
||
| private static string? GetJwtClaim(string? jwt, string claimName) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(jwt)) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| var parts = jwt.Split('.'); | ||
| if (parts.Length < 2) | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| var payload = parts[1] | ||
| .Replace('-', '+') | ||
| .Replace('_', '/'); | ||
|
|
||
| var mod4 = payload.Length % 4; | ||
| if (mod4 > 0) | ||
| { | ||
| payload = payload.PadRight(payload.Length + (4 - mod4), '='); | ||
| } | ||
|
|
||
| var json = Encoding.UTF8.GetString(Convert.FromBase64String(payload)); | ||
| using var doc = JsonDocument.Parse(json); | ||
|
|
||
| if (doc.RootElement.TryGetProperty(claimName, out var claimValue) && claimValue.ValueKind == JsonValueKind.String) | ||
| { | ||
| return claimValue.GetString(); | ||
| } | ||
| } | ||
| catch | ||
| { | ||
| return null; | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,7 +2,6 @@ | |||||||||
| @inject NavigationManager Navigation | ||||||||||
| @inject IUserSession UserSession | ||||||||||
| @inject IWebAssemblyHostEnvironment HostEnvironment | ||||||||||
| @inject HttpClient Http | ||||||||||
| @using System.Net.Http.Json | ||||||||||
|
|
||||||||||
| <div class="page"> | ||||||||||
|
|
@@ -35,7 +34,7 @@ | |||||||||
|
|
||||||||||
| <AuthorizeView> | ||||||||||
| <Authorized> | ||||||||||
| <span class="me-3">Hi, @context.User.Identity?.Name</span> | ||||||||||
| <span class="me-3">Welcome, @DisplayName</span> | ||||||||||
| <a href="https://f1-team.cloudflareaccess.com/cdn-cgi/access/logout" class="btn btn-sm btn-outline-danger">Logout</a> | ||||||||||
| </Authorized> | ||||||||||
| <NotAuthorized> | ||||||||||
|
|
@@ -65,16 +64,6 @@ | |||||||||
| { | ||||||||||
| try | ||||||||||
| { | ||||||||||
| // Construct an absolute URL based on the current browser address. | ||||||||||
| // If you are at http://localhost:5001, this makes the API http://localhost:5001/api/ | ||||||||||
| var absoluteApiUrl = Navigation.ToAbsoluteUri("api/"); | ||||||||||
|
|
||||||||||
| Http.BaseAddress = absoluteApiUrl; | ||||||||||
|
|
||||||||||
| Console.WriteLine($"🌐 API Base Address: {Http.BaseAddress}"); | ||||||||||
|
|
||||||||||
| await UserSession.InitializeAsync(); | ||||||||||
|
|
||||||||||
| // Version logic | ||||||||||
| using var localClient = new HttpClient { BaseAddress = new Uri(Navigation.BaseUri) }; | ||||||||||
| var versionModel = await localClient.GetFromJsonAsync<VersionModel>("version.json"); | ||||||||||
|
|
@@ -96,5 +85,10 @@ | |||||||||
| StateHasChanged(); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| private string DisplayName => | ||||||||||
| !string.IsNullOrWhiteSpace(UserSession.User?.Name) | ||||||||||
| ? UserSession.User!.Name! | ||||||||||
| : UserSession.User?.Email ?? "User"; | ||||||||||
|
||||||||||
| : UserSession.User?.Email ?? "User"; | |
| : !string.IsNullOrWhiteSpace(UserSession.User?.Email) | |
| ? UserSession.User!.Email! | |
| : "User"; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,7 +1,15 @@ | ||||||||||||||||||||||||||||||||||||
| @page "/" | ||||||||||||||||||||||||||||||||||||
| @inject F1.Web.Services.IUserSession UserSession | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| <PageTitle>Home</PageTitle> | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| <h1>Hello, Phil!</h1> | ||||||||||||||||||||||||||||||||||||
| <h1>Hello, @DisplayName!</h1> | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Welcome to your new app. | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| @code { | ||||||||||||||||||||||||||||||||||||
| private string DisplayName => | ||||||||||||||||||||||||||||||||||||
| !string.IsNullOrWhiteSpace(UserSession.User?.Name) | ||||||||||||||||||||||||||||||||||||
| ? UserSession.User!.Name! | ||||||||||||||||||||||||||||||||||||
| : UserSession.User?.Email?.Split('@')[0] ?? "Driver"; | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
+11
to
+14
|
||||||||||||||||||||||||||||||||||||
| 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; | |
| } | |
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -49,4 +49,13 @@ void ConfigureApi(IServiceProvider sp, HttpClient client) | |||||||||||||||
| builder.Services.AddAuthorizationCore(); | ||||||||||||||||
| builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>(); | ||||||||||||||||
|
|
||||||||||||||||
| await builder.Build().RunAsync(); | ||||||||||||||||
| var host = builder.Build(); | ||||||||||||||||
|
|
||||||||||||||||
| // Load the authenticated user session once at startup so layout/pages can render immediately. | ||||||||||||||||
| using (var scope = host.Services.CreateScope()) | ||||||||||||||||
| { | ||||||||||||||||
| var userSession = scope.ServiceProvider.GetRequiredService<IUserSession>(); | ||||||||||||||||
| await userSession.InitializeAsync(); | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+55
to
+59
|
||||||||||||||||
| using (var scope = host.Services.CreateScope()) | |
| { | |
| var userSession = scope.ServiceProvider.GetRequiredService<IUserSession>(); | |
| await userSession.InitializeAsync(); | |
| } | |
| var userSession = host.Services.GetRequiredService<IUserSession>(); | |
| await userSession.InitializeAsync(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The API route was changed to
[Route("user")](so/api/user/me), but the Web app currently callsusers/me(seeF1.Web/Services/UserSession.cs), which will 404 and leave the UI unauthenticated. Please align the API route and the WebMeEndpointso they match (either keep/user/meeverywhere or use/users/meeverywhere).