using Blazored.LocalStorage;
using decePubClient.Models;
using Microsoft.AspNetCore.Components.Authorization;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
using System.Security.Claims;
namespace decePubClient.Services
{
///
/// The session (AuthData in localStorage, cached in memory) and the authentication state built from it. It only
/// stores and hands out tokens; signing in, exchanging and revoking them is AuthService's job.
///
public class TokenAuthStateProvider(ILocalStorageService storage, ILogger logger)
: AuthenticationStateProvider
{
/// The claim naming the persona the client acts as (an avatar id).
public const string PersonaClaim = "persona";
AuthData authData;
bool loaded;
/// The personas PrivaPub refused a token to while the JWT was good (suspended, or gone): no request acts as
/// them again until the reader picks one anew or the session is read again.
readonly HashSet refusedPersonas = [];
/// Raised once for a persona PrivaPub refused a token to (Refuse); CascadingState moves the session on.
public event Action PersonaRefused;
/// Raised with a persona's id when its token is replaced or forgotten: a Web Push subscription made with the
/// old one ended with it (PushService subscribes the persona again with its new one).
public event Action PersonaTokenChanged;
/// The stored session, or an empty one.
public async Task GetAuthData(CancellationToken cancellationToken = default)
{
if (loaded)
return authData ?? new();
authData = await storage.GetItemAsync(nameof(AuthData), cancellationToken);
loaded = true;
return authData ?? new();
}
/// The root's JWT while it is still valid, otherwise null.
public async Task GetToken(CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
if (string.IsNullOrEmpty(session.Token) || session.TokenExpiration is not { } expiration || expiration <= DateTime.UtcNow.Ticks)
return null;
return session.Token;
}
/// The current persona's Mastodon token, or null when the persona has none yet.
public async Task GetPersonaToken(CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
if (session.PersonaId is null)
return null;
return session.PersonaTokens.GetValueOrDefault(session.PersonaId);
}
/// That persona's Mastodon token, or null when it has none yet; never another persona's.
public async Task GetPersonaToken(string personaId, CancellationToken cancellationToken = default)
{
if (personaId is null)
return null;
var session = await GetAuthData(cancellationToken);
return session.PersonaTokens.GetValueOrDefault(personaId);
}
public async Task IsAuthenticatedAsync(CancellationToken cancellationToken = default) =>
await GetToken(cancellationToken) is not null;
/// Starts or renews the session with a JWT from login or sniff/again; persona tokens are kept.
public async Task SetSession(JwtUser jwtUser, CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
session.Token = jwtUser.Token;
session.TokenExpiration = jwtUser.Expiration;
session.TokenIssuedAt = DateTime.UtcNow.Ticks;
session.UserName = jwtUser.Username;
session.Policies = jwtUser.Policies ?? [];
if (jwtUser.UserSettings?.LanguageCode is { Length: > 0 } language)
session.CurrentLanguageCode = language;
await Save(session, cancellationToken);
}
/// Makes the persona current, remembering its token when one is given.
public async Task SetPersona(string personaId, string token = default, CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
session.PersonaId = personaId;
var changed = token is not null && session.PersonaTokens.GetValueOrDefault(personaId) != token;
if (token is not null)
session.PersonaTokens[personaId] = token;
await Save(session, cancellationToken);
if (changed)
PersonaTokenChanged?.Invoke(personaId);
}
/// Remembers a persona's new token without making it current (a request as another persona than the one
/// the session acts as, HttpService's *As).
public async Task SetPersonaToken(string personaId, string token, CancellationToken cancellationToken = default)
{
if (personaId is null || token is null)
return;
var session = await GetAuthData(cancellationToken);
if (session.PersonaTokens.GetValueOrDefault(personaId) == token)
return;
session.PersonaTokens[personaId] = token;
await Save(session, cancellationToken);
PersonaTokenChanged?.Invoke(personaId);
}
/// Drops a persona's token that PrivaPub no longer accepts.
public async Task ForgetPersonaToken(string personaId, CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
if (!session.PersonaTokens.Remove(personaId))
return;
await Save(session, cancellationToken);
PersonaTokenChanged?.Invoke(personaId);
}
/// Whether PrivaPub refused the persona a token in this session (Refuse).
public bool IsRefused(string personaId) => personaId is not null && refusedPersonas.Contains(personaId);
/// PrivaPub refused the persona a token while the JWT was good: no request acts as it from now on, and
/// PersonaRefused tells the session, once.
public void Refuse(string personaId)
{
if (personaId is null || !refusedPersonas.Add(personaId))
return;
PersonaRefused?.Invoke(personaId);
}
/// The persona may be tried again: the reader picked it, or PrivaPub lists it as usable again.
public void Unrefuse(string personaId)
{
if (personaId is not null)
refusedPersonas.Remove(personaId);
}
/// Drops every persona's token: PrivaPub revoked them all (a password change), and each is exchanged anew when used.
public async Task ForgetPersonaTokens(CancellationToken cancellationToken = default)
{
var session = await GetAuthData(cancellationToken);
if (session.PersonaTokens.Count == 0)
return;
var forgotten = session.PersonaTokens.Keys.ToList();
session.PersonaTokens.Clear();
await Save(session, cancellationToken);
foreach (var personaId in forgotten)
PersonaTokenChanged?.Invoke(personaId);
}
public async Task LogoutAsync(CancellationToken cancellationToken = default)
{
logger.LogInformation($"set null({nameof(LogoutAsync)})");
authData = null;
loaded = true;
refusedPersonas.Clear();
await storage.RemoveItemAsync(nameof(AuthData), cancellationToken);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
public override async Task GetAuthenticationStateAsync()
{
var token = await GetToken();
if (token is null)
return new(new());
var session = await GetAuthData();
var claims = new List { new(ClaimTypes.Name, session.UserName ?? string.Empty) };
foreach (var policy in new[] { Policies.IsUser, Policies.IsModerator, Policies.IsAdmin })
claims.Add(new(policy, session.Policies.Contains(policy) ? "true" : "false"));
if (session.PersonaId is not null)
claims.Add(new(PersonaClaim, session.PersonaId));
return new(new(new ClaimsIdentity(claims, "jwt")));
}
async Task Save(AuthData session, CancellationToken cancellationToken)
{
authData = session;
loaded = true;
await storage.SetItemAsync(nameof(AuthData), session, cancellationToken);
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
}
}