Read-only mirrors. Development, issues and pull requests are on GitHub.

summaryrefslogtreecommitdiffhomepage
path: root/apps/api
diff options
context:
space:
mode:
authorLeitwolf112026-10-03 18:46:59 +0300
committerLeitwolf112026-10-03 18:46:59 +0300
commitb84a2216c26cc5392d0dc300b7a605ccb288d8c8 (patch)
tree048ae623f49cc5f2f94234d747351f9e659f5267 /apps/api
parentec774db15976385a6d5ea6f48d3622fe507d3843 (diff)
parentcf444eb3bccf99c10f59ed5330b61c0397e0aeaa (diff)
downloadprogmaweb-fix/api-without-google.tar.gz
Merge remote-tracking branch 'origin/main' into fix/api-without-googlefix/api-without-google
Diffstat (limited to 'apps/api')
-rw-r--r--apps/api/Accounts/AccountContracts.cs38
-rw-r--r--apps/api/Accounts/AccountEndpoints.cs67
-rw-r--r--apps/api/Accounts/AccountNamePolicy.cs19
-rw-r--r--apps/api/Accounts/AccountRecord.cs18
-rw-r--r--apps/api/Accounts/AccountService.cs53
-rw-r--r--apps/api/Accounts/EmailAddressPolicy.cs12
-rw-r--r--apps/api/Accounts/IAccountStore.cs33
-rw-r--r--apps/api/Accounts/InMemoryAccountStore.cs18
-rw-r--r--apps/api/Accounts/PasswordPolicy.cs11
-rw-r--r--apps/api/Accounts/SessionService.cs36
-rw-r--r--apps/api/Progmaweb.Api.csproj3
-rw-r--r--apps/api/Program.cs5
-rw-r--r--apps/api/Security/ForwardedHeadersPolicy.cs9
-rw-r--r--apps/api/Security/SecurityHeadersMiddleware.cs9
14 files changed, 331 insertions, 0 deletions
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);
}
diff --git a/apps/api/Accounts/InMemoryAccountStore.cs b/apps/api/Accounts/InMemoryAccountStore.cs
index 9f183e7..7a6930f 100644
--- a/apps/api/Accounts/InMemoryAccountStore.cs
+++ b/apps/api/Accounts/InMemoryAccountStore.cs
@@ -5,15 +5,27 @@ namespace Progmasoft.Progmaweb.Api.Accounts;
// This development store models the uniqueness and session semantics required by the future PostgreSQL implementation.
// It deliberately exposes no enumeration or bulk-delete surface to public endpoints.
+/// <summary>An <see cref="IAccountStore"/> that keeps everything in the memory of the process.</summary>
+/// <remarks>
+/// It implements the uniqueness and session rules a durable store must keep, under one lock. Nothing survives a
+/// restart of the service: every account and every session is lost when the process stops.
+/// </remarks>
internal sealed class InMemoryAccountStore : IAccountStore
{
+ /// <summary>Guards every dictionary below; each operation holds it for its whole duration.</summary>
private readonly Lock gate = new();
+ /// <summary>Every account, by identifier.</summary>
private readonly Dictionary<Guid, AccountRecord> accountsById = [];
+ /// <summary>Account identifiers by normalized email address; the uniqueness index of addresses.</summary>
private readonly Dictionary<string, Guid> accountIdsByEmail = new(StringComparer.Ordinal);
+ /// <summary>Account identifiers by Google subject; the uniqueness index of linked identities.</summary>
private readonly Dictionary<string, Guid> accountIdsByGoogleSubject = new(StringComparer.Ordinal);
+ /// <summary>Account identifiers by normalized account name; the uniqueness index of names.</summary>
private readonly Dictionary<string, Guid> accountIdsByName = new(StringComparer.Ordinal);
+ /// <summary>Sessions by the hexadecimal form of their token digest.</summary>
private readonly Dictionary<string, SessionRecord> sessionsByDigest = new(StringComparer.Ordinal);
+ /// <inheritdoc/>
public ValueTask<CreateAccountResult> CreateAsync(AccountRecord account, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -46,6 +58,7 @@ internal sealed class InMemoryAccountStore : IAccountStore
}
}
+ /// <inheritdoc/>
public ValueTask<AccountRecord?> FindByGoogleSubjectAsync(
string googleSubject,
CancellationToken cancellationToken)
@@ -58,6 +71,7 @@ internal sealed class InMemoryAccountStore : IAccountStore
}
}
+ /// <inheritdoc/>
public ValueTask<AccountRecord?> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -68,6 +82,7 @@ internal sealed class InMemoryAccountStore : IAccountStore
}
}
+ /// <inheritdoc/>
public ValueTask<AccountRecord?> FindByIdAsync(Guid id, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -78,6 +93,7 @@ internal sealed class InMemoryAccountStore : IAccountStore
}
}
+ /// <inheritdoc/>
public ValueTask StoreSessionAsync(SessionRecord session, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -88,6 +104,7 @@ internal sealed class InMemoryAccountStore : IAccountStore
}
}
+ /// <inheritdoc/>
public ValueTask<SessionRecord?> FindSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
@@ -98,6 +115,7 @@ internal sealed class InMemoryAccountStore : IAccountStore
}
}
+ /// <inheritdoc/>
public ValueTask RevokeSessionAsync(byte[] tokenDigest, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
diff --git a/apps/api/Accounts/PasswordPolicy.cs b/apps/api/Accounts/PasswordPolicy.cs
index baf8c75..6a5bf38 100644
--- a/apps/api/Accounts/PasswordPolicy.cs
+++ b/apps/api/Accounts/PasswordPolicy.cs
@@ -3,11 +3,22 @@
namespace Progmasoft.Progmaweb.Api.Accounts;
+/// <summary>Rules for passwords.</summary>
+/// <remarks>
+/// Only length is constrained, so long passphrases are welcome. The upper bound keeps hashing cost bounded for a
+/// request that an anonymous caller can send.
+/// </remarks>
internal static class PasswordPolicy
{
+ /// <summary>Fewest characters a password may have.</summary>
public const int MinimumLength = 12;
+ /// <summary>Most characters a password may have.</summary>
public const int MaximumLength = 256;
+ /// <summary>Validates a new password.</summary>
+ /// <param name="password">The password in plain text.</param>
+ /// <param name="error">Message for the user when the password is refused; empty otherwise.</param>
+ /// <returns><see langword="true"/> when the password