using System.Security.Claims; using DodoSSH.Domain; using DodoSSH.Infrastructure; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Options; using Npgsql; namespace DodoSSH.Api.Authorization; /// Resolves the authenticated caller, provisioning them on first sight. public interface ICurrentUserContext { /// /// Returns the caller's account, creating it if this is their first authenticated request. /// /// The request is not authenticated. Task GetOrProvisionAsync(CancellationToken cancellationToken); } /// /// Request-scoped caller identity with just-in-time provisioning. /// /// /// /// Identity is keyed on (issuer, subject), never on email. Matching an existing account by /// email means anyone who can obtain a token bearing a victim's email address — from any configured /// provider — inherits that victim's vaults, so it is opt-in configuration and off by default. /// /// /// Nothing here reads the email claim for authorization, and there is deliberately no hook left for /// anything that would. This class used to claim pending team invitations on the way past, which /// made a provider's assertion about an address into a decision about who joins a team; invitations are /// gone and membership is granted only to an account somebody named — see /// docs/adr/0009-team-access-model.md. The address is still recorded, for display. /// /// internal sealed class CurrentUserContext( IHttpContextAccessor accessor, DodoDbContext database, IOptions oidcOptions, TimeProvider clock) : ICurrentUserContext { /// /// How stale may get before a request refreshes it. /// /// /// An hour, and coarse on purpose in both directions. Writing it on every request would put an /// UPDATE on the hot path of every authenticated call and — because user_account carries /// the xmin concurrency token — would start losing races between a user's own overlapping /// requests. Writing it never is what made the old "last active" column impossible to offer /// honestly. An hour answers the question a colleague actually asks, which is "this week or not". /// private static readonly TimeSpan LastSeenWindow = TimeSpan.FromHours(1); private UserAccount? cached; /// public async Task GetOrProvisionAsync(CancellationToken cancellationToken) { if (cached is not null) { return cached; } var principal = accessor.HttpContext?.User ?? throw new InvalidOperationException("No HTTP context is available."); if (principal.Identity?.IsAuthenticated != true) { throw new InvalidOperationException("The request is not authenticated."); } var issuer = RequireClaim(principal, "iss"); var subject = RequireClaim(principal, "sub"); var options = oidcOptions.Value; var email = principal.FindFirstValue(options.EmailClaim); var displayName = principal.FindFirstValue(options.NameClaim); var existing = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false); if (existing is null) { cached = await ProvisionAsync(issuer, subject, email, displayName, cancellationToken) .ConfigureAwait(false); return cached; } cached = existing; await RefreshLastSeenAsync(existing, cancellationToken).ConfigureAwait(false); return cached; } /// /// Records that this account is active. /// /// /// ExecuteUpdateAsync rather than the change tracker, and the predicate rather than a /// read-then-write: user_account carries the xmin concurrency token, so two overlapping /// requests from one user would each read the row, each set the timestamp, and the second would /// fail on a version that had moved under it. This writes at most one row and cannot conflict. /// /// ◆ But the write moves xmin under the tracked entity, so the entity is re-read after it. It is /// not marked modified — that would enlist the user row in whatever the request saves next — and a /// stale timestamp in memory would change nothing on its own. A stale concurrency token does: the next /// save in the same request that touches this user (enrolment stamps EnrolledAtUtc) compared /// against the version from before the update and failed, so enrolling more than an hour after first /// signing in answered 500. Only when a row was actually written, which is once an hour at most. /// /// private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken) { var now = clock.GetUtcNow(); if (user.LastSeenAtUtc is { } seen && now - seen < LastSeenWindow) { return; } var written = await database.Users .Where(u => u.Id == user.Id && (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow)) .ExecuteUpdateAsync( setters => setters.SetProperty(u => u.LastSeenAtUtc, now), cancellationToken) .ConfigureAwait(false); if (written > 0) { await database.Entry(user).ReloadAsync(cancellationToken).ConfigureAwait(false); } } private Task FindAsync(string issuer, string subject, CancellationToken cancellationToken) => database.Users.SingleOrDefaultAsync( u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null, cancellationToken); private async Task ProvisionAsync( string issuer, string subject, string? email, string? displayName, CancellationToken cancellationToken) { var now = clock.GetUtcNow(); var user = new UserAccount { Id = Guid.CreateVersion7(), Issuer = issuer, Subject = subject, Email = email, DisplayName = displayName, Status = UserStatus.Active, CreatedAtUtc = now, UpdatedAtUtc = now, LastSeenAtUtc = now, }; database.Users.Add(user); try { await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false); return user; } catch (DbUpdateException exception) when (string.Equals( (exception.InnerException as PostgresException)?.SqlState, PostgresErrorCodes.UniqueViolation, StringComparison.Ordinal)) { // Two concurrent first requests from the same new user. The unique index on // (issuer, subject) is what makes this safe: one insert wins and the other reads it // back, rather than both proceeding with a duplicate account. database.Entry(user).State = EntityState.Detached; var winner = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false); if (winner is not null) { return winner; } // The unique violation came from something other than the race we expected — for // instance the partial unique index on email. Do not swallow it. throw; } } private static string RequireClaim(ClaimsPrincipal principal, string claimType) => principal.FindFirstValue(claimType) ?? throw new InvalidOperationException( $"The access token is missing the required '{claimType}' claim."); }