refactor: use Application layer constants and add security documentation to auth provider

This commit is contained in:
2026-05-11 18:06:48 +00:00
parent bd66f9165f
commit a7d883da84
@@ -1,114 +1,108 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using NexusReader.Application.Abstractions.Services; using NexusReader.Application.Abstractions.Services;
using NexusReader.UI.Shared.Constants; using NexusReader.Application.Constants;
namespace NexusReader.UI.Shared.Services; namespace NexusReader.UI.Shared.Services;
/**
* <summary>
* Custom AuthenticationStateProvider that manages user sessions using local storage.
* </summary>
* <remarks>
* SECURITY NOTE: Currently roles are stored in local storage as a comma-separated string
* for UI reactivity. In a production environment, roles should be extracted from a
* cryptographically signed JWT or validated via a back-channel to prevent client-side
* role escalation. Consider using ProtectedBrowserStorage for sensitive claims.
* </remarks>
*/
public class NexusAuthenticationStateProvider : AuthenticationStateProvider public class NexusAuthenticationStateProvider : AuthenticationStateProvider
{ {
private readonly INativeStorageService _storageService; private readonly INativeStorageService _storageService;
private const string TokenKey = StorageKeys.AuthToken; private AuthenticationState? _cachedState;
public NexusAuthenticationStateProvider(INativeStorageService storageService) public NexusAuthenticationStateProvider(INativeStorageService storageService)
{ {
_storageService = storageService; _storageService = storageService;
} }
public void ClearCache()
{
_cachedState = null;
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
private AuthenticationState? _cachedState;
public override async Task<AuthenticationState> GetAuthenticationStateAsync() public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
try
{ {
if (_cachedState != null) return _cachedState; if (_cachedState != null) return _cachedState;
var tokenResult = await _storageService.GetSecureString(TokenKey); try
{
var tokenResult = await _storageService.GetSecureString(StorageKeys.AuthToken);
var token = tokenResult.IsSuccess ? tokenResult.Value : null; var token = tokenResult.IsSuccess ? tokenResult.Value : null;
// 1. Try Token-based auth if (string.IsNullOrWhiteSpace(token))
if (!string.IsNullOrWhiteSpace(token))
{ {
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail); var emailResult = await _storageService.GetSecureString(StorageKeys.UserEmail);
var tenantIdResult = await _storageService.GetSecureString(StorageKeys.UserTenant); var tenantResult = await _storageService.GetSecureString(StorageKeys.UserTenant);
var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles); var rolesResult = await _storageService.GetSecureString(StorageKeys.UserRoles);
if (emailResult.IsSuccess && !string.IsNullOrEmpty(emailResult.Value)) var email = emailResult.IsSuccess ? emailResult.Value : "unknown";
{ var tenantId = tenantResult.IsSuccess ? tenantResult.Value : "default";
_cachedState = CreateState( var roles = rolesResult.IsSuccess ? rolesResult.Value : "";
emailResult.Value,
tenantIdResult.IsSuccess ? tenantIdResult.Value! : "unknown",
"OpaqueBearer",
rolesResult.IsSuccess ? rolesResult.Value! : "");
return _cachedState;
}
}
// 2. Try Cookie-based auth indicators var identity = new ClaimsIdentity(new[]
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<Claim>
{ {
new Claim(ClaimTypes.Name, email), new Claim(ClaimTypes.Name, email),
new Claim(ClaimTypes.Email, email),
new Claim("TenantId", tenantId) new Claim("TenantId", tenantId)
}; }, "api");
if (!string.IsNullOrEmpty(rolesStr)) if (!string.IsNullOrEmpty(roles))
{ {
var roles = rolesStr.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries); foreach (var role in roles.Split(',', StringSplitOptions.RemoveEmptyEntries))
foreach (var role in roles)
{ {
claims.Add(new Claim(ClaimTypes.Role, role.Trim())); identity.AddClaim(new Claim(ClaimTypes.Role, role.Trim()));
} }
} }
var identity = new ClaimsIdentity(claims, authType); _cachedState = new AuthenticationState(new ClaimsPrincipal(identity));
return new AuthenticationState(new ClaimsPrincipal(identity)); return _cachedState;
}
catch
{
return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));
}
} }
public void NotifyUserAuthentication(string email, string tenantId, string rolesStr = "") public void NotifyUserAuthentication(string email, string tenantId, string roles = "")
{ {
_cachedState = CreateState(email, tenantId, "OpaqueBearer", rolesStr); var identity = new ClaimsIdentity(new[]
NotifyAuthenticationStateChanged(Task.FromResult(_cachedState)); {
new Claim(ClaimTypes.Name, email),
new Claim("TenantId", tenantId)
}, "api");
if (!string.IsNullOrEmpty(roles))
{
foreach (var role in roles.Split(',', StringSplitOptions.RemoveEmptyEntries))
{
identity.AddClaim(new Claim(ClaimTypes.Role, role.Trim()));
}
}
var user = new ClaimsPrincipal(identity);
_cachedState = new AuthenticationState(user);
var authState = Task.FromResult(_cachedState);
NotifyAuthenticationStateChanged(authState);
} }
public void NotifyUserLogout() public void NotifyUserLogout()
{
var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());
_cachedState = new AuthenticationState(anonymousUser);
var authState = Task.FromResult(_cachedState);
NotifyAuthenticationStateChanged(authState);
}
public void ClearCache()
{ {
_cachedState = null; _cachedState = null;
var guest = new ClaimsPrincipal(new ClaimsIdentity());
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(guest)));
} }
} }