2026-10-04 11:03:14 +02:00
|
|
|
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
|
|
|
|
|
{
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// 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.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public class AuthService(IHttpService httpService, TokenAuthStateProvider authStateProvider, AppConfiguration appConfiguration,
|
|
|
|
|
ILoggingService logger, CoalescingStringLocalizer localizer)
|
|
|
|
|
{
|
|
|
|
|
/// <summary>The JWT is renewed (sniff/again) once less than this, or a quarter of its lifetime, is left.</summary>
|
|
|
|
|
static readonly TimeSpan RenewBefore = TimeSpan.FromDays(2);
|
|
|
|
|
|
|
|
|
|
/// <summary>Signs the root in; the JWT never goes with the request (PrivaPub redirects a login that carries one).</summary>
|
|
|
|
|
public async Task<WebResult> 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<JwtUser>(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 };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>The root's personas.</summary>
|
|
|
|
|
public async Task<List<ViewAvatar>> 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<List<ViewAvatar>>(cancellationToken) ?? [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 06:20:18 +02:00
|
|
|
/// <summary>Makes a new persona for the root; Data is the persona. No other persona is linked to it, anywhere.</summary>
|
|
|
|
|
public async Task<WebResult> CreatePersona(InsertAvatarForm form, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Post(APIs.ClientApi.POST_InsertPersona, form, cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
|
|
|
|
|
return new WebResult { Data = await response.DefaultReadFromJsonAsync<ViewAvatar>(cancellationToken) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 11:03:14 +02:00
|
|
|
/// <summary>Acts as the persona from now on, exchanging a token for it unless it has one.</summary>
|
|
|
|
|
public async Task<WebResult> 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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>The current persona's Mastodon account, or null.</summary>
|
|
|
|
|
public async Task<Account> 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<Account>(cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Renews the JWT when it is about to expire; persona tokens do not expire and are kept.</summary>
|
|
|
|
|
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<JwtUser>(cancellationToken);
|
|
|
|
|
if (!string.IsNullOrEmpty(jwtUser?.Token))
|
|
|
|
|
await authStateProvider.SetSession(jwtUser, cancellationToken);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-05 18:32:47 +02:00
|
|
|
/// <summary>Changes the root's password, the current one given.</summary>
|
|
|
|
|
public async Task<WebResult> ChangePassword(UserPasswordForm form, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Post(APIs.ClientApi.POST_UpdatePassword, form, cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
|
|
|
|
|
return new WebResult();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Deletes the root, its password given: every persona of it and everything public they had, here and, as
|
|
|
|
|
/// PrivaPub tells them, on the servers that hold it. The caller signs out afterwards.</summary>
|
|
|
|
|
public async Task<WebResult> DeleteAccount(RemoveSelfForm form, CancellationToken cancellationToken = default)
|
|
|
|
|
{
|
|
|
|
|
using var response = await httpService.Post(APIs.ClientApi.POST_DeleteAccount, form, cancellationToken: cancellationToken);
|
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
|
|
|
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
|
|
|
|
|
|
|
|
|
return new WebResult();
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-04 11:03:14 +02:00
|
|
|
/// <summary>Revokes every persona token the client holds, tells PrivaPub, then forgets the session and the local data.</summary>
|
|
|
|
|
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<string, string>
|
|
|
|
|
{
|
|
|
|
|
["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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|