diff --git a/src/F1.Api/Controllers/UsersController.cs b/src/F1.Api/Controllers/UsersController.cs index 0bb2c41..741402e 100644 --- a/src/F1.Api/Controllers/UsersController.cs +++ b/src/F1.Api/Controllers/UsersController.cs @@ -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 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 }); } + 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; + } + } diff --git a/src/F1.Api/Middleware/CloudflareAccessMiddleware.cs b/src/F1.Api/Middleware/CloudflareAccessMiddleware.cs index 444e8bb..22455d2 100644 --- a/src/F1.Api/Middleware/CloudflareAccessMiddleware.cs +++ b/src/F1.Api/Middleware/CloudflareAccessMiddleware.cs @@ -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 { 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)); + } + 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; + } } } diff --git a/src/F1.Web/Layout/MainLayout.razor b/src/F1.Web/Layout/MainLayout.razor index e6b1d53..2428a40 100644 --- a/src/F1.Web/Layout/MainLayout.razor +++ b/src/F1.Web/Layout/MainLayout.razor @@ -2,7 +2,6 @@ @inject NavigationManager Navigation @inject IUserSession UserSession @inject IWebAssemblyHostEnvironment HostEnvironment -@inject HttpClient Http @using System.Net.Http.Json
@@ -35,7 +34,7 @@ - Hi, @context.User.Identity?.Name + Welcome, @DisplayName Logout @@ -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("version.json"); @@ -96,5 +85,10 @@ StateHasChanged(); } + private string DisplayName => + !string.IsNullOrWhiteSpace(UserSession.User?.Name) + ? UserSession.User!.Name! + : UserSession.User?.Email ?? "User"; + private class VersionModel { public string? Version { get; set; } } } \ No newline at end of file diff --git a/src/F1.Web/Models/User.cs b/src/F1.Web/Models/User.cs index 47d60a5..b1b27d3 100644 --- a/src/F1.Web/Models/User.cs +++ b/src/F1.Web/Models/User.cs @@ -2,7 +2,10 @@ namespace F1.Web.Models { public class User { + public string? Name { get; set; } public string? Email { get; set; } public bool IsAdmin { get; set; } + public bool IsAuthenticated { get; set; } + public string? Id { get; set; } } } diff --git a/src/F1.Web/Pages/Home.razor b/src/F1.Web/Pages/Home.razor index 2301837..fcb99bf 100644 --- a/src/F1.Web/Pages/Home.razor +++ b/src/F1.Web/Pages/Home.razor @@ -1,7 +1,15 @@ @page "/" +@inject F1.Web.Services.IUserSession UserSession Home -

Hello, Phil!

+

Hello, @DisplayName!

Welcome to your new app. + +@code { + private string DisplayName => + !string.IsNullOrWhiteSpace(UserSession.User?.Name) + ? UserSession.User!.Name! + : UserSession.User?.Email?.Split('@')[0] ?? "Driver"; +} diff --git a/src/F1.Web/Program.cs b/src/F1.Web/Program.cs index 6beb6b2..7b6721a 100644 --- a/src/F1.Web/Program.cs +++ b/src/F1.Web/Program.cs @@ -49,4 +49,13 @@ void ConfigureApi(IServiceProvider sp, HttpClient client) builder.Services.AddAuthorizationCore(); builder.Services.AddScoped(); -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(); + await userSession.InitializeAsync(); +} + +await host.RunAsync(); diff --git a/src/F1.Web/Services/CustomAuthenticationStateProvider.cs b/src/F1.Web/Services/CustomAuthenticationStateProvider.cs index 490eed9..709559e 100644 --- a/src/F1.Web/Services/CustomAuthenticationStateProvider.cs +++ b/src/F1.Web/Services/CustomAuthenticationStateProvider.cs @@ -32,6 +32,15 @@ public override Task GetAuthenticationStateAsync() new Claim(ClaimTypes.Email, _userSession.User.Email) }; + var displayName = string.IsNullOrWhiteSpace(_userSession.User.Name) + ? _userSession.User.Email + : _userSession.User.Name; + + if (!string.IsNullOrWhiteSpace(displayName)) + { + claims.Add(new Claim(ClaimTypes.Name, displayName)); + } + if (_userSession.User.IsAdmin) { claims.Add(new Claim(ClaimTypes.Role, "Admin"));