// SPDX-FileCopyrightText: 2026 Progmasoft // SPDX-License-Identifier: AGPL-3.0-or-later WITH AdditionRef-Progmasoft-Patent-Grant-1.1 using System.Security.Cryptography; using Microsoft.Extensions.Options; namespace Progmasoft.Progmaweb.Api.Accounts; /// Settings of account sessions, bound from the AccountSessions configuration section. internal sealed class AccountSessionOptions { /// Name of the configuration section. public const string SectionName = "AccountSessions"; /// The longest a session may stay valid: 30 days. public const int MaximumLifetimeHours = 30 * 24; /// /// Hours a session stays valid, 30 days unless configured. Values outside 1 to /// are clamped to that range. /// public int LifetimeHours { get; init; } = MaximumLifetimeHours; /// /// Name of the session cookie. The __Host- prefix binds it to this host over HTTPS; a cookie shared with /// takes the __Secure- prefix instead, which browsers allow with a domain. /// public string CookieName { get; init; } = "__Host-ProgmasoftSession"; /// /// The domain the session cookie is shared with, such as progmasoft.com, or for a /// cookie of the account host alone. Production shares it so that the Progmasoft Console on /// console.progmasoft.com knows the signed-in account without a sign-in of its own. /// public string? CookieDomain { get; init; } } /// Opens, checks and revokes account sessions. /// /// A session is a random 256-bit token that only the browser holds. The store keeps its SHA-256 digest, so a copy /// of the store does not reveal a usable token. /// /// Store that keeps the session digests. /// Clock used for creation and expiry. /// Session settings. internal sealed class SessionService( IAccountStore store, TimeProvider timeProvider, IOptions options) { /// The session settings in effect. private readonly AccountSessionOptions settings = options.Value; /// Opens a session for an account. /// The account that signed in. /// Cancels the operation. /// The account with the new session token and its expiry. public async ValueTask CreateAsync(AccountRecord account, CancellationToken cancellationToken) { byte[] tokenBytes = RandomNumberGenerator.GetBytes(32); string token = Convert.ToBase64String(tokenBytes) .Replace('+', '-') .Replace('/', '_') .TrimEnd('='); byte[] digest = SHA256.HashData(System.Text.Encoding.ASCII.GetBytes(token)); DateTimeOffset createdAt = timeProvider.GetUtcNow(); DateTimeOffset expiresAt = createdAt.AddHours(Math.Clamp(settings.LifetimeHours, 1, AccountSessionOptions.MaximumLifetimeHours)); await store.StoreSessionAsync(new SessionRecord(digest, account.Id, createdAt, expiresAt), cancellationToken); return new AuthenticatedAccount(account, token, expiresAt); } /// Resolves a session token to its account. /// The token from the session cookie. /// Cancels the operation. /// /// The account, or when the token is malformed, unknown or expired. An expired session is /// revoked on the way. /// public async ValueTask AuthenticateAsync(string? token, CancellationToken cancellationToken) { if (!TryDigest(token, out byte[] digest)) { return null; } SessionRecord? session = await store.FindSessionAsync(digest, cancellationToken); if (session is null) { return null; } if (session.ExpiresAt <= timeProvider.GetUtcNow()) { await store.RevokeSessionAsync(digest, cancellationToken); return null; } return await store.FindByIdAsync(session.AccountId, cancellationToken); } /// Ends the session of a token. A malformed or unknown token is ignored. /// The token from the session cookie. /// Cancels the operation. /// A task that completes when the session is gone. public async ValueTask RevokeAsync(string? token, CancellationToken cancellationToken) { if (TryDigest(token, out byte[] digest)) { await store.RevokeSessionAsync(digest, cancellationToken); } } /// Creates the attributes of the session cookie. /// /// Moment the session expires; the cookie expires with it. describes the cookie for its /// deletion. /// /// Options for a secure, HTTP-only, same-site cookie on the root path of the configured domain. public CookieOptions CreateCookieOptions(DateTimeOffset? expiresAt) => new() { Domain = string.IsNullOrEmpty(settings.CookieDomain) ? null : settings.CookieDomain, Expires = expiresAt, HttpOnly = true, IsEssential = true, Path = "/", SameSite = SameSiteMode.Lax, Secure = true }; /// Finds the account of the session cookie of a request. /// The request. /// The account, or without a valid session. public ValueTask AuthenticateAsync(HttpContext context) { context.Request.Cookies.TryGetValue(CookieName, out string? token); return AuthenticateAsync(token, context.RequestAborted); } /// Name of the session cookie. public string CookieName => settings.CookieName; /// Computes the digest of a token that has the shape of a session token. /// The candidate token. /// The SHA-256 digest; empty when the token is refused. /// when the token has a plausible length and alphabet. private static bool TryDigest(string? token, out byte[] digest) { digest = []; if (token is null || token.Length is < 40 or > 64 || token.Any(character => !(char.IsAsciiLetterOrDigit(character) || character is '-' or '_'))) { return false; } digest = SHA256.HashData(System.Text.Encoding.ASCII.GetBytes(token)); return true; } }