refactor: switch to cookie-based auth flow with JS interop fallback
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 7s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 15s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Deploy to Production / Build & Deploy to Production (push) Failing after 3m14s

Architecture shift:
- Primary: HTTP-only cookie authentication (server-side)
- Fallback: localStorage with JS interop for SPA

Changes:
1. CustomAuthenticationStateProvider:
   - Add IJSRuntime for direct localStorage access
   - Try JS interop first, fallback to LocalStorageService
   - Added detailed logging for auth debugging

2. Dashboard.razor:
   - Add @rendermode InteractiveWebAssembly (CLAUDE.md compliance)
   - Restore auth check with logging
   - Redirect to /login.html if not authenticated

3. Program.cs:
   - Reorder MapRazorComponents: WebAssembly first (default)
   - Add detailed logging to /api/auth/login cookie setup
   - Verify Set-Cookie headers are sent correctly

4. login.html:
   - Simplified to 2-second wait before redirect
   - localStorage as backup storage
   - Ready for cookie-based auth

Next steps:
- Verify Set-Cookie headers appear in responses
- Confirm cookie-based auth works end-to-end
- Test dashboard loads with cookie authentication

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 01:48:01 +09:00
parent eae0a68f06
commit bcd1cc0f93
7 changed files with 206 additions and 11 deletions
@@ -1,5 +1,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.JSInterop;
using QuantEngine.Web.Client.Services;
namespace QuantEngine.Web.Client.Infrastructure
@@ -8,30 +9,59 @@ namespace QuantEngine.Web.Client.Infrastructure
{
private readonly LocalStorageService _localStorage;
private readonly HttpClient _http;
private readonly IJSRuntime _jsRuntime;
private readonly ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity());
private const string TokenKey = "quant_admin_access_token";
private const string UsernameKey = "quant_admin_username";
private const string RoleKey = "quant_admin_role";
private const string RememberUsernameKey = "quant_admin_remember_username";
public CustomAuthenticationStateProvider(LocalStorageService localStorage, HttpClient http)
public CustomAuthenticationStateProvider(LocalStorageService localStorage, HttpClient http, IJSRuntime jsRuntime)
{
_localStorage = localStorage;
_http = http;
_jsRuntime = jsRuntime;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
try
{
var token = await _localStorage.GetAsync<string>(TokenKey);
var username = await _localStorage.GetAsync<string>(UsernameKey);
var role = await _localStorage.GetAsync<string>(RoleKey) ?? "Admin";
string token = null;
string username = null;
string role = null;
// Try to read from localStorage using JS interop (direct access)
try
{
token = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", TokenKey);
username = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", UsernameKey);
role = await _jsRuntime.InvokeAsync<string>("localStorage.getItem", RoleKey);
Console.WriteLine($"[Auth] JS interop: token={!string.IsNullOrWhiteSpace(token)}, username={username}");
}
catch (Exception jsEx)
{
Console.WriteLine($"[Auth] JS interop failed: {jsEx.Message}. Falling back to LocalStorageService...");
// Fallback to LocalStorageService
token = await _localStorage.GetAsync<string>(TokenKey);
username = await _localStorage.GetAsync<string>(UsernameKey);
role = await _localStorage.GetAsync<string>(RoleKey);
Console.WriteLine($"[Auth] LocalStorageService: token={!string.IsNullOrWhiteSpace(token)}, username={username}");
}
if (string.IsNullOrWhiteSpace(role))
{
role = "Admin";
}
if (!string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(username))
{
try
{
Console.WriteLine($"[Auth] Validating token with /api/auth/me...");
var request = new HttpRequestMessage(HttpMethod.Get, "api/auth/me");
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
var response = await _http.SendAsync(request);
@@ -43,7 +73,7 @@ namespace QuantEngine.Web.Client.Infrastructure
return new AuthenticationState(_anonymous);
}
Console.WriteLine($"[Auth] User authenticated: {username}");
Console.WriteLine($"[Auth] User authenticated: {username}");
var identity = new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.Name, username),
@@ -61,13 +91,12 @@ namespace QuantEngine.Web.Client.Infrastructure
}
else
{
Console.WriteLine("[Auth] No token or username found in localStorage");
Console.WriteLine($"[Auth] No token or username found. token={!string.IsNullOrWhiteSpace(token)}, username={!string.IsNullOrWhiteSpace(username)}");
}
}
catch (Exception ex)
{
Console.WriteLine($"[Auth] Error accessing localStorage: {ex.Message}");
// Return anonymous if localStorage isn't ready
}
return new AuthenticationState(_anonymous);
@@ -245,7 +245,7 @@
{
// Check authentication
var authState = await AuthStateProvider.GetAuthenticationStateAsync();
Console.WriteLine($"[Dashboard] Auth state received. IsAuthenticated: {authState.User.Identity?.IsAuthenticated}");
Console.WriteLine($"[Dashboard] Auth state: IsAuthenticated={authState.User.Identity?.IsAuthenticated}, Name={authState.User.Identity?.Name}");
if (!authState.User.Identity?.IsAuthenticated ?? true)
{
@@ -255,7 +255,7 @@
return;
}
Console.WriteLine($"[Dashboard] Authenticated as: {authState.User.Identity?.Name}");
Console.WriteLine($"[Dashboard] Authenticated as: {authState.User.Identity?.Name}");
try
{
+10 -2
View File
@@ -294,7 +294,9 @@ app.MapPost("/api/auth/login", async (JsonElement payload, HttpContext httpConte
});
// Set HTTP-only cookie for server-side authentication
// Note: Secure=true only in production (HTTPS); localhost uses HTTP
Console.WriteLine($"[Auth/Login] Setting cookie 'quant_auth_token'");
Console.WriteLine($"[Auth/Login] IsHttps: {httpContext.Request.IsHttps}");
httpContext.Response.Cookies.Append(
"quant_auth_token",
rawToken,
@@ -308,8 +310,11 @@ app.MapPost("/api/auth/login", async (JsonElement payload, HttpContext httpConte
}
);
Console.WriteLine($"[Auth/Login] Cookie append completed");
Console.WriteLine($"[Auth/Login] Response headers count: {httpContext.Response.Headers.Count}");
// Also return token for localStorage backup (for SPA navigation)
return Results.Ok(new
var result = Results.Ok(new
{
success = true,
username = account.Username,
@@ -317,6 +322,9 @@ app.MapPost("/api/auth/login", async (JsonElement payload, HttpContext httpConte
accessToken = rawToken,
expiresAt = expiresAt.ToString("O")
});
Console.WriteLine($"[Auth/Login] About to return 200 OK response");
return result;
}).DisableAntiforgery();
app.MapGet("/api/auth/me", async (HttpContext context, IWorkspaceRepository workspaceRepo) =>