Public Access
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.
73 lines
3.2 KiB
C#
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;
|
|
}
|
|
}
|