Public Access
Merge branch 'claude/api-fastendpoints-migration-020431'
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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)!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<T>("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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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<T>("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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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!
|
||||
|
||||
Reference in New Issue
Block a user