using System.Net; using collAnon.Client.Services; using decePubClient.Extensions; using decePubClient.Helpers; using decePubClient.Models; using decePubClient.Models.Mastodon; using PrivaPub.ClientModels; using PrivaPub.ClientModels.User; using PrivaPub.ClientModels.User.Avatar; namespace decePubClient.Services { /// /// One sign-in for both of PrivaPub's APIs: the root signs in on /clientapi, then each persona it uses gets its own /// Mastodon token in exchange for the JWT (PrivaPub's PersonaExchange), so the Mastodon API never learns the root. /// Results are WebResults, lists come back empty on failure; nothing throws. /// public class AuthService(IHttpService httpService, TokenAuthStateProvider authStateProvider, AppConfiguration appConfiguration, ILoggingService logger, CoalescingStringLocalizer localizer) { /// The JWT is renewed (sniff/again) once less than this, or a quarter of its lifetime, is left. static readonly TimeSpan RenewBefore = TimeSpan.FromDays(2); /// Signs the root in; the JWT never goes with the request (PrivaPub redirects a login that carries one). public async Task Login(LoginForm form, CancellationToken cancellationToken = default) { using var response = await httpService.PostTotallyAnon(APIs.ClientApi.POST_Login, form, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); var jwtUser = await response.DefaultReadFromJsonAsync(cancellationToken); if (string.IsNullOrEmpty(jwtUser?.Token)) return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway); await authStateProvider.SetSession(jwtUser, cancellationToken); return new WebResult { Data = jwtUser }; } /// The root's personas. public async Task> Personas(CancellationToken cancellationToken = default) { using var response = await httpService.Get(APIs.ClientApi.GET_Personas, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) { await LogFailure(response, nameof(Personas), cancellationToken); return []; } return await response.DefaultReadFromJsonAsync>(cancellationToken) ?? []; } /// Acts as the persona from now on, exchanging a token for it unless it has one. public async Task UsePersona(string personaId, CancellationToken cancellationToken = default) { var session = await authStateProvider.GetAuthData(cancellationToken); if (session.PersonaTokens.ContainsKey(personaId)) { await authStateProvider.SetPersona(personaId, cancellationToken: cancellationToken); return new(); } using var response = await httpService.ExchangePersonaToken(personaId, cancellationToken); return response.IsSuccessStatusCode ? new() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); } /// The current persona's Mastodon account, or null. public async Task CurrentAccount(CancellationToken cancellationToken = default) { using var response = await httpService.Get(APIs.Mastodon.GET_VerifyCredentials, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) { await LogFailure(response, nameof(CurrentAccount), cancellationToken); return null; } return await response.MastodonReadFromJsonAsync(cancellationToken); } /// Renews the JWT when it is about to expire; persona tokens do not expire and are kept. public async Task RefreshIfNeeded(CancellationToken cancellationToken = default) { var session = await authStateProvider.GetAuthData(cancellationToken); if (session.TokenExpiration is not { } expiration) return; var lifetime = session.TokenIssuedAt is { } issued ? TimeSpan.FromTicks(expiration - issued) : RenewBefore * 4; var renewBefore = lifetime / 4 < RenewBefore ? lifetime / 4 : RenewBefore; if (new DateTime(expiration, DateTimeKind.Utc) - DateTime.UtcNow > renewBefore) return; if (await authStateProvider.GetToken(cancellationToken) is null) return; using var response = await httpService.Get(APIs.ClientApi.GET_SniffAgain, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) { await LogFailure(response, nameof(RefreshIfNeeded), cancellationToken); return; } var jwtUser = await response.DefaultReadFromJsonAsync(cancellationToken); if (!string.IsNullOrEmpty(jwtUser?.Token)) await authStateProvider.SetSession(jwtUser, cancellationToken); } /// Revokes every persona token the client holds, tells PrivaPub, then forgets the session and the local data. public async Task Logout(CancellationToken cancellationToken = default) { var session = await authStateProvider.GetAuthData(cancellationToken); foreach (var token in session.PersonaTokens.Values) { using var revoked = await httpService.PostTotallyAnon(APIs.OAuth.POST_Revoke, new Dictionary { ["token"] = token, ["client_id"] = appConfiguration.ClientId }, cancellationToken: cancellationToken); if (!revoked.IsSuccessStatusCode) await LogFailure(revoked, nameof(Logout), cancellationToken); } if (await authStateProvider.GetToken(cancellationToken) is not null) { using var response = await httpService.Get(APIs.ClientApi.GET_Logout, cancellationToken: cancellationToken); if (!response.IsSuccessStatusCode) await LogFailure(response, nameof(Logout), cancellationToken); } await authStateProvider.LogoutAsync(deleteDb: true, cancellationToken); } async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken) { var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); if (result.StatusCode is not (int)HttpStatusCode.Gone) await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AuthService)}.{caller}", cancellationToken); } } }