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.");
}