Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 54 additions & 6 deletions src/F1.Api/Controllers/UsersController.cs
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];
Comment on lines +10 to +21
Copy link

Copilot AI Mar 6, 2026

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 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).

Copilot uses AI. Check for mistakes.

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
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

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;
}

}
61 changes: 58 additions & 3 deletions src/F1.Api/Middleware/CloudflareAccessMiddleware.cs
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
{
Expand Down Expand Up @@ -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
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
if (string.Equals(email, AdminEmail, System.StringComparison.OrdinalIgnoreCase))
{
claims.Add(new Claim(ClaimTypes.Role, "Admin"));
Expand All @@ -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;
}
}
}
18 changes: 6 additions & 12 deletions src/F1.Web/Layout/MainLayout.razor
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
@inject NavigationManager Navigation
@inject IUserSession UserSession
@inject IWebAssemblyHostEnvironment HostEnvironment
@inject HttpClient Http
@using System.Net.Http.Json

<div class="page">
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -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");
Expand All @@ -96,5 +85,10 @@
StateHasChanged();
}

private string DisplayName =>
!string.IsNullOrWhiteSpace(UserSession.User?.Name)
? UserSession.User!.Name!
: UserSession.User?.Email ?? "User";
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
: UserSession.User?.Email ?? "User";
: !string.IsNullOrWhiteSpace(UserSession.User?.Email)
? UserSession.User!.Email!
: "User";

Copilot uses AI. Check for mistakes.

private class VersionModel { public string? Version { get; set; } }
}
3 changes: 3 additions & 0 deletions src/F1.Web/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
}
10 changes: 9 additions & 1 deletion src/F1.Web/Pages/Home.razor
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
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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;
}
}

Copilot uses AI. Check for mistakes.
}
11 changes: 10 additions & 1 deletion src/F1.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
using (var scope = host.Services.CreateScope())
{
var userSession = scope.ServiceProvider.GetRequiredService<IUserSession>();
await userSession.InitializeAsync();
}
var userSession = host.Services.GetRequiredService<IUserSession>();
await userSession.InitializeAsync();

Copilot uses AI. Check for mistakes.

await host.RunAsync();
9 changes: 9 additions & 0 deletions src/F1.Web/Services/CustomAuthenticationStateProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ public override Task<AuthenticationState> 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"));
Expand Down
Loading