Merge branch 'claude/api-fastendpoints-migration-020431'
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s

This commit is contained in:
2026-07-31 10:10:22 +02:00
21 changed files with 1287 additions and 201 deletions
+1
View File
@@ -11,6 +11,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="FastEndpoints" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
</ItemGroup>
@@ -1,88 +1,86 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using FastEndpoints;
using Microsoft.AspNetCore.Http.HttpResults;
namespace DodoSSH.Api.Features.Identity;
/// <summary>
/// The caller's own identity: profile, unlock state and enrollment.
/// The caller's own profile, unlock state and reachable vaults.
/// </summary>
/// <remarks>
/// The group runs under <see cref="Auth.AuthenticatedPolicy"/> rather than
/// <see cref="Auth.EnrolledPolicy"/>, and must: <c>GET /</c> and <c>POST /enrollment</c> are how a client
/// discovers that it needs to enroll and then does so, so gating them on enrollment would make enrollment
/// unreachable. <c>POST /devices</c> is the exception and adds the stricter policy itself.
/// This is the first authenticated call a client makes and the only one that works before enrollment,
/// so it must answer "what do I do next" — either enroll, or unlock with these parameters and open
/// these vaults.
/// </remarks>
internal static class IdentityEndpoints
internal sealed class GetMeEndpoint(ICurrentUserContext currentUser, IdentityService identity)
: EndpointWithoutRequest<Ok<MeResponse>>
{
internal static IEndpointRouteBuilder MapIdentityEndpoints(this IEndpointRouteBuilder app)
/// <inheritdoc />
public override void Configure()
{
var group = app.MapGroup("/api/v1/me")
.RequireAuthorization(Auth.AuthenticatedPolicy)
.WithTags("Identity");
// No trailing slash. The desktop client sends exactly "/api/v1/me".
Get("/api/v1/me");
group.MapGet("/", GetMeAsync)
// Authenticated rather than enrolled, and must be: this is how a client discovers that it
// needs to enroll, so gating it on enrollment would make enrollment unreachable.
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("GetMe")
.WithSummary("The caller's profile, enrollment state and reachable vaults.");
group.MapPost("/enrollment", EnrollAsync)
.WithName("Enroll")
.WithSummary("Publishes the caller's first identity key and creates their personal vault.");
// The one endpoint in this group that does require enrollment, and it says so itself rather than
// relying on the group. You cannot wrap a bundle to a device before you have a bundle, and the
// authorization handler turns the unmet requirement into "enrollment-required" — a better answer
// than a 400 from validation about state the caller could not have known.
group.MapPost("/devices", RegisterDeviceAsync)
.RequireAuthorization(Auth.EnrolledPolicy)
.WithName("RegisterDevice")
.WithSummary("Registers a device key so this machine can unlock without the passphrase.");
// On the group's ordinary policy, unlike registering. Registering needs a bundle to seal, so
// demanding enrollment says something true; revoking needs nothing but the account, and a user whose
// enrollment state is somehow in doubt is exactly who should still be able to withdraw a laptop they
// have lost. Not enrolled means no devices, which this answers as 404 and no harm done.
group.MapDelete("/devices/{deviceId:guid}", RevokeDeviceAsync)
.WithName("RevokeDevice")
.WithSummary("Withdraws a device key, so that machine can no longer unlock without the passphrase.");
return app;
.WithSummary("The caller's profile, enrollment state and reachable vaults.")
.WithTags("Identity"));
}
private static async Task<Ok<MeResponse>> GetMeAsync(
ICurrentUserContext currentUser,
IdentityService identity,
CancellationToken cancellationToken)
/// <inheritdoc />
public override async Task<Ok<MeResponse>> ExecuteAsync(CancellationToken ct)
{
// Provisioning happens here, on the first authenticated request, keyed on (issuer, subject).
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
return TypedResults.Ok(
await identity.GetMeAsync(user, cancellationToken).ConfigureAwait(false));
return TypedResults.Ok(await identity.GetMeAsync(user, ct).ConfigureAwait(false));
}
}
/// <summary>Publishes the caller's first identity key and creates their personal vault.</summary>
internal sealed class EnrollEndpoint(ICurrentUserContext currentUser, EnrollmentService enrollment)
: Endpoint<EnrollmentRequest, Results<Ok<EnrollmentResponse>, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
Post("/api/v1/me/enrollment");
// Authenticated rather than enrolled, for the reason GetMe gives: this is the call that stops
// a caller needing enrollment.
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("Enroll")
.WithSummary("Publishes the caller's first identity key and creates their personal vault.")
.WithTags("Identity"));
}
private static async Task<Results<Ok<EnrollmentResponse>, ProblemHttpResult>> EnrollAsync(
EnrollmentRequest request,
ICurrentUserContext currentUser,
EnrollmentService enrollment,
CancellationToken cancellationToken)
/// <inheritdoc />
public override async Task<Results<Ok<EnrollmentResponse>, ProblemHttpResult>> ExecuteAsync(
EnrollmentRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
try
{
// 200 rather than 201. A retry of an identical request returns the same body, so there
// is no single moment of creation to point a Location header at, and the client already
// knows the vault id — it chose it.
var response = await enrollment.EnrollAsync(user, request, cancellationToken)
.ConfigureAwait(false);
var response = await enrollment.EnrollAsync(user, req, ct).ConfigureAwait(false);
return TypedResults.Ok(response);
}
catch (EnrollmentInvalidException exception)
{
return Problem(
return Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.InvalidEnrollment,
exception.Message);
@@ -91,71 +89,109 @@ internal static class IdentityEndpoints
{
// 400, not 401: the access token authenticated the caller perfectly well. What failed is
// the separate assertion they supplied about their own keys, which is request content.
return Problem(
return Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.IdentityBindingInvalid,
exception.Message);
}
catch (AlreadyEnrolledException exception)
{
return Problem(
return Problems.Coded(
StatusCodes.Status409Conflict,
ProblemCodes.AlreadyEnrolled,
exception.Message);
}
}
}
/// <remarks>
/// 200 rather than 201, for the reason enrollment gives: re-registering the same public key returns the
/// existing device, so there is no single moment of creation to point a Location header at.
/// </remarks>
private static async Task<Results<Ok<RegisterDeviceResponse>, ProblemHttpResult>> RegisterDeviceAsync(
RegisterDeviceRequest request,
ICurrentUserContext currentUser,
DeviceService devices,
CancellationToken cancellationToken)
/// <summary>Registers a device key so this machine can unlock without the passphrase.</summary>
/// <remarks>
/// 200 rather than 201, for the reason enrollment gives: re-registering the same public key returns the
/// existing device, so there is no single moment of creation to point a Location header at.
/// </remarks>
internal sealed class RegisterDeviceEndpoint(ICurrentUserContext currentUser, DeviceService devices)
: Endpoint<RegisterDeviceRequest, Results<Ok<RegisterDeviceResponse>, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
Post("/api/v1/me/devices");
// The one endpoint in this area that does require enrollment. You cannot wrap a bundle to a
// device before you have a bundle, and the authorization handler turns the unmet requirement
// into "enrollment-required" — a better answer than a 400 from validation about state the
// caller could not have known.
Policies(Auth.AuthenticatedPolicy, Auth.EnrolledPolicy);
Description(b => b
.WithName("RegisterDevice")
.WithSummary("Registers a device key so this machine can unlock without the passphrase.")
.WithTags("Identity"));
}
/// <inheritdoc />
public override async Task<Results<Ok<RegisterDeviceResponse>, ProblemHttpResult>> ExecuteAsync(
RegisterDeviceRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
try
{
var response = await devices.RegisterAsync(user, request, cancellationToken)
.ConfigureAwait(false);
var response = await devices.RegisterAsync(user, req, ct).ConfigureAwait(false);
return TypedResults.Ok(response);
}
catch (DeviceRegistrationInvalidException exception)
{
return Problem(
return Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.InvalidDeviceRegistration,
exception.Message);
}
}
}
/// <remarks>
/// 404 for a device that is not there, rather than a bland 204. A revocation is one of the few calls
/// where succeeding on a typo would be a real disservice — "revoked" is what the user reads, and reading
/// it about the wrong id is worse than being told to look again. Clients that are only driving towards
/// "this machine cannot unlock" can treat 404 as having arrived, which is what the desktop client does.
/// </remarks>
private static async Task<Results<NoContent, NotFound>> RevokeDeviceAsync(
Guid deviceId,
ICurrentUserContext currentUser,
DeviceService devices,
CancellationToken cancellationToken)
/// <summary>Withdraws a device key, so that machine can no longer unlock without the passphrase.</summary>
/// <remarks>
/// 404 for a device that is not there, rather than a bland 204. A revocation is one of the few calls
/// where succeeding on a typo would be a real disservice — "revoked" is what the user reads, and reading
/// it about the wrong id is worse than being told to look again. Clients that are only driving towards
/// "this machine cannot unlock" can treat 404 as having arrived, which is what the desktop client does.
/// </remarks>
internal sealed class RevokeDeviceEndpoint(ICurrentUserContext currentUser, DeviceService devices)
: EndpointWithoutRequest<Results<NoContent, NotFound>>
{
/// <inheritdoc />
public override void Configure()
{
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
Delete("/api/v1/me/devices/{deviceId:guid}");
var revoked = await devices.RevokeAsync(user, deviceId, cancellationToken).ConfigureAwait(false);
// Authenticated, not enrolled, unlike registering. Registering needs a bundle to seal, so
// demanding enrollment says something true; revoking needs nothing but the account, and a user
// whose enrollment state is somehow in doubt is exactly who should still be able to withdraw a
// laptop they have lost. Not enrolled means no devices, which this answers as 404 and no harm
// done.
Policies(Auth.AuthenticatedPolicy);
Description(b => b
.WithName("RevokeDevice")
.WithSummary("Withdraws a device key, so that machine can no longer unlock without the passphrase.")
.WithTags("Identity"));
}
/// <inheritdoc />
public override async Task<Results<NoContent, NotFound>> ExecuteAsync(CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
// Read from the route rather than bound onto a request DTO: this request has no body, and
// inventing a type to hold one route value — or adding the id to a shared contract record —
// would put a routing concern in the client's contract.
var deviceId = Route<Guid>("deviceId");
var revoked = await devices.RevokeAsync(user, deviceId, ct).ConfigureAwait(false);
return revoked ? TypedResults.NoContent() : TypedResults.NotFound();
}
private static ProblemHttpResult Problem(int statusCode, string code, string detail) =>
TypedResults.Problem(
detail: detail,
statusCode: statusCode,
type: ProblemCodes.TypeBaseUri + code,
extensions: new Dictionary<string, object?>(StringComparer.Ordinal) { ["code"] = code });
}
+57 -30
View File
@@ -2,6 +2,7 @@ using System.Reflection;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using FastEndpoints;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Extensions.Options;
@@ -15,7 +16,11 @@ namespace DodoSSH.Api.Features.Meta;
/// the normal case for self-hosted software — a client needs to ask what this particular server
/// supports rather than assume. See ADR 0002.
/// </remarks>
internal static class MetaEndpoints
internal sealed class GetMetaEndpoint(
IOptions<SyncOptions> sync,
IOptions<RelayOptions> relay,
IOptions<ServerOptions> server)
: EndpointWithoutRequest<Ok<MetaResponse>>
{
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
internal const int SyncProtocolVersion = 1;
@@ -23,27 +28,23 @@ internal static class MetaEndpoints
/// <summary>Feature flag for the relay.</summary>
internal const string RelayFeature = "relay";
internal static IEndpointRouteBuilder MapMetaEndpoints(this IEndpointRouteBuilder app)
/// <inheritdoc />
public override void Configure()
{
Get("/api/v1/meta");
// Anonymous by necessity: a client must be able to discover how to authenticate before it
// can authenticate.
app.MapGet("/api/v1/meta", GetMeta)
.AllowAnonymous()
// can authenticate. Load-bearing rather than decorative — FastEndpoints authenticates every
// endpoint that does not say otherwise, so removing this line is a 401 nobody can recover from.
AllowAnonymous();
Description(b => b
.WithName("GetMeta")
.WithSummary("Server capabilities, versions and limits.");
app.MapGet("/.well-known/dodossh-configuration", GetConfiguration)
.AllowAnonymous()
.WithName("GetDodoSshConfiguration")
.WithSummary("Everything a client needs to begin authenticating, from one URL.");
return app;
.WithSummary("Server capabilities, versions and limits."));
}
private static Ok<MetaResponse> GetMeta(
IOptions<SyncOptions> sync,
IOptions<RelayOptions> relay,
IOptions<ServerOptions> server)
/// <inheritdoc />
public override Task<Ok<MetaResponse>> ExecuteAsync(CancellationToken ct)
{
List<string> features = ["teams"];
if (relay.Value.Enabled)
@@ -51,7 +52,7 @@ internal static class MetaEndpoints
features.Add(RelayFeature);
}
return TypedResults.Ok(new MetaResponse(
return Task.FromResult(TypedResults.Ok(new MetaResponse(
ServerVersion: ServerVersion,
ApiVersions: [1],
SyncProtocolVersion: SyncProtocolVersion,
@@ -60,17 +61,48 @@ internal static class MetaEndpoints
MinClientVersion: server.Value.MinClientVersion,
MaxOperationsPerPush: sync.Value.MaxOperationsPerPush,
MaxPayloadBytes: sync.Value.MaxPayloadBytes,
MaxItemPayloadBytes: sync.Value.MaxItemPayloadBytes));
MaxItemPayloadBytes: sync.Value.MaxItemPayloadBytes)));
}
private static Ok<DodoSshConfiguration> GetConfiguration(
IOptions<OidcOptions> oidc,
IOptions<RelayOptions> relay,
IOptions<ServerOptions> server)
private static string ServerVersion { get; } =
typeof(GetMetaEndpoint).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? "0.0.0";
}
/// <summary>
/// Everything a client needs to begin authenticating, from one URL.
/// </summary>
/// <remarks>
/// This <em>is</em> the onboarding story: the user types one server URL and the client discovers the
/// identity provider, the client id and the relay from here. See ADR 0002.
/// </remarks>
internal sealed class GetDodoSshConfigurationEndpoint(
IOptions<OidcOptions> oidc,
IOptions<RelayOptions> relay,
IOptions<ServerOptions> server)
: EndpointWithoutRequest<Ok<DodoSshConfiguration>>
{
/// <inheritdoc />
public override void Configure()
{
Get("/.well-known/dodossh-configuration");
// Anonymous for the same reason as /api/v1/meta, and more so: this is the document that says
// where the identity provider is.
AllowAnonymous();
Description(b => b
.WithName("GetDodoSshConfiguration")
.WithSummary("Everything a client needs to begin authenticating, from one URL."));
}
/// <inheritdoc />
public override Task<Ok<DodoSshConfiguration>> ExecuteAsync(CancellationToken ct)
{
var relayOptions = relay.Value;
return TypedResults.Ok(new DodoSshConfiguration(
return Task.FromResult(TypedResults.Ok(new DodoSshConfiguration(
ApiBaseUrl: new Uri(server.Value.PublicBaseUrl, UriKind.Absolute),
Oidc: new OidcConfiguration(
Authority: new Uri(oidc.Value.Authority, UriKind.Absolute),
@@ -81,11 +113,6 @@ internal static class MetaEndpoints
Enabled: relayOptions.Enabled,
WebSocketUrl: relayOptions.Enabled && relayOptions.WebSocketUrl is not null
? new Uri(relayOptions.WebSocketUrl, UriKind.Absolute)
: null)));
: null))));
}
private static string ServerVersion { get; } =
typeof(MetaEndpoints).Assembly
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? "0.0.0";
}
+68 -50
View File
@@ -2,56 +2,57 @@ using DodoSSH.Api.Authorization;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain.Authorization;
using FastEndpoints;
using Microsoft.AspNetCore.Http.HttpResults;
namespace DodoSSH.Api.Features.Sync;
/// <summary>
/// The vault write path, and the delta read that pairs with it.
/// The delta read that pairs with the vault write path.
/// </summary>
/// <remarks>
/// Push is the <em>only</em> way vault items change; there are no per-entity POST, PUT or DELETE
/// endpoints. One place therefore enforces revisions, the change log and access control, which
/// halves both the endpoint count and the authorization surface. See ADR 0003.
/// </remarks>
internal static class SyncEndpoints
internal sealed class SyncPullEndpoint(
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
SyncService sync)
: Endpoint<SyncPullRequest, Results<Ok<SyncPullResponse>, NotFound, ProblemHttpResult>>
{
internal static IEndpointRouteBuilder MapSyncEndpoints(this IEndpointRouteBuilder app)
/// <inheritdoc />
public override void Configure()
{
// POST rather than GET: the filters live in the body, cursors are opaque, and no caching is
// wanted. Non-mutating despite the verb.
Post("/api/v1/vaults/{vaultId:guid}/sync/pull");
// Enrolled, not merely authenticated. A caller with no identity key holds no vault key
// either, so it can neither produce ciphertext anyone can read nor read what is there.
// Serving it would look like corruption; refusing with a code it can act on does not.
var group = app.MapGroup("/api/v1/vaults/{vaultId:guid}/sync")
.RequireAuthorization(Auth.EnrolledPolicy)
.WithTags("Sync");
Policies(Auth.EnrolledPolicy);
// POST rather than GET: the filters live in the body, cursors are opaque, and no caching is
// wanted. Non-mutating despite the verb.
group.MapPost("/pull", PullAsync)
Description(b => b
.WithName("SyncPull")
.WithSummary("Reads vault changes after a cursor.");
group.MapPost("/push", PushAsync)
.WithName("SyncPush")
.WithSummary("Applies a batch of vault changes.");
return app;
.WithSummary("Reads vault changes after a cursor.")
.WithTags("Sync"));
}
private static async Task<Results<Ok<SyncPullResponse>, NotFound, ProblemHttpResult>> PullAsync(
Guid vaultId,
SyncPullRequest request,
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
SyncService sync,
CancellationToken cancellationToken)
/// <inheritdoc />
public override async Task<Results<Ok<SyncPullResponse>, NotFound, ProblemHttpResult>> ExecuteAsync(
SyncPullRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
var access = await vaultAccess.ResolveAsync(user.Id, vaultId, cancellationToken)
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var access = await vaultAccess
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
.ConfigureAwait(false);
// 404 rather than 403, and identically for "absent" and "forbidden": distinguishing them is
// an existence oracle for other tenants' vault ids.
// an existence oracle for other tenants' vault ids. Decided before the cursor is looked at,
// so a cursor minted for a vault the caller cannot see answers "no such vault" rather than
// confirming the cursor was well-formed.
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
{
return TypedResults.NotFound();
@@ -59,27 +60,51 @@ internal static class SyncEndpoints
try
{
var response = await sync.PullAsync(access.Vault!, request, cancellationToken)
.ConfigureAwait(false);
var response = await sync.PullAsync(access.Vault!, req, ct).ConfigureAwait(false);
return TypedResults.Ok(response);
}
catch (InvalidCursorException exception)
{
return Problem(StatusCodes.Status400BadRequest, ProblemCodes.InvalidCursor, exception.Message);
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.InvalidCursor, exception.Message);
}
}
}
private static async Task<Results<Ok<SyncPushResponse>, NotFound, ProblemHttpResult>> PushAsync(
Guid vaultId,
SyncPushRequest request,
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
SyncService sync,
CancellationToken cancellationToken)
/// <summary>
/// The vault write path.
/// </summary>
/// <remarks>
/// See <see cref="SyncPullEndpoint"/> and ADR 0003 for why every mutation arrives here.
/// </remarks>
internal sealed class SyncPushEndpoint(
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
SyncService sync)
: Endpoint<SyncPushRequest, Results<Ok<SyncPushResponse>, NotFound, ProblemHttpResult>>
{
/// <inheritdoc />
public override void Configure()
{
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
var access = await vaultAccess.ResolveAsync(user.Id, vaultId, cancellationToken)
Post("/api/v1/vaults/{vaultId:guid}/sync/push");
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("SyncPush")
.WithSummary("Applies a batch of vault changes.")
.WithTags("Sync"));
}
/// <inheritdoc />
public override async Task<Results<Ok<SyncPushResponse>, NotFound, ProblemHttpResult>> ExecuteAsync(
SyncPushRequest req,
CancellationToken ct)
{
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var access = await vaultAccess
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
.ConfigureAwait(false);
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
@@ -90,7 +115,7 @@ internal static class SyncEndpoints
// Read but not Write: the vault exists and is visible, so 403 leaks nothing here.
if (!access.Permissions.HasFlag(PermissionFlags.Write))
{
return Problem(
return Problems.Coded(
StatusCodes.Status403Forbidden,
ProblemCodes.Forbidden,
"You do not have permission to modify this vault.");
@@ -100,28 +125,21 @@ internal static class SyncEndpoints
{
// 200 even when individual operations failed. Per-operation status is in the body, so a
// single stale item cannot block everything else a client queued while offline.
var response = await sync.PushAsync(access.Vault!, user.Id, request, cancellationToken)
.ConfigureAwait(false);
var response = await sync.PushAsync(access.Vault!, user.Id, req, ct).ConfigureAwait(false);
return TypedResults.Ok(response);
}
catch (PushBatchTooLargeException exception)
{
return Problem(
return Problems.Coded(
StatusCodes.Status413PayloadTooLarge,
ProblemCodes.PushBatchTooLarge,
exception.Message);
}
catch (PushBatchInvalidException exception)
{
return Problem(StatusCodes.Status400BadRequest, ProblemCodes.PushBatchTooLarge, exception.Message);
return Problems.Coded(
StatusCodes.Status400BadRequest, ProblemCodes.PushBatchTooLarge, exception.Message);
}
}
private static ProblemHttpResult Problem(int statusCode, string code, string detail) =>
TypedResults.Problem(
detail: detail,
statusCode: statusCode,
type: ProblemCodes.TypeBaseUri + code,
extensions: new Dictionary<string, object?>(StringComparer.Ordinal) { ["code"] = code });
}
+5
View File
@@ -15,6 +15,7 @@ builder.Services.AddDodoJson();
builder.Services.AddDodoOptions();
builder.Services.AddDodoPersistence(builder.Configuration);
builder.Services.AddDodoAuthentication();
builder.Services.AddDodoEndpoints();
builder.Services.AddDodoOpenApi();
builder.Services.AddDodoHealthChecks();
@@ -22,6 +23,8 @@ builder.Services.AddDodoHealthChecks();
// TimeProvider so time can be faked in tests.
builder.Services.AddSingleton(TimeProvider.System);
// FastEndpoints registers this too. Kept because CurrentUserContext needs it on its own merits, and
// the registration would be silently lost the day the endpoint framework changed again.
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserContext, CurrentUserContext>();
builder.Services.AddScoped<IVaultAccessService, VaultAccessService>();
@@ -48,6 +51,8 @@ var app = builder.Build();
// produces redirect loops behind a proxy. HTTPS in development comes from the
// launch profile instead.
app.BlockFastEndpointsRouteTable();
app.UseAuthentication();
app.UseAuthorization();
+28 -17
View File
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
@@ -54,24 +55,34 @@ internal static class Auth
options.MapInboundClaims = false;
});
services.AddAuthorization(options =>
{
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
// "enrollment-required" by DodoAuthorizationResultHandler, because an empty 403 cannot
// tell a client whether the problem is theirs to fix.
options.AddPolicy(
EnrolledPolicy,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new Authorization.EnrolledRequirement()));
// Deny by default: an endpoint without an explicit policy still requires a caller.
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
});
services.AddAuthorization(AddDodoPolicies);
return services;
}
private static void AddDodoPolicies(AuthorizationOptions options)
{
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
// "enrollment-required" by DodoAuthorizationResultHandler, because an empty 403 cannot
// tell a client whether the problem is theirs to fix.
options.AddPolicy(
EnrolledPolicy,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new Authorization.EnrolledRequirement()));
// Deny by default: an endpoint without an explicit policy still requires a caller.
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
// The same policy again, as the default rather than the fallback. FastEndpoints attaches
// authorization metadata to every endpoint that is not AllowAnonymous, and an endpoint that
// carries metadata is covered by the default policy rather than the fallback — so the
// fallback is no longer what secures the API. Setting both to the same policy keeps them
// from drifting, because tightening one and not the other would protect half the surface
// and look like it had protected all of it.
options.DefaultPolicy = options.GetPolicy(AuthenticatedPolicy)!;
}
}
+125 -14
View File
@@ -1,32 +1,143 @@
using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Meta;
using DodoSSH.Api.Features.Sync;
using DodoSSH.Contracts;
using FastEndpoints;
namespace DodoSSH.Api.Setup;
/// <summary>
/// The single, explicit list of every endpoint module.
/// The single, explicit list of every endpoint.
/// </summary>
/// <remarks>
/// Deliberately not reflection-based discovery. Explicit registration gives predictable startup,
/// survives trimming, and makes every route greppable — and a route that silently disappears
/// because an assembly was not scanned is a genuinely nasty failure. The cost is one line per
/// module.
/// FastEndpoints can find endpoints by scanning assemblies. It is deliberately not asked to. Explicit
/// registration gives predictable startup, survives trimming, and makes every route greppable from one
/// file — and a route that silently disappears because an assembly was not scanned is a genuinely nasty
/// failure. Scanning is worse here than in general: under <c>WebApplicationFactory</c> the scan reaches
/// the test assembly too, so an endpoint written in a test would be registered into the host under test.
/// The cost is one line per endpoint, and a line forgotten is a route missing with no compile error —
/// which is what <c>EndpointInventoryTests</c> exists to catch.
/// </remarks>
internal static class EndpointRegistration
{
/// <summary>
/// The route template FastEndpoints maps whether or not it is wanted.
/// </summary>
/// <remarks>
/// The registered template, not a request path: it is matched against what routing selected rather
/// than against anything a caller typed. FastEndpoints registers it without a leading slash.
/// </remarks>
private const string RouteTableTemplate = "_test_url_cache_";
internal static IServiceCollection AddDodoEndpoints(this IServiceCollection services) =>
services.AddFastEndpoints(new List<Type>
{
typeof(GetMetaEndpoint),
typeof(GetDodoSshConfigurationEndpoint),
typeof(GetMeEndpoint),
typeof(EnrollEndpoint),
typeof(RegisterDeviceEndpoint),
typeof(RevokeDeviceEndpoint),
typeof(SyncPullEndpoint),
typeof(SyncPushEndpoint),
// Registered as each feature lands:
// Identity — key rotation, passphrase change
// Directory — public-key lookup
// Vaults — grants, rekey, ACL
// Relay — tickets and the WebSocket
// Teams, Audit, Admin
});
/// <summary>Hides the endpoint listing FastEndpoints publishes at <c>GET /_test_url_cache_</c>.</summary>
/// <remarks>
/// <para>
/// <c>UseFastEndpoints</c> maps that route unconditionally, in every environment, carrying neither a
/// policy nor <c>AllowAnonymous</c>. It answers with every endpoint class name and route template the
/// server knows, to back a test helper this repository does not use, and there is no switch to turn
/// it off. The deny-by-default policy means a caller has to be authenticated to read it, which is not
/// the same as it being nobody's business.
/// </para>
/// <para>
/// This asks routing which endpoint it selected rather than comparing the request path, because the
/// path cannot be compared correctly by hand: routing matches literal segments case-insensitively
/// and tolerates a trailing slash, so <c>/_TEST_URL_CACHE_</c> and <c>/_test_url_cache_/</c> reach
/// the same endpoint as the canonical spelling. A hand-written comparison that agrees with the
/// matcher on Monday is a bypass on Tuesday. <c>WebApplication</c> inserts <c>UseRouting</c> ahead of
/// every middleware registered here, so the selected endpoint is already available; short-circuiting
/// before <c>UseEndpoints</c> is what keeps the answer independent of registration order.
/// </para>
/// </remarks>
internal static WebApplication BlockFastEndpointsRouteTable(this WebApplication app)
{
app.Use(static async (context, next) =>
{
if (context.GetEndpoint() is RouteEndpoint selected
&& string.Equals(
selected.RoutePattern.RawText?.Trim('/'),
RouteTableTemplate,
StringComparison.Ordinal))
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}
await next(context).ConfigureAwait(false);
});
return app;
}
internal static WebApplication MapDodoEndpoints(this WebApplication app)
{
app.MapMetaEndpoints();
app.MapIdentityEndpoints();
app.MapSyncEndpoints();
app.UseFastEndpoints(config =>
{
// Every route is written out in full in its own Configure(). A global prefix would rewrite
// all of them at once, and /.well-known/ is not under /api at all.
config.Endpoints.RoutePrefix = null;
// FastEndpoints serialises through its own copy of the host's JsonOptions, taken implicitly
// at this point. Applied again explicitly because that copy is undocumented, and Setup/Json.cs
// records what silent JSON drift on this exact surface already cost once.
DodoSshJsonContext.ApplyTo(config.Serializer.Options);
// A body that will not deserialise is answered here, before any handler runs. The default
// body is not a problem document despite the media type it claims, and it names the failing
// .NET type; Problems.ForBindingFailure says the same thing in the shape everything else uses.
config.Errors.ProducesMetadataType = null;
config.Errors.ResponseBuilder =
static (_, context, statusCode) => Problems.ForBindingFailure(context, statusCode);
// Applied to every endpoint rather than endpoint by endpoint: the default is the dangerous
// one, so the safe choice has to be the one nobody can forget.
config.Endpoints.Configurator =
static endpoint => endpoint.RequestBinder(typeof(BodyOnlyRequestBinder<>));
});
// Registered as each feature lands:
// Identity — key rotation, devices, passphrase change
// Directory — public-key lookup
// Vaults — grants, rekey, ACL
// Relay — tickets and the WebSocket
// Teams, Audit, Admin
return app;
}
}
/// <summary>
/// Binds a request DTO from the JSON body and nothing else.
/// </summary>
/// <remarks>
/// <para>
/// FastEndpoints' default binder deserialises the body and then writes route values, query-string
/// parameters, headers, claims and cookies over the top, matching DTO properties by name. Minimal APIs
/// bound a body parameter from the body alone, so leaving the default in place would silently widen
/// every request: <c>?cursor=…</c> would override the cursor in a pull body, and — the reason this is
/// not merely untidy — <c>?identityProviderToken=…</c> would let an ID token be supplied in a URL,
/// where proxies, browser history and access logs all keep copies of it. This API takes some trouble to
/// keep tokens out of logs; see <c>IncludeErrorDetails = false</c> in <see cref="Auth"/>.
/// </para>
/// <para>
/// Route values are still read, deliberately and one at a time, with <c>Route&lt;T&gt;("name")</c> in
/// the handlers that need one. That reads the route directly rather than through the DTO, so it is
/// unaffected by this.
/// </para>
/// </remarks>
/// <typeparam name="TRequest">The request DTO being bound.</typeparam>
internal sealed class BodyOnlyRequestBinder<TRequest>()
: RequestBinder<TRequest>(BindingSource.JsonBody)
where TRequest : notnull;
+7 -1
View File
@@ -6,7 +6,7 @@ namespace DodoSSH.Api.Setup;
internal static class Json
{
/// <summary>
/// Applies <see cref="DodoSshJsonContext"/>'s settings to the minimal-API serialiser.
/// Applies <see cref="DodoSshJsonContext"/>'s settings to the host's serialiser.
/// </summary>
/// <remarks>
/// <para>
@@ -22,6 +22,12 @@ internal static class Json
/// serialised its requests with <c>PostAsJsonAsync</c>'s defaults, so both sides agreed on integers and
/// nothing disagreed with anything.
/// </para>
/// <para>
/// FastEndpoints does not use this <see cref="System.Text.Json.JsonSerializerOptions"/> instance. It
/// copies from it, once, while the pipeline is being built. That copy is undocumented and process-wide,
/// so <c>Setup/EndpointRegistration.cs</c> applies the same settings to it explicitly rather than
/// trusting it — which is the lesson of the paragraph above, applied to the mechanism that replaced it.
/// </para>
/// </remarks>
internal static IServiceCollection AddDodoJson(this IServiceCollection services) =>
services.ConfigureHttpJsonOptions(options => DodoSshJsonContext.ApplyTo(options.SerializerOptions));
+93 -2
View File
@@ -1,3 +1,7 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.OpenApi;
using Microsoft.OpenApi;
namespace DodoSSH.Api.Setup;
/// <summary>
@@ -9,13 +13,15 @@ namespace DodoSSH.Api.Setup;
/// contract change fails the pull request. The desktop client's actual contract is the
/// <c>DodoSSH.Contracts</c> assembly, guarded by PublicApiAnalyzers.
/// </remarks>
internal static class OpenApi
internal static partial class OpenApi
{
internal const string DocumentName = "v1";
internal static IServiceCollection AddDodoOpenApi(this IServiceCollection services)
{
services.AddOpenApi(DocumentName);
services.AddOpenApi(
DocumentName,
options => options.AddOperationTransformer<RouteParameterTransformer>());
// Added in M1, once there are endpoints to describe:
// - a document transformer contributing the OAuth2 authorizationCode + PKCE
@@ -25,3 +31,88 @@ internal static class OpenApi
return services;
}
}
/// <summary>
/// Declares the path parameters that the route template uses but no handler parameter binds.
/// </summary>
/// <remarks>
/// <para>
/// Endpoints that need a route value read it with <c>Route&lt;T&gt;("name")</c> rather than binding it
/// onto the request DTO, so nothing in the endpoint's signature mentions it and the generator emits
/// <c>/api/v1/vaults/{vaultId}/sync/pull</c> with an empty <c>parameters</c> list. A template expression
/// with no matching parameter is invalid OpenAPI, and no client generator can fill it in — which would
/// quietly make the document useless for the third parties it exists for.
/// </para>
/// <para>
/// The constraint survives in <c>ApiDescription.RelativePath</c> even though the document's path key
/// strips it, which is what makes the type recoverable rather than guessed.
/// </para>
/// </remarks>
internal sealed partial class RouteParameterTransformer : IOpenApiOperationTransformer
{
/// <inheritdoc />
public Task TransformAsync(
OpenApiOperation operation,
OpenApiOperationTransformerContext context,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(operation);
ArgumentNullException.ThrowIfNull(context);
var template = context.Description.RelativePath;
if (string.IsNullOrEmpty(template))
{
return Task.CompletedTask;
}
foreach (Match match in TemplateParameter().Matches(template))
{
var name = match.Groups["name"].Value;
var alreadyDeclared = operation.Parameters?.Any(
parameter => string.Equals(parameter.Name, name, StringComparison.OrdinalIgnoreCase));
if (alreadyDeclared == true)
{
continue;
}
operation.Parameters ??= [];
operation.Parameters.Add(new OpenApiParameter
{
Name = name,
In = ParameterLocation.Path,
// A path parameter is required by definition; the specification rejects any other value.
Required = true,
Schema = SchemaFor(match.Groups["constraint"].Value),
});
}
return Task.CompletedTask;
}
/// <remarks>
/// Only the constraints this API actually uses are mapped. An unrecognised one becomes a plain
/// string, which is weaker than it could be but never wrong — the alternative, guessing, puts a type
/// in a published contract that the server does not enforce.
/// </remarks>
private static OpenApiSchema SchemaFor(string constraint) => constraint switch
{
"guid" => new OpenApiSchema { Type = JsonSchemaType.String, Format = "uuid" },
"int" => new OpenApiSchema { Type = JsonSchemaType.Integer, Format = "int32" },
"long" => new OpenApiSchema { Type = JsonSchemaType.Integer, Format = "int64" },
_ => new OpenApiSchema { Type = JsonSchemaType.String },
};
/// <summary>Matches <c>{name}</c> and <c>{name:constraint}</c>, ignoring catch-all and optional forms.</summary>
/// <remarks>
/// The timeout is there to satisfy MA0009 rather than because it can fire: the input is this
/// server's own route table, read once at document generation, and never anything a caller sent.
/// </remarks>
[GeneratedRegex(
@"\{(?<name>[A-Za-z_][A-Za-z0-9_]*)(?::(?<constraint>[^}]+))?\}",
RegexOptions.None,
matchTimeoutMilliseconds: 1000)]
private static partial Regex TemplateParameter();
}
+72
View File
@@ -0,0 +1,72 @@
using System.Diagnostics;
using DodoSSH.Contracts;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.WebUtilities;
namespace DodoSSH.Api.Setup;
/// <summary>
/// The one shape every error this API returns takes.
/// </summary>
/// <remarks>
/// RFC 9457 with a root-level <c>code</c>. The prose is for whoever reads a log; the code is what the
/// client branches on, which is why it is a constant in <see cref="ProblemCodes"/> and never a literal
/// at the call site. Shared rather than duplicated per feature because there are now several endpoint
/// classes per file, and a second copy is how two of them end up disagreeing.
/// </remarks>
internal static class Problems
{
/// <summary>An error a handler decided on.</summary>
internal static ProblemHttpResult Coded(int statusCode, string code, string detail) =>
TypedResults.Problem(
detail: detail,
statusCode: statusCode,
type: ProblemCodes.TypeBaseUri + code,
extensions: new Dictionary<string, object?>(StringComparer.Ordinal) { ["code"] = code });
/// <summary>The same shape, for the one path that cannot return an <see cref="IResult"/>.</summary>
/// <remarks>
/// FastEndpoints answers a request whose body will not deserialise before any handler runs, through
/// a builder that returns a plain object rather than a result — so <see cref="Coded"/> cannot be
/// reused and the shape has to be spelled out. Replacing the default is not cosmetic: it announces
/// <c>application/problem+json</c> while sending something that is not a problem document, and it
/// puts the failing .NET type name on the wire, which is exactly what <c>IncludeErrorDetails =
/// false</c> on the bearer handler exists to prevent.
/// </remarks>
internal static CodedProblem ForBindingFailure(HttpContext context, int statusCode)
{
ArgumentNullException.ThrowIfNull(context);
return new CodedProblem(
Type: ProblemCodes.TypeBaseUri + ProblemCodes.MalformedRequest,
Title: ReasonPhrases.GetReasonPhrase(statusCode),
Status: statusCode,
// Deliberately says nothing about which member failed. The binder knows, but naming it
// describes the server's types rather than the caller's request.
Detail: "The request body could not be read. Check it against the contract for the server "
+ "version reported by GET /api/v1/meta.",
Code: ProblemCodes.MalformedRequest,
TraceId: Activity.Current?.Id ?? context.TraceIdentifier);
}
}
/// <summary>A problem document written by something other than <c>TypedResults.Problem</c>.</summary>
/// <remarks>
/// Member order here is member order on the wire, and the names match what
/// <see cref="Microsoft.AspNetCore.Mvc.ProblemDetails"/> serialises to, so a client cannot tell which
/// of the two produced a given response.
/// </remarks>
/// <param name="Type">The problem type URI, formed under <see cref="ProblemCodes.TypeBaseUri"/>.</param>
/// <param name="Title">The status code's reason phrase.</param>
/// <param name="Status">The HTTP status code.</param>
/// <param name="Detail">Human-readable explanation. Never a secret and never a .NET type name.</param>
/// <param name="Code">The stable code the client switches on.</param>
/// <param name="TraceId">Correlates the response with the server's trace.</param>
internal sealed record CodedProblem(
string Type,
string Title,
int Status,
string Detail,
string Code,
string? TraceId);
+49
View File
@@ -2,6 +2,18 @@
"version": 2,
"dependencies": {
"net10.0": {
"FastEndpoints": {
"type": "Direct",
"requested": "[8.2.0, )",
"resolved": "8.2.0",
"contentHash": "NfsC7v8YDmZtBjWYs88+ef1/vnL+qGcw8FigGyNzJD8IVAG9ZSmtIKyLJu95BZjfAMwcGcjo+3qXsyC9L7SLlA==",
"dependencies": {
"FastEndpoints.Attributes": "8.2.0",
"FastEndpoints.JobQueues": "8.2.0",
"FastEndpoints.Messaging": "8.2.0",
"FluentValidation": "12.1.1"
}
},
"Meziantou.Analyzer": {
"type": "Direct",
"requested": "[3.0.134, )",
@@ -32,6 +44,43 @@
"resolved": "5.6.0",
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
},
"FastEndpoints.Attributes": {
"type": "Transitive",
"resolved": "8.2.0",
"contentHash": "ni128Yjqk5cAYTvkHqWvhCoFIDqUNstnNd7SljUKr3m8UdLDcZCUKy0lKe9W8R8KLTEdzwk0nufeZDb3MxAaDg=="
},
"FastEndpoints.Core": {
"type": "Transitive",
"resolved": "8.2.0",
"contentHash": "jYC2hFYyH0Yfiv6ykR4SgctGu0Y1cx7gpiScRmdubepzGvPRysbNVNeysAKFg18ZP0PGXUharea3kcEAnJn8Iw=="
},
"FastEndpoints.JobQueues": {
"type": "Transitive",
"resolved": "8.2.0",
"contentHash": "2ZhXE0Ghq+/TqsMP/3uy+XiT4FZRZ6+IFvnDfl+roSlhkgAO30g0SYob0eX3pEr93+KtT1trDSG2H+5rZHMTmA==",
"dependencies": {
"FastEndpoints.Messaging": "8.2.0"
}
},
"FastEndpoints.Messaging": {
"type": "Transitive",
"resolved": "8.2.0",
"contentHash": "5gyFV0GxY88WxxJZX4A+wY0a+wTpoloyyG+egJTiwZh9JrnDXgdMSKMzBf7ePi8vJUoGrVhcrJS4B+ThmUItqA==",
"dependencies": {
"FastEndpoints.Core": "8.2.0",
"FastEndpoints.Messaging.Core": "8.2.0"
}
},
"FastEndpoints.Messaging.Core": {
"type": "Transitive",
"resolved": "8.2.0",
"contentHash": "ubGKGIzdSos62ECTNkkPcHubBem7Nbi7T1+f+h6uHhZblwW56SOxeSzQMA3C7/2qIs94bVnlT0bFphcEewH4BQ=="
},
"FluentValidation": {
"type": "Transitive",
"resolved": "12.1.1",
"contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw=="
},
"Microsoft.Bcl.Cryptography": {
"type": "Transitive",
"resolved": "10.0.2",
+12
View File
@@ -54,6 +54,18 @@ public static class ProblemCodes
/// </remarks>
public const string InvalidDeviceRegistration = "invalid-device-registration";
/// <summary>
/// The request body could not be read at all: malformed JSON, or a property the server does not
/// know.
/// </summary>
/// <remarks>
/// A contract mismatch rather than a rejected value. The request never reached a handler, so no
/// field-level detail is offered and none should be inferred from its absence. Compare the request
/// against the <c>DodoSSH.Contracts</c> assembly for the server version <c>GET /api/v1/meta</c>
/// reports.
/// </remarks>
public const string MalformedRequest = "malformed-request";
/// <summary>The relay refused the requested target. Never states why, to avoid a probe oracle.</summary>
public const string RelayTargetRejected = "relay-target-rejected";
@@ -8,6 +8,7 @@ const DodoSSH.Contracts.ProblemCodes.IdentityBindingInvalid = "identity-binding-
const DodoSSH.Contracts.ProblemCodes.InvalidCursor = "invalid-cursor" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidDeviceRegistration = "invalid-device-registration" -> string!
const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> string!
const DodoSSH.Contracts.ProblemCodes.MalformedRequest = "malformed-request" -> string!
const DodoSSH.Contracts.ProblemCodes.PushBatchTooLarge = "push-batch-too-large" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string!
const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> string!