R9 Web Push in decePub: one subscription per persona, each with its own token and registration
Build / Build (push) Successful in 56s
Build / Build (push) Successful in 56s
Settings gets "Notifications on this device" (PushEditor): personas, alerts in four groups, policy, a test
notification, after a one-time notice of what the push service learns. Each persona ticked gets its own service
worker registration (push-worker.js at push/{slot}/), so its own endpoint and keys, subscribed with its own token
(IHttpService's *As); nothing PrivaPub stores ties two personas together and the worker never reads the payload's
token. PushService follows the tokens (re-subscribing after a renewal), reconciles at every session start, turns
off before sign-out and drops deleted or suspended personas. A click opens that persona's notifications; the app
badge counts the unread ones. Exchanges now ask for the push scope.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
d33697e3fc
commit
ab17959ed4
29 files changed
+2047
-67
No files matched your search
@@ -161,6 +161,35 @@ namespace decePubClient.Services
|
||||
return response.IsSuccessStatusCode ? new() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Revokes the persona's token and forgets it, so the next request as the persona exchanges a new one with the scopes
|
||||
/// the client asks for now (APIs.OAuth.Scopes): a token exchanged before the client asked for push can't subscribe to
|
||||
/// Web Push (PrivaPub's 403). The persona stays current if it was.
|
||||
/// </summary>
|
||||
public async Task<WebResult> RenewPersonaToken(string personaId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = new WebResult();
|
||||
var session = await authStateProvider.GetAuthData(cancellationToken);
|
||||
if (session.PersonaTokens.GetValueOrDefault(personaId) is not { Length: > 0 } token)
|
||||
return result;
|
||||
|
||||
// forgotten first, so no request uses it while it is revoked
|
||||
await authStateProvider.ForgetPersonaToken(personaId, cancellationToken);
|
||||
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)
|
||||
{
|
||||
result = await revoked.Content.ReadWebResult(revoked.StatusCode, localizer, cancellationToken);
|
||||
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AuthService)}.{nameof(RenewPersonaToken)}", cancellationToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>The current persona's Mastodon account, or null.</summary>
|
||||
public async Task<Account> CurrentAccount(CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
+96
-27
@@ -20,7 +20,8 @@ namespace decePubClient.Services
|
||||
/// Nothing throws: a failure comes back as a response carrying an invalid WebResult (SUtility.ClassifiedFailure, read
|
||||
/// with ExtensionMethods.ReadWebResult), a cancelled request as HTTP 410. With retryOnError, 408/429/502/503/504 and
|
||||
/// transport errors are retried after 1, 2, 4, 8, 16 and 29 seconds, then every 60, plus jitter, for at most 10
|
||||
/// minutes. Every request PrivaPub answers is recorded in RequestStats.
|
||||
/// minutes. Every request PrivaPub answers is recorded in RequestStats. The *As methods send a Mastodon API request as
|
||||
/// a given persona of the login, whichever the session acts as: with that persona's own token, never another's.
|
||||
/// </summary>
|
||||
public interface IHttpService
|
||||
{
|
||||
@@ -48,6 +49,22 @@ namespace decePubClient.Services
|
||||
/// current. A failure comes back like any other.
|
||||
/// </summary>
|
||||
Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>A Mastodon API GET as that persona (its token exchanged when missing, without making it current).</summary>
|
||||
Task<HttpResponseMessage> GetAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>A Mastodon API POST as that persona (its token exchanged when missing, without making it current).</summary>
|
||||
Task<HttpResponseMessage> PostAs(string personaId, string uri, object payload = default, bool retryOnError = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>A Mastodon API PUT as that persona (its token exchanged when missing, without making it current).</summary>
|
||||
Task<HttpResponseMessage> PutAs(string personaId, string uri, object payload = default, bool retryOnError = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>A Mastodon API DELETE as that persona (its token exchanged when missing, without making it current).</summary>
|
||||
Task<HttpResponseMessage> DeleteAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class HttpService(IHttpClientFactory httpClientFactory, TokenAuthStateProvider authStateProvider, AppStatusService statusService,
|
||||
@@ -91,10 +108,40 @@ namespace decePubClient.Services
|
||||
Send(nameof(Delete), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAuthRequest, retryOnError, cancellationToken);
|
||||
|
||||
public async Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var (response, token) = await RequestPersonaToken(personaId, cancellationToken);
|
||||
if (token is not null)
|
||||
await authStateProvider.SetPersona(personaId, token, cancellationToken);
|
||||
return response;
|
||||
}
|
||||
|
||||
public Task<HttpResponseMessage> GetAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Send(nameof(GetAs), () => BuildRequest(HttpMethod.Get, uri, queryParams: queryParams), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
|
||||
retryOnError, cancellationToken);
|
||||
|
||||
public Task<HttpResponseMessage> PostAs(string personaId, string uri, object payload = default, bool retryOnError = false,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Send(nameof(PostAs), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
|
||||
retryOnError, cancellationToken);
|
||||
|
||||
public Task<HttpResponseMessage> PutAs(string personaId, string uri, object payload = default, bool retryOnError = false,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Send(nameof(PutAs), () => BuildRequest(HttpMethod.Put, uri, payload, forceContent: true), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
|
||||
retryOnError, cancellationToken);
|
||||
|
||||
public Task<HttpResponseMessage> DeleteAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Send(nameof(DeleteAs), () => BuildRequest(HttpMethod.Delete, uri, queryParams: queryParams), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
|
||||
retryOnError, cancellationToken);
|
||||
|
||||
/// <summary>The exchange itself (RFC 8693 on /oauth/token, PrivaPub's PersonaExchange): the response, and the persona's
|
||||
/// new token when PrivaPub gave one. Nothing is stored here.</summary>
|
||||
async Task<(HttpResponseMessage Response, string Token)> RequestPersonaToken(string personaId, CancellationToken cancellationToken)
|
||||
{
|
||||
var jwt = await authStateProvider.GetToken(cancellationToken);
|
||||
if (jwt is null)
|
||||
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
|
||||
return (SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]), null);
|
||||
|
||||
var response = await PostTotallyAnon(APIs.OAuth.POST_Token, new Dictionary<string, string>
|
||||
{
|
||||
@@ -106,14 +153,16 @@ namespace decePubClient.Services
|
||||
["scope"] = APIs.OAuth.Scopes
|
||||
}, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return response;
|
||||
return (response, null);
|
||||
|
||||
var token = await response.Content.ReadFromJsonAsync<TokenResponse>(SUtility.MastodonSerializer, cancellationToken);
|
||||
if (string.IsNullOrEmpty(token?.AccessToken))
|
||||
return SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server sent an invalid response."], HttpStatusCode.BadGateway);
|
||||
{
|
||||
response.Dispose();
|
||||
return (SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server sent an invalid response."], HttpStatusCode.BadGateway), null);
|
||||
}
|
||||
|
||||
await authStateProvider.SetPersona(personaId, token.AccessToken, cancellationToken);
|
||||
return response;
|
||||
return (response, token.AccessToken);
|
||||
}
|
||||
|
||||
async Task<HttpResponseMessage> Send(string caller, Func<HttpRequestMessage> requestFactory,
|
||||
@@ -217,7 +266,7 @@ namespace decePubClient.Services
|
||||
{
|
||||
var request = requestFactory();
|
||||
if (IsMastodon(request))
|
||||
return await SendAsPersona(request, requestFactory, cancellationToken);
|
||||
return await SendAsPersona((await authStateProvider.GetAuthData(cancellationToken)).PersonaId, request, requestFactory, cancellationToken);
|
||||
|
||||
var token = await authStateProvider.GetToken(cancellationToken);
|
||||
if (token is null)
|
||||
@@ -236,24 +285,39 @@ namespace decePubClient.Services
|
||||
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Mastodon API request as the current persona. A missing token is exchanged first; a refused one (revoked,
|
||||
/// or PrivaPub's database was reset) is exchanged once more and the request sent again. Without a usable JWT the
|
||||
/// session ends. A persona PrivaPub refuses a token to while the JWT is good (suspended by the staff, or gone) is
|
||||
/// marked refused (TokenAuthStateProvider.Refuse, which tells CascadingState once) and no request acts as it again.
|
||||
/// </summary>
|
||||
async Task<HttpResponseMessage> SendAsPersona(HttpRequestMessage request, Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
|
||||
/// <summary>A Mastodon API request as the persona named, whichever the session acts as.</summary>
|
||||
async Task<HttpResponseMessage> SendAsGivenPersona(string personaId, Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
|
||||
{
|
||||
var session = await authStateProvider.GetAuthData(cancellationToken);
|
||||
if (authStateProvider.IsRefused(session.PersonaId))
|
||||
var request = requestFactory();
|
||||
if (!IsMastodon(request) || personaId is null)
|
||||
{
|
||||
request.Dispose();
|
||||
return SUtility.ClassifiedFailure(FailureCodes.ClientError,
|
||||
localizer["The request could not be prepared. Reload the page and try again."], HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
return await SendAsPersona(personaId, request, requestFactory, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A Mastodon API request as the persona (the current one, or the one an *As method names), with that persona's own
|
||||
/// token. A missing token is exchanged first; a refused one (revoked, or PrivaPub's database was reset) is exchanged
|
||||
/// once more and the request sent again. Without a usable JWT the session ends. A persona PrivaPub refuses a token to
|
||||
/// while the JWT is good (suspended by the staff, or gone) is marked refused (TokenAuthStateProvider.Refuse, which
|
||||
/// tells CascadingState once) and no request acts as it again.
|
||||
/// </summary>
|
||||
async Task<HttpResponseMessage> SendAsPersona(string personaId, HttpRequestMessage request, Func<HttpRequestMessage> requestFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (authStateProvider.IsRefused(personaId))
|
||||
return PersonaRefused();
|
||||
|
||||
var token = await authStateProvider.GetPersonaToken(cancellationToken);
|
||||
var token = await authStateProvider.GetPersonaToken(personaId, cancellationToken);
|
||||
if (token is null)
|
||||
{
|
||||
var exchanged = await Exchange(session.PersonaId, cancellationToken);
|
||||
var exchanged = await Exchange(personaId, cancellationToken);
|
||||
if (exchanged.Refused)
|
||||
return PersonaRefused(session.PersonaId);
|
||||
return PersonaRefused(personaId);
|
||||
token = exchanged.Token;
|
||||
}
|
||||
if (token is null)
|
||||
@@ -265,10 +329,10 @@ namespace decePubClient.Services
|
||||
return response;
|
||||
|
||||
response.Dispose();
|
||||
await authStateProvider.ForgetPersonaToken(session.PersonaId, cancellationToken);
|
||||
var renewed = await Exchange(session.PersonaId, cancellationToken);
|
||||
await authStateProvider.ForgetPersonaToken(personaId, cancellationToken);
|
||||
var renewed = await Exchange(personaId, cancellationToken);
|
||||
if (renewed.Refused)
|
||||
return PersonaRefused(session.PersonaId);
|
||||
return PersonaRefused(personaId);
|
||||
token = renewed.Token;
|
||||
if (token is null)
|
||||
return await EndSession(cancellationToken);
|
||||
@@ -283,15 +347,20 @@ namespace decePubClient.Services
|
||||
return await EndSession(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>A new token for the persona, or none; Refused when PrivaPub itself answered no (its 400 invalid_grant or a
|
||||
/// 403) to a JWT the client still holds as good, rather than the request failing on the way.</summary>
|
||||
/// <summary>A new token for the persona, kept without making it current, or none; Refused when PrivaPub itself answered
|
||||
/// no (its 400 invalid_grant or a 403) to a JWT the client still holds as good, rather than the request failing on the
|
||||
/// way.</summary>
|
||||
async Task<(string Token, bool Refused)> Exchange(string personaId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (personaId is null)
|
||||
return (null, false);
|
||||
using var response = await ExchangePersonaToken(personaId, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
return (await authStateProvider.GetPersonaToken(cancellationToken), false);
|
||||
var (exchange, token) = await RequestPersonaToken(personaId, cancellationToken);
|
||||
using var response = exchange;
|
||||
if (token is not null)
|
||||
{
|
||||
await authStateProvider.SetPersonaToken(personaId, token, cancellationToken);
|
||||
return (token, false);
|
||||
}
|
||||
|
||||
var refused = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden
|
||||
&& !response.Content.Headers.Contains(SUtility.FailureHeader)
|
||||
|
||||
@@ -6,8 +6,10 @@ namespace decePubClient.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Asks a persona, once, before its first reaction or poll vote, and once before it first joins an event, to accept
|
||||
/// that these are public and visible as that persona (an owner decision). PublicNoticeHost shows the question; Confirm
|
||||
/// returns true at once for a persona that already accepted it, and false when the reader declines.
|
||||
/// that these are public and visible as that persona (an owner decision); and this device, once, before its first Web
|
||||
/// Push subscription, to accept that the browser's push service learns when notifications arrive (PublicAct.Push, told
|
||||
/// once for the device whichever persona asks). PublicNoticeHost shows the question; Confirm returns true at once for
|
||||
/// what was accepted already, and false when the reader declines.
|
||||
/// </summary>
|
||||
public class PublicNoticeService(ILocalStorageService localStorage)
|
||||
{
|
||||
@@ -22,11 +24,11 @@ namespace decePubClient.Services
|
||||
|
||||
public async Task<bool> Confirm(string personaId, PublicAct act = PublicAct.Reaction, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(personaId))
|
||||
if (string.IsNullOrEmpty(personaId) && act != PublicAct.Push)
|
||||
return false;
|
||||
var seen = await localStorage.GetItemAsync<PublicActionNotices>(nameof(PublicActionNotices), cancellationToken) ?? new();
|
||||
var told = act == PublicAct.Participation ? seen.EventPersonaIds : seen.PersonaIds;
|
||||
if (told.Contains(personaId))
|
||||
if (act == PublicAct.Push ? seen.PushTold : told.Contains(personaId))
|
||||
return true;
|
||||
|
||||
Act = act;
|
||||
@@ -38,7 +40,10 @@ namespace decePubClient.Services
|
||||
if (!accepted)
|
||||
return false;
|
||||
|
||||
told.Add(personaId);
|
||||
if (act == PublicAct.Push)
|
||||
seen.PushTold = true;
|
||||
else
|
||||
told.Add(personaId);
|
||||
await localStorage.SetItemAsync(nameof(PublicActionNotices), seen, cancellationToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
using Blazored.LocalStorage;
|
||||
|
||||
using decePubClient.Extensions;
|
||||
using decePubClient.Helpers;
|
||||
using decePubClient.Models;
|
||||
using decePubClient.Models.Mastodon;
|
||||
using decePubClient.Models.Types;
|
||||
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.User;
|
||||
using PrivaPub.ClientModels.User.Avatar;
|
||||
|
||||
namespace decePubClient.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Web Push on this device (Mastodon's /api/v1/push/subscription, PrivaPub's R9), through wwwroot/js/push.js.
|
||||
/// <para>
|
||||
/// PrivaPub keeps one subscription per access token, so per persona, and pushes it that persona's notifications alone.
|
||||
/// Each persona chosen here gets a service worker registration of its own in this browser (wwwroot/push-worker.js at
|
||||
/// push/{slot}/, a random slot this device keeps in PushDevice), so its own endpoint and keys: PrivaPub stores nothing
|
||||
/// that ties two personas of one login together, and the worker knows the persona from its scope without reading the
|
||||
/// token Mastodon's payload carries. Each persona's subscription is made, read, changed and deleted with that
|
||||
/// persona's own token (IHttpService's *As), never another's, whichever persona the session acts as.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A subscription ends with its token on PrivaPub (a revocation, a password change, two-factor sign-in turned on,
|
||||
/// sessions ended, a suspension), so a persona whose token changes is subscribed again with the new one
|
||||
/// (TokenAuthStateProvider.PersonaTokenChanged), and every start of the session checks them all (Reconcile). Signing
|
||||
/// out ends them first (TurnOff); a deleted or suspended persona is dropped here (Drop). Failures come back as invalid
|
||||
/// WebResults; nothing throws.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class PushService(IHttpService httpService, TokenAuthStateProvider authStateProvider, AuthService authService, AboutService aboutService,
|
||||
ILocalStorageService localStorage, IJSRuntime jsRuntime, ILoggingService logger, CoalescingStringLocalizer localizer) : IAsyncDisposable
|
||||
{
|
||||
/// <summary>How long a changed token waits before its persona is subscribed again: one change often brings others.</summary>
|
||||
static readonly TimeSpan RenewDelay = TimeSpan.FromSeconds(2);
|
||||
|
||||
readonly SemaphoreSlim gate = new(1, 1);
|
||||
readonly CancellationTokenSource stopping = new();
|
||||
/// <summary>The personas whose tokens changed since their subscriptions were last made sure of.</summary>
|
||||
readonly HashSet<string> stale = [];
|
||||
/// <summary>The personas subscribed on this device, as last read or saved.</summary>
|
||||
HashSet<string> subscribed = [];
|
||||
IJSObjectReference module;
|
||||
bool moduleFailed;
|
||||
bool listening;
|
||||
bool renewing;
|
||||
/// <summary>The persona a request of this service acts as: the token changes its own exchanges make need no renewal.</summary>
|
||||
string actingAs;
|
||||
|
||||
/// <summary>Whether this browser can be pushed to, and what the reader allowed it.</summary>
|
||||
public async Task<PushSupport> Support(CancellationToken cancellationToken = default) =>
|
||||
await Js("support", new PushSupport(), [], cancellationToken) ?? new();
|
||||
|
||||
/// <summary>The browser's own question, asked once; what it knows already is answered at once (granted, denied or
|
||||
/// default). Call it from the reader's click.</summary>
|
||||
public async Task<string> RequestPermission(CancellationToken cancellationToken = default) =>
|
||||
await Js("requestPermission", "default", [], cancellationToken) ?? "default";
|
||||
|
||||
/// <summary>Whether the server gives a key to push with (the instance API's configuration.vapid): false for a server
|
||||
/// that pushes nothing, null while the instance can't be read.</summary>
|
||||
public async Task<bool?> ServerPushes(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var instance = await aboutService.Instance(cancellationToken: cancellationToken);
|
||||
if (instance is null)
|
||||
return null;
|
||||
return instance.Configuration?.Vapid?.PublicKey is { Length: > 0 };
|
||||
}
|
||||
|
||||
/// <summary>This device's choices: the personas subscribed, the alerts and the policy.</summary>
|
||||
public async Task<PushDevice> Device(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var device = await localStorage.GetItemAsync<PushDevice>(nameof(PushDevice), cancellationToken) ?? new();
|
||||
subscribed = device.Personas.Select(p => p.PersonaId).ToHashSet();
|
||||
return device;
|
||||
}
|
||||
|
||||
/// <summary>The persona a registration's slot is for on this device (a notification's click), or null.</summary>
|
||||
public async Task<string> PersonaOf(string slot, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(slot))
|
||||
return null;
|
||||
return (await Device(cancellationToken)).Personas.FirstOrDefault(p => p.Slot == slot)?.PersonaId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes each persona chosen, with its own token, to the alerts and the policy chosen, and unsubscribes the others;
|
||||
/// none chosen is push off on this device. The browser's permission must be granted already (RequestPermission). The
|
||||
/// first failure comes back; the personas that succeeded stay subscribed.
|
||||
/// </summary>
|
||||
public async Task<WebResult> Apply(List<string> personaIds, List<string> alerts, PushPolicy policy, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
Listen();
|
||||
var device = await Device(cancellationToken);
|
||||
device.Alerts = [.. alerts.Where(alert => PushAlerts.Offered.Contains(alert))];
|
||||
device.Policy = policy;
|
||||
foreach (var dropped in device.Personas.Where(p => !personaIds.Contains(p.PersonaId)).ToList())
|
||||
await Unsubscribe(device, dropped, true, cancellationToken);
|
||||
|
||||
var result = new WebResult();
|
||||
var key = personaIds.Count == 0 ? null : await ServerKey(cancellationToken);
|
||||
if (personaIds.Count > 0 && key is null)
|
||||
{
|
||||
await Save(device, cancellationToken);
|
||||
return result.Invalidate(localizer["This server does not offer push notifications."], (int)HttpStatusCode.NotImplemented);
|
||||
}
|
||||
|
||||
foreach (var personaId in personaIds)
|
||||
{
|
||||
var pushed = await Push(device, personaId, key, cancellationToken);
|
||||
if (pushed.IsInvalid && !result.IsInvalid)
|
||||
result = pushed;
|
||||
}
|
||||
await Save(device, cancellationToken);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Push off on this device: PrivaPub forgets each persona's subscription (asked with that persona's token, while
|
||||
/// the session holds one), then the browser forgets the subscriptions and their registrations. Signing out does it first.</summary>
|
||||
public Task<WebResult> TurnOff(CancellationToken cancellationToken = default) => Clear(true, cancellationToken);
|
||||
|
||||
/// <summary>The browser forgets every subscription and registration, PrivaPub is not asked: its subscriptions ended
|
||||
/// already with the tokens (every session ended, the login deleted, a session that expired).</summary>
|
||||
public Task<WebResult> Forget(CancellationToken cancellationToken = default) => Clear(false, cancellationToken);
|
||||
|
||||
/// <summary>A persona deleted, or suspended by the staff: PrivaPub ended its subscription with it, and this device drops
|
||||
/// its own.</summary>
|
||||
public async Task<WebResult> Drop(string personaId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var device = await Device(cancellationToken);
|
||||
if (device.Personas.FirstOrDefault(p => p.PersonaId == personaId) is not { } entry)
|
||||
return new WebResult();
|
||||
|
||||
await Unsubscribe(device, entry, false, cancellationToken);
|
||||
await Save(device, cancellationToken);
|
||||
return new WebResult();
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Brings this device in line once the session is read (CascadingState.LoadSession), and when Settings shows it:
|
||||
/// registrations it keeps no persona for go; with the permission withdrawn, or push gone from the browser, every
|
||||
/// subscription goes; a persona deleted, or suspended by the staff, is dropped (PrivaPub ended its subscription); each
|
||||
/// other one is made sure of (Push), so a token renewed since, or a new endpoint, subscribes it again.
|
||||
/// </summary>
|
||||
public async Task<WebResult> Reconcile(List<ViewAvatar> personas, CancellationToken cancellationToken = default)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
Listen();
|
||||
var device = await Device(cancellationToken);
|
||||
await DropStrays(device, cancellationToken);
|
||||
if (device.Personas.Count == 0)
|
||||
return new WebResult();
|
||||
|
||||
var support = await Support(cancellationToken);
|
||||
if (!support.Supported || !support.IsGranted)
|
||||
{
|
||||
foreach (var entry in device.Personas.ToList())
|
||||
await Unsubscribe(device, entry, true, cancellationToken);
|
||||
await Save(device, cancellationToken);
|
||||
return new WebResult();
|
||||
}
|
||||
|
||||
// (a list that couldn't be read drops nothing)
|
||||
if (personas.Count > 0)
|
||||
foreach (var gone in device.Personas.Where(p => personas.FirstOrDefault(a => a.Id == p.PersonaId) is not { AccountState: not ViewAvatarState.Suspended }).ToList())
|
||||
await Unsubscribe(device, gone, false, cancellationToken);
|
||||
|
||||
var key = await ServerKey(cancellationToken);
|
||||
if (key is not null)
|
||||
foreach (var entry in device.Personas.ToList())
|
||||
await Warn(await Push(device, entry.PersonaId, key, cancellationToken), nameof(Reconcile), cancellationToken);
|
||||
await Save(device, cancellationToken);
|
||||
return new WebResult();
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A notification shown by the persona's registration alone (the first subscribed one's, when it has none):
|
||||
/// nothing goes to any server. False when there is none, or the browser refused.</summary>
|
||||
public async Task<bool> Test(string personaId, string title, string body, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var device = await Device(cancellationToken);
|
||||
var entry = device.Personas.FirstOrDefault(p => p.PersonaId == personaId) ?? device.Personas.FirstOrDefault();
|
||||
if (entry is null)
|
||||
return false;
|
||||
return await Js("test", false, [entry.Slot, title, body], cancellationToken);
|
||||
}
|
||||
|
||||
async Task<WebResult> Clear(bool tellServer, CancellationToken cancellationToken)
|
||||
{
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var device = await Device(cancellationToken);
|
||||
foreach (var entry in device.Personas.ToList())
|
||||
await Unsubscribe(device, entry, tellServer, cancellationToken);
|
||||
await DropStrays(device, cancellationToken);
|
||||
await Save(device, cancellationToken);
|
||||
return new WebResult();
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Makes sure PrivaPub pushes the persona's notifications to its subscription in this browser as the device's alerts
|
||||
/// and policy say. The browser's subscription is made when missing. PrivaPub's is read: left as it is when it matches,
|
||||
/// given the device's alerts and policy (PUT) when only they differ, and made again (POST) when it is missing or holds
|
||||
/// another endpoint (its token changed, or the push service gave a new endpoint). A persona PrivaPub refuses (suspended,
|
||||
/// or gone) is dropped; a new one that fails leaves nothing behind.
|
||||
/// </summary>
|
||||
async Task<WebResult> Push(PushDevice device, string personaId, string key, CancellationToken cancellationToken)
|
||||
{
|
||||
var entry = device.Personas.FirstOrDefault(p => p.PersonaId == personaId);
|
||||
var isNew = entry is null;
|
||||
entry ??= new() { PersonaId = personaId, Slot = NewSlot() };
|
||||
actingAs = personaId;
|
||||
try
|
||||
{
|
||||
var browser = await Js<BrowserSubscription>("subscribe", null, [entry.Slot, key], cancellationToken);
|
||||
if (browser is not { Endpoint.Length: > 0, P256dh.Length: > 0, Auth.Length: > 0 })
|
||||
{
|
||||
if (isNew)
|
||||
await Js("unsubscribe", false, [entry.Slot], cancellationToken);
|
||||
return new WebResult().Invalidate(localizer["This browser did not subscribe: {0}", browser?.Error ?? "?"], (int)HttpStatusCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
var result = await Tell(device, entry.PersonaId, browser, cancellationToken);
|
||||
if (result.IsInvalid && (isNew || result.ErrorCode is FailureCodes.PersonaRefused))
|
||||
{
|
||||
await Js("unsubscribe", false, [entry.Slot], cancellationToken);
|
||||
device.Personas.Remove(entry);
|
||||
return result;
|
||||
}
|
||||
if (result.IsInvalid)
|
||||
return result;
|
||||
|
||||
entry.SubscriptionId = (result.Data as WebPushSubscription)?.Id ?? entry.SubscriptionId;
|
||||
if (isNew)
|
||||
device.Personas.Add(entry);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
actingAs = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>PrivaPub's side of Push; Data is the WebPushSubscription it holds now.</summary>
|
||||
async Task<WebResult> Tell(PushDevice device, string personaId, BrowserSubscription browser, CancellationToken cancellationToken)
|
||||
{
|
||||
var data = Data(device);
|
||||
var held = await Held(personaId, cancellationToken);
|
||||
if (held.IsInvalid)
|
||||
return held;
|
||||
|
||||
if (held.Data is WebPushSubscription current && current.Endpoint == browser.Endpoint)
|
||||
{
|
||||
if (Matches(current, data))
|
||||
return held;
|
||||
|
||||
var updated = await Send(personaId, () => httpService.PutAs(personaId, APIs.Mastodon.PUT_PushSubscription, new PushDataForm { Data = data },
|
||||
cancellationToken: cancellationToken), cancellationToken);
|
||||
if (!updated.IsInvalid || updated.StatusCode is not (int)HttpStatusCode.NotFound)
|
||||
return updated;
|
||||
}
|
||||
|
||||
return await Send(personaId, () => httpService.PostAs(personaId, APIs.Mastodon.POST_PushSubscription, new PushSubscriptionForm
|
||||
{
|
||||
Subscription = new()
|
||||
{
|
||||
Endpoint = browser.Endpoint,
|
||||
Keys = new() { P256dh = browser.P256dh, Auth = browser.Auth },
|
||||
Standard = browser.Standard
|
||||
},
|
||||
Data = data
|
||||
}, cancellationToken: cancellationToken), cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>The subscription PrivaPub holds for the persona's token as Data, or no Data when it holds none (its 404).</summary>
|
||||
async Task<WebResult> Held(string personaId, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await AsPersona(personaId, () => httpService.GetAs(personaId, APIs.Mastodon.GET_PushSubscription,
|
||||
cancellationToken: cancellationToken), cancellationToken);
|
||||
if (response.StatusCode is HttpStatusCode.NotFound)
|
||||
return new WebResult();
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult { Data = await response.MastodonReadFromJsonAsync<WebPushSubscription>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>A POST or PUT of the persona's subscription; Data is what PrivaPub holds then.</summary>
|
||||
async Task<WebResult> Send(string personaId, Func<Task<HttpResponseMessage>> send, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await AsPersona(personaId, send, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
|
||||
|
||||
return new WebResult { Data = await response.MastodonReadFromJsonAsync<WebPushSubscription>(cancellationToken) };
|
||||
}
|
||||
|
||||
/// <summary>A request as the persona. A token PrivaPub refuses for want of the push scope (one exchanged before decePub
|
||||
/// asked for it) is revoked and forgotten, and the request sent once more with the new one the exchange gives.</summary>
|
||||
async Task<HttpResponseMessage> AsPersona(string personaId, Func<Task<HttpResponseMessage>> send, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = await send();
|
||||
if (response.StatusCode is not HttpStatusCode.Forbidden || response.Content.Headers.Contains(SUtility.FailureHeader))
|
||||
return response;
|
||||
// (read once, the content stays buffered for ReadWebResult)
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!body.Contains("authorized scopes", StringComparison.OrdinalIgnoreCase))
|
||||
return response;
|
||||
|
||||
response.Dispose();
|
||||
await authService.RenewPersonaToken(personaId, cancellationToken);
|
||||
return await send();
|
||||
}
|
||||
|
||||
/// <summary>The persona goes from this device; PrivaPub is asked to forget its subscription first when tellServer and the
|
||||
/// persona still holds a token (one forgotten took its subscription with it).</summary>
|
||||
async Task Unsubscribe(PushDevice device, PushPersona entry, bool tellServer, CancellationToken cancellationToken)
|
||||
{
|
||||
if (tellServer && !authStateProvider.IsRefused(entry.PersonaId) && await authStateProvider.IsAuthenticatedAsync(cancellationToken)
|
||||
&& await authStateProvider.GetPersonaToken(entry.PersonaId, cancellationToken) is not null)
|
||||
{
|
||||
actingAs = entry.PersonaId;
|
||||
try
|
||||
{
|
||||
using var response = await httpService.DeleteAs(entry.PersonaId, APIs.Mastodon.DELETE_PushSubscription, cancellationToken: cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
await Warn(await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken), nameof(Unsubscribe), cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
actingAs = null;
|
||||
}
|
||||
}
|
||||
|
||||
await Js("unsubscribe", false, [entry.Slot], cancellationToken);
|
||||
device.Personas.Remove(entry);
|
||||
}
|
||||
|
||||
/// <summary>Registrations at push/{slot}/ this device keeps no persona for (its storage cleared, or a failure half-way) go
|
||||
/// with their subscriptions, so nothing PrivaPub still pushes to them is shown here.</summary>
|
||||
async Task DropStrays(PushDevice device, CancellationToken cancellationToken)
|
||||
{
|
||||
var known = device.Personas.Select(p => p.Slot).ToHashSet();
|
||||
foreach (var slot in await Js<List<string>>("slots", [], [], cancellationToken) ?? [])
|
||||
if (!known.Contains(slot))
|
||||
await Js("unsubscribe", false, [slot], cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>The server's VAPID key (the instance API's configuration.vapid.public_key), or null when it pushes nothing.</summary>
|
||||
async Task<string> ServerKey(CancellationToken cancellationToken) =>
|
||||
(await aboutService.Instance(cancellationToken: cancellationToken))?.Configuration?.Vapid?.PublicKey is { Length: > 0 } key ? key : null;
|
||||
|
||||
/// <summary>Every alert offered, on as the device has it; those PrivaPub never fires are left out, which is off.</summary>
|
||||
static PushData Data(PushDevice device) => new()
|
||||
{
|
||||
Alerts = PushAlerts.Offered.ToDictionary(alert => alert, alert => device.Alerts.Contains(alert)),
|
||||
Policy = PushPolicies.Name(device.Policy)
|
||||
};
|
||||
|
||||
static bool Matches(WebPushSubscription held, PushData data) =>
|
||||
PushPolicies.Of(held.Policy) == PushPolicies.Of(data.Policy)
|
||||
&& PushAlerts.Offered.All(alert => held.Alerts.GetValueOrDefault(alert) == data.Alerts.GetValueOrDefault(alert));
|
||||
|
||||
static string NewSlot() => Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();
|
||||
|
||||
async Task Save(PushDevice device, CancellationToken cancellationToken)
|
||||
{
|
||||
subscribed = device.Personas.Select(p => p.PersonaId).ToHashSet();
|
||||
await localStorage.SetItemAsync(nameof(PushDevice), device, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>From the first Apply or Reconcile on: a persona whose token changes is subscribed again with the new one,
|
||||
/// and a session that ends on its own (an expired or refused JWT) takes this device's subscriptions with it.</summary>
|
||||
void Listen()
|
||||
{
|
||||
if (listening)
|
||||
return;
|
||||
listening = true;
|
||||
authStateProvider.PersonaTokenChanged += OnTokenChanged;
|
||||
authStateProvider.AuthenticationStateChanged += OnAuthenticationChanged;
|
||||
}
|
||||
|
||||
void OnTokenChanged(string personaId)
|
||||
{
|
||||
if (personaId == actingAs || !subscribed.Contains(personaId) || !stale.Add(personaId) || renewing)
|
||||
return;
|
||||
_ = RenewSoon(stopping.Token);
|
||||
}
|
||||
|
||||
void OnAuthenticationChanged(Task<AuthenticationState> state) => _ = ForgetWhenSignedOut(state, stopping.Token);
|
||||
|
||||
async Task RenewSoon(CancellationToken cancellationToken)
|
||||
{
|
||||
renewing = true;
|
||||
try
|
||||
{
|
||||
await Task.Delay(RenewDelay, cancellationToken);
|
||||
await gate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var device = await Device(cancellationToken);
|
||||
var key = await ServerKey(cancellationToken);
|
||||
while (stale.Count > 0)
|
||||
{
|
||||
var personaIds = stale.ToList();
|
||||
stale.Clear();
|
||||
if (key is null || !await authStateProvider.IsAuthenticatedAsync(cancellationToken))
|
||||
continue;
|
||||
foreach (var personaId in personaIds.Where(id => device.Personas.Any(p => p.PersonaId == id)))
|
||||
await Warn(await Push(device, personaId, key, cancellationToken), nameof(RenewSoon), cancellationToken);
|
||||
}
|
||||
await Save(device, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Release();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await logger.ProcessError(ex, $"{nameof(PushService)}.{nameof(RenewSoon)}", cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
renewing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async Task ForgetWhenSignedOut(Task<AuthenticationState> state, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if ((await state).User.Identity?.IsAuthenticated == true || subscribed.Count == 0)
|
||||
return;
|
||||
await Forget(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await logger.ProcessError(ex, $"{nameof(PushService)}.{nameof(ForgetWhenSignedOut)}", cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
async Task Warn(WebResult result, string caller, CancellationToken cancellationToken)
|
||||
{
|
||||
if (result.IsInvalid && result.StatusCode is not (int)HttpStatusCode.Gone)
|
||||
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(PushService)}.{caller}", cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>A push.js function; its fallback when the module can't be loaded or the call fails (logged).</summary>
|
||||
async Task<T> Js<T>(string function, T fallback, object[] args, CancellationToken cancellationToken)
|
||||
{
|
||||
var push = await Module(cancellationToken);
|
||||
if (push is null)
|
||||
return fallback;
|
||||
try
|
||||
{
|
||||
return await push.InvokeAsync<T>(function, cancellationToken, args);
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
await logger.ProcessError(ex, $"{nameof(PushService)}.{function}", cancellationToken);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async Task<IJSObjectReference> Module(CancellationToken cancellationToken)
|
||||
{
|
||||
if (module is not null || moduleFailed)
|
||||
return module;
|
||||
try
|
||||
{
|
||||
return module = await jsRuntime.InvokeAsync<IJSObjectReference>("import", cancellationToken, "./js/push.js");
|
||||
}
|
||||
catch (JSException ex)
|
||||
{
|
||||
moduleFailed = true;
|
||||
await logger.ProcessError(ex, $"{nameof(PushService)}.{nameof(Module)}", cancellationToken);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
authStateProvider.PersonaTokenChanged -= OnTokenChanged;
|
||||
authStateProvider.AuthenticationStateChanged -= OnAuthenticationChanged;
|
||||
await stopping.CancelAsync();
|
||||
stopping.Dispose();
|
||||
if (module is not null)
|
||||
await module.DisposeAsync();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,10 @@ namespace decePubClient.Services
|
||||
/// <summary>Raised once for a persona PrivaPub refused a token to (Refuse); CascadingState moves the session on.</summary>
|
||||
public event Action<string> PersonaRefused;
|
||||
|
||||
/// <summary>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).</summary>
|
||||
public event Action<string> PersonaTokenChanged;
|
||||
|
||||
/// <summary>The stored session, or an empty one.</summary>
|
||||
public async Task<AuthData> GetAuthData(CancellationToken cancellationToken = default)
|
||||
{
|
||||
@@ -59,6 +63,15 @@ namespace decePubClient.Services
|
||||
return session.PersonaTokens.GetValueOrDefault(session.PersonaId);
|
||||
}
|
||||
|
||||
/// <summary>That persona's Mastodon token, or null when it has none yet; never another persona's.</summary>
|
||||
public async Task<string> 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<bool> IsAuthenticatedAsync(CancellationToken cancellationToken = default) =>
|
||||
await GetToken(cancellationToken) is not null;
|
||||
|
||||
@@ -81,9 +94,26 @@ namespace decePubClient.Services
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>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).</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>Drops a persona's token that PrivaPub no longer accepts.</summary>
|
||||
@@ -93,6 +123,7 @@ namespace decePubClient.Services
|
||||
if (!session.PersonaTokens.Remove(personaId))
|
||||
return;
|
||||
await Save(session, cancellationToken);
|
||||
PersonaTokenChanged?.Invoke(personaId);
|
||||
}
|
||||
|
||||
/// <summary>Whether PrivaPub refused the persona a token in this session (Refuse).</summary>
|
||||
@@ -120,8 +151,11 @@ namespace decePubClient.Services
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user