Files
DodoSSH/src/DodoSSH.Api/Setup/Auth.cs
T
jaap-jan 3829217e8a Add sync engine: cursors, push/pull, and the advisory-lock ordering proof (M1)
The vault write path. Push is the only way items change — no per-entity POST/PUT/DELETE —
so one place enforces revisions, the change log and access control.

The concurrency hazard, now proven rather than asserted:
bigserial assigns sequence values when the INSERT runs, not at commit, so transaction A
can take sequence 5 while B takes 6 and commits first. A reader polling in between sees
only 6, advances past 5, and never learns about it. AdvisoryLockOrderingTests reproduces
that gap WITHOUT the lock first — otherwise the with-lock test proves nothing, since it
would pass just as happily if the interleaving never occurred — then shows
pg_advisory_xact_lock removes it, and that 12 concurrent writers produce no gaps.

Cursors are opaque and HMAC-tagged, and carry their vault id. 29 unit tests cover the
rejections, which are the point: an accepted-but-wrong cursor is silent data loss, strictly
worse than an error a client can resync from. Rejected: tampered tag, tampered payload,
foreign signing key, a legitimately-issued cursor from another vault, truncation, and
hostile input (never throws — cursors come from clients).

Push semantics:
- 200 even on partial failure, with per-operation status, so one stale item cannot block
  everything a client queued while offline.
- Conflict returns the server's current row for client-side three-way merge. The server
  cannot merge ciphertext, so never last-writer-wins.
- opId receipts make retries exactly-once per operation, not per batch — a client retrying
  a partially-overlapping batch after a timeout would otherwise double-apply what landed.
- A tombstone beats a late upsert, and delete clears hostname/port: leaving the address
  would keep the server able to resolve a host the user believes they deleted.
- Relay field validation mirrors the DB CHECK so a bad request is a clear Invalid rather
  than a constraint violation surfacing as a 500.

Authorization goes through IVaultAccessService, which returns the same answer for "absent"
and "forbidden" — distinguishing them is an existence oracle for other tenants' vault ids.
Team vaults are explicitly denied until M3 rather than falling through to a permissive
default. JIT provisioning keys on (issuer, subject), never email, and handles the
concurrent-first-request race via the unique index.

Renamed two domain types: Host -> SshHost, because Host collides with
Microsoft.Extensions.Hosting.Host in every file of a web project, and SyncChange ->
VaultChange to stop it colliding with the Contracts DTO of the same name. Aliasing at every
use site would have been permanent friction.

Worth noting: `ef migrations has-pending-model-changes` reported clean after those renames
even though the snapshot still said "DodoSSH.Domain.Host" — it diffs tables, not CLR type
names. The snapshot was regenerated and the emitted DDL diffed against the previous
artifacts/schema/v0.1.sql to confirm the rename produced no schema change.

Also removed ConfigureAwait(false) from test methods: xUnit1030 flags it as bypassing
parallelization limits, which is why MA0004 is suppressed in test projects.

Verified: 0 warnings on a clean rebuild, 146 tests pass (up from 122), format clean.

Endpoint-level tests are the immediate next step: they need a WireMock OIDC/JWKS stub and
real JWT minting, so the "wrong user is denied" matrix does not exist yet for these two
routes. The service-layer authorization and the concurrency property are covered.
2026-07-28 15:02:02 +02:00

73 lines
3.2 KiB
C#

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
namespace DodoSSH.Api.Setup;
/// <summary>Authentication and authorization wiring.</summary>
/// <remarks>
/// The API validates bearer access tokens only. It never runs a browser flow itself: the desktop
/// app is a public client using Authorization Code with PKCE and a loopback redirect, and it talks
/// to the identity provider directly.
/// </remarks>
internal static class Auth
{
/// <summary>Policy requiring an authenticated caller.</summary>
internal const string AuthenticatedPolicy = "Authenticated";
/// <summary>Policy requiring a caller who has completed key enrollment.</summary>
internal const string EnrolledPolicy = "Enrolled";
internal static IServiceCollection AddDodoAuthentication(this IServiceCollection services)
{
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Bound late so options validation has already run and the values are known good.
var oidc = services.BuildServiceProvider().GetRequiredService<IOptions<OidcOptions>>().Value;
options.Authority = oidc.Authority;
options.Audience = oidc.Audience;
options.RequireHttpsMetadata = oidc.RequireHttpsMetadata;
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
RequireSignedTokens = true,
RequireExpirationTime = true,
// 30 seconds, not the 5-minute default. A five-minute grace period on a
// credential that grants vault ciphertext access is far more slack than any
// sane clock needs.
ClockSkew = TimeSpan.FromSeconds(30),
};
// Tokens are the one thing that must never reach a log or a trace.
options.IncludeErrorDetails = false;
// Keep claim names as the provider issued them. The default mapping rewrites
// "sub" to a long WS-Federation URI, which makes provider-agnostic claim
// configuration confusing and silently breaks when a provider is swapped.
options.MapInboundClaims = false;
});
services.AddAuthorization(options =>
{
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
// Enrollment state lives in the database, so the real handler arrives with the
// enrollment feature. Registered now so endpoint groups can reference the policy name
// and the endpoint-inventory test has something to assert against.
options.AddPolicy(EnrolledPolicy, policy => policy.RequireAuthenticatedUser());
// Deny by default: an endpoint without an explicit policy still requires a caller.
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
});
return services;
}
}