diff options
| author | Leitwolf11 | 2026-10-03 18:46:59 +0300 |
|---|---|---|
| committer | Leitwolf11 | 2026-10-03 18:46:59 +0300 |
| commit | b84a2216c26cc5392d0dc300b7a605ccb288d8c8 (patch) | |
| tree | 048ae623f49cc5f2f94234d747351f9e659f5267 | |
| parent | ec774db15976385a6d5ea6f48d3622fe507d3843 (diff) | |
| parent | cf444eb3bccf99c10f59ed5330b61c0397e0aeaa (diff) | |
| download | progmaweb-fix/api-without-google.tar.gz | |
Merge remote-tracking branch 'origin/main' into fix/api-without-googlefix/api-without-google
25 files changed, 538 insertions, 18 deletions
diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index a1f6320..6504906 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -47,10 +47,13 @@ jobs: - run: dotnet build Progmaweb.slnx --configuration Release --no-restore - run: dotnet test --project apps/api-tests/Progmaweb.Api.Tests.csproj --configuration Release --no-build --coverlet --coverlet-output-format cobertura - # Documentation gate: DocFX reads the API project and builds the - # reference; an unresolved reference or a broken link is an error. + # Documentation gate. DocFX reads the API project and builds the + # reference; an unresolved reference or a broken link is an error. The + # coverage check then fails when a namespace, type or member of the + # reference has no description, which DocFX itself does not report. - run: dotnet tool restore - run: dotnet docfx docs/api/docfx.json --warningsAsErrors + - run: dotnet run docs/api/CheckDocumentation.cs -- docs/api - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -96,6 +96,7 @@ dotnet test --project apps/api-tests/Progmaweb.Api.Tests.csproj --configuration dotnet test --project apps/api-tests/Progmaweb.Api.Tests.csproj --configuration Release --coverlet --coverlet-output-format cobertura dotnet tool restore dotnet docfx docs/api/docfx.json --warningsAsErrors +dotnet run docs/api/CheckDocumentation.cs -- docs/api ``` Frontend tests exercise the published locale contract and account/ViGet metadata without starting a production service. @@ -103,7 +104,8 @@ The frontend LCOV and API Cobertura reports are retained as one-day artifacts, t jobs. Only those upload jobs receive `id-token: write`; dependency installation, builds, and tests do not receive OIDC permissions, and fork-originated pull requests skip the upload jobs. CodeQL and Codacy provide static analysis; Dependabot proposes dependency updates without merging them automatically. The DocFX command builds the API reference -and fails on a broken link or an unresolved reference. [Development](docs/DEVELOPMENT.md) explains each check. +and fails on a broken link or an unresolved reference; the check after it fails when any item of the reference has no +description. [Development](docs/DEVELOPMENT.md) explains each check. ## License diff --git a/apps/api/Accounts/AccountContracts.cs b/apps/api/Accounts/AccountContracts.cs index ca7ddb1..4a803a0 100644 --- a/apps/api/Accounts/AccountContracts.cs +++ b/apps/api/Accounts/AccountContracts.cs @@ -3,27 +3,65 @@ namespace Progmasoft.Progmaweb.Api.Accounts; +/// <summary>Body of <c>POST /api/v1/accounts/register</c>.</summary> +/// <remarks> +/// Every field is optional on the wire so that a missing value is reported as a validation error of that field +/// instead of a malformed request. +/// </remarks> +/// <param name="AccountName">Requested account name; see <see cref="AccountNamePolicy"/>.</param> +/// <param name="Email">Email address of the account; see <see cref="EmailAddressPolicy"/>.</param> +/// <param name="Password">Password in plain text; see <see cref="PasswordPolicy"/>. It is hashed before it is stored.</param> public sealed record RegisterAccountRequest(string? AccountName, string? Email, string? Password); +/// <summary>Body of <c>POST /api/v1/accounts/login</c>.</summary> +/// <param name="Email">Email address the account was registered with.</param> +/// <param name="Password">Password in plain text.</param> public sealed record LoginAccountRequest(string? Email, string? Password); +/// <summary>Public view of an account, returned by the register, login and <c>/me</c> endpoints.</summary> +/// <param name="AccountName">Account name in its canonical spelling.</param> +/// <param name="Email">Email address as the owner entered it.</param> +/// <param name="CreatedAt">Moment the account was created, in UTC.</param> public sealed record AccountResponse(string AccountName, string Email, DateTimeOffset CreatedAt); +/// <summary>An account together with the session that was just opened for it.</summary> +/// <param name="Account">The signed-in account.</param> +/// <param name="SessionToken"> +/// Session token for the cookie. It exists only here and in the browser; the store keeps its digest. +/// </param> +/// <param name="ExpiresAt">Moment the session stops being valid, in UTC.</param> internal sealed record AuthenticatedAccount(AccountRecord Account, string SessionToken, DateTimeOffset ExpiresAt); +/// <summary>Outcome of asking the store to create an account.</summary> internal enum CreateAccountStatus { + /// <summary>The account was stored.</summary> Created, + /// <summary>Another account already has this name, compared without case.</summary> AccountNameUnavailable, + /// <summary>Another account already has this email address, compared without case.</summary> EmailUnavailable, + /// <summary>Another account is already linked to this Google identity.</summary> GoogleSubjectUnavailable } +/// <summary>Result of <see cref="IAccountStore.CreateAsync"/>.</summary> +/// <param name="Status">Whether the account was stored, and if not, which uniqueness rule refused it.</param> +/// <param name="Account">The stored account; <see langword="null"/> unless <paramref name="Status"/> is created.</param> internal sealed record CreateAccountResult(CreateAccountStatus Status, AccountRecord? Account) { + /// <summary>Creates the result of a stored account.</summary> + /// <param name="account">The account that was stored.</param> + /// <returns>A result with the created status and the account.</returns> public static CreateAccountResult Created(AccountRecord account) => new(CreateAccountStatus.Created, account); + /// <summary>Creates the result of a refused account name.</summary> + /// <returns>A result without an account.</returns> public static CreateAccountResult AccountNameUnavailable() => new(CreateAccountStatus.AccountNameUnavailable, null); + /// <summary>Creates the result of a refused email address.</summary> + /// <returns>A result without an account.</returns> public static CreateAccountResult EmailUnavailable() => new(CreateAccountStatus.EmailUnavailable, null); + /// <summary>Creates the result of a refused Google identity.</summary> + /// <returns>A result without an account.</returns> public static CreateAccountResult GoogleSubjectUnavailable() => new(CreateAccountStatus.GoogleSubjectUnavailable, null); } diff --git a/apps/api/Accounts/AccountEndpoints.cs b/apps/api/Accounts/AccountEndpoints.cs index 06f4997..c5be51c 100644 --- a/apps/api/Accounts/AccountEndpoints.cs +++ b/apps/api/Accounts/AccountEndpoints.cs @@ -7,10 +7,29 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Google; +/// <summary>HTTP endpoints of the account service under <c>/api/v1/accounts</c>.</summary> +/// <remarks> +/// <list type="table"> +/// <item><term><c>POST /register</c></term><description>Creates a password account and signs it in.</description></item> +/// <item><term><c>POST /login</c></term><description>Signs in with email address and password.</description></item> +/// <item><term><c>POST /logout</c></term><description>Ends the current session.</description></item> +/// <item><term><c>GET /me</c></term><description>Returns the signed-in account.</description></item> +/// <item><term><c>GET /oauth/google/start</c></term><description>Begins sign-in with Google.</description></item> +/// <item><term><c>GET /oauth/google/complete</c></term><description>Finishes sign-in with Google.</description></item> +/// </list> +/// The endpoints that accept credentials share the <c>account-auth</c> rate limit. +/// </remarks> internal static class AccountEndpoints { + /// <summary> + /// Authentication scheme of the short-lived cookie that carries the Google identity between the provider callback + /// and <c>/oauth/google/complete</c>. + /// </summary> internal const string ExternalCookieScheme = "ProgmasoftExternal"; + /// <summary>Registers the account endpoints.</summary> + /// <param name="endpoints">The route builder of the application.</param> + /// <returns>The same route builder, for chaining.</returns> public static IEndpointRouteBuilder MapAccountEndpoints(this IEndpointRouteBuilder endpoints) { RouteGroupBuilder accounts = endpoints.MapGroup("/api/v1/accounts"); @@ -25,6 +44,15 @@ internal static class AccountEndpoints return endpoints; } + /// <summary>Begins sign-in with Google.</summary> + /// <param name="accountName"> + /// Account name to create when the Google identity is new; omitted when signing in to an existing account. + /// </param> + /// <param name="configuration">Application configuration with the Google client credentials.</param> + /// <returns> + /// A challenge that sends the browser to Google, a redirect back to registration when the name is invalid, or + /// 503 when Google sign-in is not configured. + /// </returns> private static IResult StartGoogleAsync(string? accountName, IConfiguration configuration) { if (string.IsNullOrWhiteSpace(configuration["Authentication:Google:ClientId"]) || @@ -54,6 +82,12 @@ internal static class AccountEndpoints return Results.Challenge(properties, [GoogleDefaults.AuthenticationScheme]); } + /// <summary>Finishes sign-in with Google after the provider callback.</summary> + /// <param name="accounts">Account service.</param> + /// <param name="sessions">Session service.</param> + /// <param name="context">The current request, holding the external cookie.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>A redirect to the dashboard on success, or to the page that explains the failure.</returns> private static async Task<IResult> CompleteGoogleAsync( AccountService accounts, SessionService sessions, @@ -90,6 +124,13 @@ internal static class AccountEndpoints return Results.Redirect($"/{Uri.EscapeDataString(authentication.Account.AccountName)}/dashboard"); } + /// <summary>Creates a password account and opens a session for it.</summary> + /// <param name="request">The registration body.</param> + /// <param name="accounts">Account service.</param> + /// <param name="sessions">Session service.</param> + /// <param name="context">The current request; receives the session cookie.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>201 with the account, or a validation problem that names each refused field.</returns> private static async Task<IResult> RegisterAsync( RegisterAccountRequest request, AccountService accounts, @@ -110,6 +151,15 @@ internal static class AccountEndpoints ToResponse(authentication.Account)); } + /// <summary>Signs in with email address and password.</summary> + /// <param name="request">The login body.</param> + /// <param name="accounts">Account service.</param> + /// <param name="sessions">Session service.</param> + /// <param name="context">The current request; receives the session cookie.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns> + /// 200 with the account, or 401 with one message that does not reveal whether the address or the password was wrong. + /// </returns> private static async Task<IResult> LoginAsync( LoginAccountRequest request, AccountService accounts, @@ -130,6 +180,11 @@ internal static class AccountEndpoints return Results.Ok(ToResponse(authentication.Account)); } + /// <summary>Ends the current session and deletes its cookie.</summary> + /// <param name="sessions">Session service.</param> + /// <param name="context">The current request.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>204, also when no session existed.</returns> private static async Task<IResult> LogoutAsync( SessionService sessions, HttpContext context, @@ -147,6 +202,11 @@ internal static class AccountEndpoints return Results.NoContent(); } + /// <summary>Returns the account of the current session.</summary> + /// <param name="sessions">Session service.</param> + /// <param name="context">The current request.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>200 with the account, or 401 when the request has no valid session.</returns> private static async Task<IResult> MeAsync( SessionService sessions, HttpContext context, @@ -157,6 +217,10 @@ internal static class AccountEndpoints return account is null ? Results.Unauthorized() : Results.Ok(ToResponse(account)); } + /// <summary>Writes the session cookie of a new session to the response.</summary> + /// <param name="context">The current request.</param> + /// <param name="sessions">Session service, which names the cookie.</param> + /// <param name="authentication">The session that was opened.</param> private static void WriteSessionCookie( HttpContext context, SessionService sessions, @@ -166,6 +230,9 @@ internal static class AccountEndpoints authentication.SessionToken, SessionService.CreateCookieOptions(authentication.ExpiresAt)); + /// <summary>Projects a stored account to its public view.</summary> + /// <param name="account">The stored account.</param> + /// <returns>The fields a client may see.</returns> private static AccountResponse ToResponse(AccountRecord account) => new(account.AccountName, account.Email, account.CreatedAt); } diff --git a/apps/api/Accounts/AccountNamePolicy.cs b/apps/api/Accounts/AccountNamePolicy.cs index 76e9dcf..3b2328d 100644 --- a/apps/api/Accounts/AccountNamePolicy.cs +++ b/apps/api/Accounts/AccountNamePolicy.cs @@ -6,19 +6,35 @@ using System.Text.RegularExpressions; namespace Progmasoft.Progmaweb.Api.Accounts; +/// <summary>Rules for account names.</summary> +/// <remarks> +/// A name is 8 to 128 ASCII letters or digits and begins with an upper-case letter. Names are unique without regard +/// to case, so two names that differ only in case cannot both exist, while each keeps the spelling its owner chose. +/// The same name is the ViGet publisher name. +/// </remarks> internal static partial class AccountNamePolicy { + /// <summary>Fewest characters an account name may have.</summary> public const int MinimumLength = 8; + /// <summary>Most characters an account name may have.</summary> public const int MaximumLength = 128; + /// <summary>Names nobody may register, in their lookup form.</summary> // These names identify Progmasoft itself and the initial administrative identity. They are provisioned only by // trusted operations tooling, never by the public registration endpoint. private static readonly FrozenSet<string> ReservedNames = new[] { "LEITWOLF", "PROGMASOFT" }.ToFrozenSet(StringComparer.Ordinal); + /// <summary>Matches a name of the allowed length, alphabet and first letter.</summary> + /// <returns>The compiled expression.</returns> [GeneratedRegex("^[A-Z][A-Za-z0-9]{7,127}$", RegexOptions.CultureInvariant)] private static partial Regex CanonicalPattern(); + /// <summary>Validates an account name and returns its canonical spelling.</summary> + /// <param name="value">The name as entered; surrounding white space is ignored.</param> + /// <param name="canonical">The trimmed name. It is meaningful only when the method returns <see langword="true"/>.</param> + /// <param name="error">Message for the user when the name is refused; empty otherwise.</param> + /// <returns><see langword="true"/> when the name satisfies every rule and is not reserved.</returns> public static bool TryNormalize(string? value, out string canonical, out string error) { canonical = value?.Trim() ?? string.Empty; @@ -45,5 +61,8 @@ internal static partial class AccountNamePolicy return true; } + /// <summary>Returns the key under which a name is compared for uniqueness.</summary> + /// <param name="canonical">A name that <see cref="TryNormalize"/> accepted.</param> + /// <returns>The name in upper case.</returns> public static string NormalizeForLookup(string canonical) => canonical.ToUpperInvariant(); } diff --git a/apps/api/Accounts/AccountRecord.cs b/apps/api/Accounts/AccountRecord.cs index 7625168..5e27f3e 100644 --- a/apps/api/Accounts/AccountRecord.cs +++ b/apps/api/Accounts/AccountRecord.cs @@ -3,6 +3,19 @@ namespace Progmasoft.Progmaweb.Api.Accounts; +/// <summary>An account as the store keeps it.</summary> +/// <param name="Id">Stable identifier; sessions refer to the account by it.</param> +/// <param name="AccountName">Account name in its canonical spelling, as shown and used in URLs.</param> +/// <param name="NormalizedAccountName">Upper-case form of the name; the uniqueness key.</param> +/// <param name="Email">Email address as the owner entered it.</param> +/// <param name="NormalizedEmail">Upper-case form of the address; the uniqueness key.</param> +/// <param name="GoogleSubject"> +/// Subject identifier of the linked Google identity; <see langword="null"/> for a password account. +/// </param> +/// <param name="PasswordHash"> +/// ASP.NET Core Identity password hash; <see langword="null"/> for an account that signs in through Google only. +/// </param> +/// <param name="CreatedAt">Moment the account was created, in UTC.</param> internal sealed record AccountRecord( Guid Id, string AccountName, @@ -13,6 +26,11 @@ internal sealed record AccountRecord( string? PasswordHash, DateTimeOffset CreatedAt); +/// <summary>A session as the store keeps it.</summary> +/// <param name="TokenDigest">SHA-256 digest of the session token. The token itself is never stored.</param> +/// <param name="AccountId">Identifier of the account the session belongs to.</param> +/// <param name="CreatedAt">Moment the session was opened, in UTC.</param> +/// <param name="ExpiresAt">Moment the session stops being valid, in UTC.</param> internal sealed record SessionRecord( byte[] TokenDigest, Guid AccountId, diff --git a/apps/api/Accounts/AccountService.cs b/apps/api/Accounts/AccountService.cs index 7168eb6..cd3e711 100644 --- a/apps/api/Accounts/AccountService.cs +++ b/apps/api/Accounts/AccountService.cs @@ -5,10 +5,14 @@ using Microsoft.AspNetCore.Identity; namespace Progmasoft.Progmaweb.Api.Accounts; +/// <summary>Registration and sign-in rules of the account service.</summary> +/// <param name="store">Store that keeps accounts.</param> +/// <param name="timeProvider">Clock used for creation times.</param> internal sealed class AccountService( IAccountStore store, TimeProvider timeProvider) { + /// <summary>An account that never exists; the subject of the dummy verification.</summary> private static readonly AccountRecord DummyAccount = new( Guid.Empty, "Missing00", @@ -18,11 +22,24 @@ internal sealed class AccountService( null, string.Empty, DateTimeOffset.UnixEpoch); + /// <summary> + /// A hash that no submitted password matches. Verifying against it makes a sign-in with an unknown address cost + /// as much as one with a wrong password. + /// </summary> private static readonly string DummyPasswordHash = new PasswordHasher<AccountRecord>().HashPassword(DummyAccount, "not-the-provided-password"); + /// <summary>Hashes and verifies passwords with the ASP.NET Core Identity algorithm.</summary> private readonly PasswordHasher<AccountRecord> passwordHasher = new(); + /// <summary>Creates a password account and opens a session for it.</summary> + /// <param name="request">The registration body.</param> + /// <param name="sessions">Session service.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns> + /// The signed-in account and no errors, or no account and the messages of each refused field, keyed by + /// <c>accountName</c>, <c>email</c> and <c>password</c>. + /// </returns> public async ValueTask<(AuthenticatedAccount? Authentication, Dictionary<string, string[]> Errors)> RegisterAsync( RegisterAccountRequest request, SessionService sessions, @@ -63,6 +80,15 @@ internal sealed class AccountService( return (authentication, errors); } + /// <summary>Signs in with email address and password.</summary> + /// <remarks> + /// An unknown address costs one password verification against a private dummy hash, so the response time does not + /// reveal whether an address is registered. + /// </remarks> + /// <param name="request">The login body.</param> + /// <param name="sessions">Session service.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The signed-in account, or <see langword="null"/> when the credentials are refused.</returns> public async ValueTask<AuthenticatedAccount?> LoginAsync( LoginAccountRequest request, SessionService sessions, @@ -92,6 +118,18 @@ internal sealed class AccountService( return await sessions.CreateAsync(account, cancellationToken); } + /// <summary>Signs in with a Google identity, creating the account on first use.</summary> + /// <remarks> + /// An identity that is already linked signs in to its account. A new identity creates an account under the + /// requested name. A new identity whose email address belongs to an existing account is refused: password + /// registrations do not verify the address, so linking by address could hand an account to someone else. + /// </remarks> + /// <param name="providerSubject">Subject identifier Google issued for the identity.</param> + /// <param name="providerEmail">Email address Google reported for the identity.</param> + /// <param name="requestedAccountName">Account name to create for a new identity; may be <see langword="null"/>.</param> + /// <param name="sessions">Session service.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The signed-in account, or no account and the reason.</returns> public async ValueTask<(AuthenticatedAccount? Authentication, GoogleAccountFailure Failure)> AuthenticateGoogleAsync( string? providerSubject, @@ -165,6 +203,12 @@ internal sealed class AccountService( return (await sessions.CreateAsync(account, cancellationToken), GoogleAccountFailure.None); } + /// <summary>Validates every field of a registration and collects the messages of the refused ones.</summary> + /// <param name="request">The registration body.</param> + /// <param name="accountName">The canonical account name.</param> + /// <param name="email">The email address as entered, trimmed.</param> + /// <param name="normalizedEmail">The email address in its lookup form.</param> + /// <returns>Messages keyed by field name; empty when the registration is valid.</returns> private static Dictionary<string, string[]> ValidateRegistration( RegisterAccountRequest request, out string accountName, @@ -192,11 +236,20 @@ internal sealed class AccountService( } } +/// <summary>Reason a sign-in with Google did not produce a session.</summary> internal enum GoogleAccountFailure { + /// <summary>The sign-in succeeded.</summary> None, + /// <summary> + /// The identity cannot be used: Google reported no usable subject or address, or the address belongs to another + /// account. + /// </summary> ProviderRejected, + /// <summary>The identity is new and no account name was supplied.</summary> AccountNameRequired, + /// <summary>The supplied account name breaks <see cref="AccountNamePolicy"/>.</summary> InvalidAccountName, + /// <summary>The supplied account name is taken.</summary> AccountNameUnavailable } diff --git a/apps/api/Accounts/EmailAddressPolicy.cs b/apps/api/Accounts/EmailAddressPolicy.cs index 9710b0c..898d07c 100644 --- a/apps/api/Accounts/EmailAddressPolicy.cs +++ b/apps/api/Accounts/EmailAddressPolicy.cs @@ -5,10 +5,22 @@ using System.Net.Mail; namespace Progmasoft.Progmaweb.Api.Accounts; +/// <summary>Rules for email addresses.</summary> +/// <remarks> +/// An address is accepted when it parses as a single mailbox without a display name and is at most 254 characters +/// long. Ownership of the address is not verified. +/// </remarks> internal static class EmailAddressPolicy { + /// <summary>Most characters an email address may have.</summary> public const int MaximumLength = 254; + /// <summary>Validates an email address and returns its stored and lookup forms.</summary> + /// <param name="value">The address as entered; surrounding white space is ignored.</param> + /// <param name="canonical">The trimmed address, kept as the owner wrote it.</param> + /// <param name="normalized">The address in upper case; the uniqueness key. Empty when the address is refused.</param> + /// <param name="error">Message for the user when the address is refused; empty otherwise.</param> + /// <returns><see langword="true"/> when the address is a single valid mailbox.</returns> public static bool TryNormalize(string? value, out string canonical, out string normalized, out string error) { canonical = value?.Trim() ?? string.Empty; diff --git a/apps/api/Accounts/IAccountStore.cs b/apps/api/Accounts/IAccountStore.cs index 6bb2955..cef8bee 100644 --- a/apps/api/Accounts/IAccountStore.cs +++ b/apps/api/Accounts/IAccountStore.cs @@ -3,14 +3,47 @@ namespace Progmasoft.Progmaweb.Api.Accounts; +/// <summary>Persistence of accounts and sessions.</summary> +/// <remarks> +/// The store owns the uniqueness rules: account name, email address and Google identity are each unique, and a +/// creation that would break one of them is refused atomically. It offers no enumeration and no bulk deletion. +/// </remarks> internal interface IAccountStore { + /// <summary>Stores a new account unless its name, email address or Google identity is taken.</summary> + /// <param name="account">The account to store, with its normalized keys filled in.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The stored account, or the uniqueness rule that refused it.</returns> ValueTask<CreateAccountResult> CreateAsync(AccountRecord account, CancellationToken cancellationToken); + /// <summary>Finds an account by its normalized email address.</summary> + /// <param name="normalizedEmail">Upper-case email address, as <see cref="EmailAddressPolicy"/> produces it.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The account, or <see langword="null"/> when no account has this address.</returns> ValueTask<AccountRecord?> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken); + /// <summary>Finds the account linked to a Google identity.</summary> + /// <param name="googleSubject">Subject identifier Google issued for the identity.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The account, or <see langword="null"/> when the identity is not linked.</returns> ValueTask<AccountRecord?> FindByGoogleSubjectAsync(string googleSubject, CancellationToken cancellationToken); + /// <summary>Finds an account by its identifier.</summary> + /// <param name="id">Identifier of the account.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The account, or <see langword="null"/> when it does not exist.</returns> ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken); + /// <summary>Stores a session, replacing one with the same token digest.</summary> + /// <param name="session">The session to store.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>A task that completes when the session is stored.</returns> ValueTask StoreSessionAsync(SessionRecord session, CancellationToken cancellationToken); + /// <summary>Finds a session by the digest of its token.</summary> + /// <param name="tokenDigest">SHA-256 digest of the session token.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>The session, or <see langword="null"/> when it does not exist or was revoked.</returns> ValueTask<SessionRecord?> FindSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken); + /// <summary>Removes a session. Revoking an unknown session is not an error.</summary> + /// <param name="tokenDigest">SHA-256 digest of the session token.</param> + /// <param name="cancellationToken">Cancels the operation.</param> + /// <returns>A task that completes when the session is gone.</returns> ValueTask RevokeSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken); } |