using System.Security.Claims; using System.Text.Json; using Microsoft.AspNetCore.Components.Authorization; using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Constants; using Microsoft.AspNetCore.Components; namespace NexusReader.UI.Shared.Services; public class NexusAuthenticationStateProvider : AuthenticationStateProvider { private readonly INativeStorageService _storageService; private readonly PersistentComponentState _persistentState; // SECURITY NOTE: We currently store roles in local storage to persist state across refreshes. // In a production SaaS environment, consider using ProtectedBrowserStorage (Blazor Server) // or encrypted storage/JWT claims validation to prevent client-side role tampering. private const string TokenKey = StorageKeys.AuthToken; public NexusAuthenticationStateProvider(INativeStorageService storageService, PersistentComponentState persistentState) { _storageService = storageService; _persistentState = persistentState; } public void ClearCache() { _cachedState = null; NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); } private AuthenticationState? _cachedState; public override async Task GetAuthenticationStateAsync() { try { if (_cachedState != null) return _cachedState; // 0. Hydrate state from SSR if available in PersistentComponentState if (_persistentState.TryTakeFromJson("UserInfo", out var userInfo) && userInfo != null) { // Save to local storage for subsequent client-only transitions/refreshes await _storageService.SaveSecureString(StorageKeys.UserEmail, userInfo.Email); await _storageService.SaveSecureString(StorageKeys.UserTenant, userInfo.TenantId); await _storageService.SaveSecureString(StorageKeys.UserRoles, userInfo.Roles); _cachedState = CreateState(userInfo.Email, userInfo.TenantId, "FederatedHydration", userInfo.Roles); return _cachedState; } var tokenResult = await _storageService.GetSecureString(TokenKey); var token = tokenResult.IsSuccess ? tokenResult.Value : null; // 1. Try Token-based auth if (!string.IsNullOrWhiteSpace(token) && !JwtTokenValidator.IsExpired(token)) { var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail); var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant); var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles); if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value)) { _cachedState = CreateState( emailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "OpaqueBearer", rolesResult.IsSuccess ? rolesResult.Value! : ""); return _cachedState; } } // 2. Try Cookie-based auth indicators var storedEmailResult = await _storageService.GetSecureString(StorageKeys.UserEmail); if (storedEmailResult.IsSuccess && !string.IsNullOrEmpty(storedEmailResult.Value)) { var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant); var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles); _cachedState = CreateState( storedEmailResult.Value, tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown", "CookieAuth", rolesResult.IsSuccess ? rolesResult.Value! : ""); return _cachedState; } // 3. Fallback: If we have no local info, we might still have a cookie (e.g. after refresh or Google login). // We should return anonymous for now but trigger a background check if we're in WASM. // Wait! In WASM, the first GetAuthenticationStateAsync is awaited. // We can do a quick check here if it's the first time. return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); } catch (Exception) { return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity())); } } private AuthenticationState CreateState(string email, string tenantId, string authType, string rolesStr = "") { var claims = new List { new Claim(ClaimTypes.Name, email), new Claim(ClaimTypes.Email, email), new Claim("TenantId", tenantId) }; if (!string.IsNullOrEmpty(rolesStr)) { var roles = rolesStr.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); foreach (var role in roles) { claims.Add(new Claim(ClaimTypes.Role, role.Trim())); } } var identity = new ClaimsIdentity(claims, authType); return new AuthenticationState(new ClaimsPrincipal(identity)); } public void NotifyUserAuthentication(string email, string tenantId, string rolesStr = "") { _cachedState = CreateState(email, tenantId, "OpaqueBearer", rolesStr); NotifyAuthenticationStateChanged(Task.FromResult(_cachedState)); } public void NotifyUserLogout() { _cachedState = null; var guest = new ClaimsPrincipal(new ClaimsIdentity()); NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest))); } } public class UserInfo { public string Email { get; set; } = string.Empty; public string TenantId { get; set; } = string.Empty; public string Roles { get; set; } = string.Empty; }