Files
DodoSSH/src/DodoSSH.Api/Authorization/CurrentUserContext.cs
T
jaap-jan 69bc9e270b Let a team be joined only by somebody who is already here
An invitation decided access from an assertion about an address. Everything else
in this model decides it from something a person did — an admin naming an
account, a key holder wrapping a vault key to a key they verified — and this was
the one place a token's email claim was the thing that let somebody in.

It was guarded as tightly as that can be guarded: the claim was refused outright
on an unverified or absent `email_verified`, with no setting to relax it. But the
guard and the risk were the same shape. The whole defence was one boolean sent by
a system the deployment does not control.

So `POST /teams/{id}/members` is the only way in, and an address with no account
is refused with `no-such-account` — which is now the end of the road rather than
the signal to invite. Both clients say the remedy: that person signs in here
once, which is what creates the account, and then they can be added. The desktop
leaves the address in the box, because a message telling you to come back later
is one you act on later.

Gone with it: the `team_invitation` table, the claim hook in the sign-in path,
and `Oidc:EmailVerifiedClaim`, which that hook was the only reader of. Nothing in
the server now reads the email claim to decide anything.

Pending invitations are dropped rather than converted. Converting one would mean
creating a membership because an address matched, which is the property being
removed — and an invitation to an address that did have an account here had
already been claimed by the hourly sweep, so what is left is offers to people who
never arrived.

Two tests carry the property rather than the feature: the endpoint inventory
asserts the three routes are absent, and the API suite adds an address that has
no account, watches the refusal, then signs that address in and checks it joined
nothing. Without the second half, a server that merely renamed the deferred path
would pass.
2026-08-05 08:28:57 +02:00

188 lines
7.3 KiB
C#

using System.Security.Claims;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Npgsql;
namespace DodoSSH.Api.Authorization;
/// <summary>Resolves the authenticated caller, provisioning them on first sight.</summary>
public interface ICurrentUserContext
{
/// <summary>
/// Returns the caller's account, creating it if this is their first authenticated request.
/// </summary>
/// <exception cref="InvalidOperationException">The request is not authenticated.</exception>
Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken);
}
/// <summary>
/// Request-scoped caller identity with just-in-time provisioning.
/// </summary>
/// <remarks>
/// <para>
/// Identity is keyed on <c>(issuer, subject)</c>, 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.
/// </para>
/// <para>
/// <b>Nothing here reads the email claim for authorization, and there is deliberately no hook left for
/// anything that would.</b> 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
/// <c>docs/adr/0009-team-access-model.md</c>. The address is still recorded, for display.
/// </para>
/// </remarks>
internal sealed class CurrentUserContext(
IHttpContextAccessor accessor,
DodoDbContext database,
IOptions<Setup.OidcOptions> oidcOptions,
TimeProvider clock)
: ICurrentUserContext
{
/// <summary>
/// How stale <see cref="UserAccount.LastSeenAtUtc"/> may get before a request refreshes it.
/// </summary>
/// <remarks>
/// 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 <c>user_account</c> 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".
/// </remarks>
private static readonly TimeSpan LastSeenWindow = TimeSpan.FromHours(1);
private UserAccount? cached;
/// <inheritdoc />
public async Task<UserAccount> 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;
}
/// <summary>
/// Records that this account is active.
/// </summary>
/// <remarks>
/// <c>ExecuteUpdateAsync</c> rather than the change tracker, and the predicate rather than a
/// read-then-write: <c>user_account</c> 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.
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes
/// nothing, and marking it modified would enlist the user row in whatever the request saves next.
/// </remarks>
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
{
var now = clock.GetUtcNow();
if (user.LastSeenAtUtc is { } seen && now - seen < LastSeenWindow)
{
return;
}
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);
}
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
database.Users.SingleOrDefaultAsync(
u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null,
cancellationToken);
private async Task<UserAccount> 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.");
}