Public Access
Merge branch 'claude/api-fastendpoints-migration-020431'
This commit is contained in:
@@ -23,6 +23,14 @@ M:System.Threading.Tasks.Task.WaitAll;Use Task.WhenAll with await.
|
||||
M:System.Threading.Tasks.Task.WaitAny;Use Task.WhenAny with await.
|
||||
M:System.Threading.Tasks.Task.GetAwaiter;Await the task directly rather than blocking on the awaiter.
|
||||
|
||||
## Request validation — FluentValidation arrives transitively with FastEndpoints and is
|
||||
## deliberately unused. A validator short-circuits before the handler and answers with
|
||||
## FastEndpoints' own envelope, which carries no ProblemDetails `code` — and the code is the only
|
||||
## part of an error the client branches on. Validation lives in the feature services, where it can
|
||||
## throw an exception the endpoint maps to a coded problem. See docs/adr/0008-fastendpoints.md.
|
||||
T:FastEndpoints.Validator`1;Validate in the feature service and map its exception to a coded problem; a Validator<T> answers with FastEndpoints' envelope, which has no `code`.
|
||||
T:FluentValidation.AbstractValidator`1;As above. FluentValidation is a transitive dependency of FastEndpoints, not a chosen one.
|
||||
|
||||
## Encoding — must be explicit, never the ambient codepage.
|
||||
P:System.Text.Encoding.Default;Specify the encoding explicitly; Encoding.Default varies by platform.
|
||||
|
||||
|
||||
@@ -16,6 +16,19 @@
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Endpoints">
|
||||
<!--
|
||||
FastEndpoints drags FluentValidation, JobQueues and Messaging in behind it. None of the
|
||||
three are used: validation lives in the feature services and is banned from moving into a
|
||||
Validator<T> (see BannedSymbols.txt and ADR 0008), and there is no message bus. They are
|
||||
left as plain transitives rather than declared here, because declaring a transitive under
|
||||
central transitive pinning is a standing promise to keep its version current, and these are
|
||||
not ours to steer. Declare one only to force a version forward for an advisory, as the
|
||||
group below does.
|
||||
-->
|
||||
<PackageVersion Include="FastEndpoints" Version="8.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Label="Pinned transitive dependencies">
|
||||
<!--
|
||||
Microsoft.AspNetCore.OpenApi 10.0.10 resolves Microsoft.OpenApi 2.0.0, which is
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ADR 0002 — Minimal APIs with explicitly registered feature modules
|
||||
|
||||
- Status: accepted
|
||||
- Status: accepted; the endpoint-framework decision is superseded by [ADR 0008](0008-fastendpoints.md)
|
||||
- Date: 2026-07-28
|
||||
|
||||
## Context
|
||||
@@ -48,3 +48,16 @@ capability negotiation:
|
||||
need and blur the one-endpoint-one-file structure that keeps the authz surface reviewable.
|
||||
- **Reflection-based endpoint discovery.** Convenient until a route silently disappears
|
||||
because an assembly was not loaded, or trimming removes it.
|
||||
|
||||
## Superseded in part
|
||||
|
||||
[ADR 0008](0008-fastendpoints.md) replaces minimal APIs with FastEndpoints. Everything else on this
|
||||
page still stands and is still the reason those endpoints look the way they do: the hard-coded
|
||||
`/api/v1` prefix, no `Asp.Versioning`, capability negotiation through `/api/v1/meta` and
|
||||
`/.well-known/dodossh-configuration`, and `DodoSSH.Contracts` as the client's real contract.
|
||||
|
||||
The two mitigations above survive the change with their reasoning intact. Registration is still
|
||||
explicit rather than a reflection scan — 0008 feeds FastEndpoints a literal type list for the reason
|
||||
this page gives, and then some. The endpoint-inventory test is no longer a plan: it exists as of
|
||||
0008, and it matters more under a hand-maintained list than it did here, because a forgotten line is
|
||||
now the way a route goes missing.
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# ADR 0008 — FastEndpoints instead of minimal APIs
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-30
|
||||
- Supersedes, in part: [ADR 0002](0002-minimal-apis.md)
|
||||
|
||||
## Context
|
||||
|
||||
[ADR 0002](0002-minimal-apis.md) chose minimal APIs grouped into feature modules. That has worked
|
||||
for the eight endpoints M1 ships, but the shape it produces — a static class per area holding
|
||||
static local functions, with route, policy, name and summary asserted in one fluent chain and the
|
||||
handler somewhere below it — does not obviously scale to the ~60 endpoints the API is planned to
|
||||
have. The specific costs are that a handler's dependencies are parameters rather than
|
||||
constructor-injected, that a group's `RequireAuthorization` sits far from the handler it governs,
|
||||
and that there is no type to hang an endpoint's own documentation on.
|
||||
|
||||
FastEndpoints implements REPR: one class per endpoint, its route and authorization declared in
|
||||
`Configure()`, its dependencies injected, its handler a method on the same type.
|
||||
|
||||
## Decision
|
||||
|
||||
FastEndpoints 8.2.0, with the endpoint classes living in the same `Features/<Area>/` files the
|
||||
static modules occupied.
|
||||
|
||||
Everything about the **wire** is unchanged, deliberately and verifiably: the same eight routes, the
|
||||
same verbs and route constraints, the same status codes, the same operation ids, and the same
|
||||
RFC 9457 problem bodies with the same `code` values. The existing HTTP test suite passes without a
|
||||
single edit.
|
||||
|
||||
Five choices make that true, and each one is a place where the idiomatic FastEndpoints answer was
|
||||
rejected:
|
||||
|
||||
- **Endpoints are registered from an explicit `List<Type>`**, not found by scanning. ADR 0002
|
||||
rejected reflection discovery and the reason it gave applies with more force here, not less:
|
||||
under `WebApplicationFactory` the scan reaches the test assembly, so an endpoint written in a
|
||||
test would be registered into the host under test.
|
||||
- **Handlers keep returning `Results<Ok<T>, NotFound, ProblemHttpResult>`** as the endpoint's
|
||||
response type, from `ExecuteAsync`. The union executes as an ordinary `IResult`, which is what
|
||||
keeps problem bodies going through the host's serialiser and `IProblemDetailsService` — and what
|
||||
keeps the compile-time record of which statuses each endpoint can produce. None of the `Send.*`
|
||||
API is used.
|
||||
- **Validation stays in the feature services.** A `Validator<T>` short-circuits before the handler
|
||||
and answers with FastEndpoints' own envelope, which carries no `code` — and the code is the only
|
||||
part of an error the client branches on. Better than twenty tests assert a specific code on a 400.
|
||||
This is banned in `BannedSymbols.txt` rather than merely avoided, because it is the single most
|
||||
attractive wrong turn available: the framework's documentation leads straight to it and it looks
|
||||
like an improvement.
|
||||
- **Request DTOs are bound from the JSON body only** (`BodyOnlyRequestBinder<T>`, installed for every
|
||||
endpoint through `Config.Endpoints.Configurator`). FastEndpoints' default binder deserialises the
|
||||
body and then writes route values, query-string parameters, headers, claims and cookies over the
|
||||
top. Minimal APIs bound a body parameter from the body alone, so the default would have widened
|
||||
every request on this API — `?cursor=…` overriding a pull cursor, and `?identityProviderToken=…`
|
||||
letting an ID token travel in a URL, through every proxy log on the path.
|
||||
- **OpenAPI stays on `Microsoft.AspNetCore.OpenApi`.** `Description(b => b.WithName(…))` writes the
|
||||
same operation ids, summaries and tags the minimal APIs wrote. It does not write the same
|
||||
*parameters*: a route value read with `Route<T>()` is invisible to ApiExplorer, so the document
|
||||
emitted `/api/v1/vaults/{vaultId}/sync/pull` with nothing declaring `vaultId` — a template
|
||||
expression with no parameter, which the specification forbids and no client generator can fill in.
|
||||
`RouteParameterTransformer` in `Setup/OpenApi.cs` puts them back, recovering the type from the
|
||||
route constraint rather than guessing it from the name. `FastEndpoints.OpenApi` would auto-tag
|
||||
every operation from the first path segment, advertise a bearer scheme that is not the OAuth2+PKCE
|
||||
scheme this API actually uses, and inject a module initializer through a build target — all for a
|
||||
`Summary()` mechanism we would not use.
|
||||
- **Full absolute routes on every endpoint**, no `Group<T>`. A group would save a repeated prefix
|
||||
and cost three startup-time failure modes, one of which — applying the group's policy to all four
|
||||
`/me` endpoints — would put `Enrolled` on `DELETE /devices` and turn today's 404 into a 403 with
|
||||
no failing test.
|
||||
|
||||
Two things about the surface did change, both on purpose:
|
||||
|
||||
- **A request body that cannot be deserialised now answers with a problem document**
|
||||
(`malformed-request`) rather than an empty 400. FastEndpoints' default for this path announces
|
||||
`application/problem+json` while sending something else, and names the failing .NET type on the
|
||||
wire — in a codebase that sets `IncludeErrorDetails = false` on the bearer handler precisely to
|
||||
avoid that. `Config.Errors.ResponseBuilder` is replaced.
|
||||
- **`GET /_test_url_cache_` is answered 404 by middleware.** `UseFastEndpoints` maps it
|
||||
unconditionally, in every environment, carrying neither a policy nor `AllowAnonymous`; it returns
|
||||
the server's entire endpoint-name-to-route table. There is no switch to turn it off. Deny-by-
|
||||
default means a caller must be authenticated to read it, which is not the same as it being
|
||||
nobody's business. The middleware matches on the endpoint routing selected, not on the request
|
||||
path — the first version compared the path with `StringComparison.Ordinal` and was therefore
|
||||
bypassable at `/_TEST_URL_CACHE_`, which a single-spelling test happily certified.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The endpoint-inventory test ADR 0002 promised now exists
|
||||
(`tests/DodoSSH.Api.Tests/EndpointInventoryTests.cs`) and asserts the complete route set — verb,
|
||||
pattern, operation id, tags, policies and anonymity. It is not optional under a hand-maintained
|
||||
type list: a type left off `EndpointRegistration.cs` is otherwise a route that quietly does not
|
||||
exist, with no compile error. It also brings `/api/v1/meta` and
|
||||
`/.well-known/dodossh-configuration` into a suite a developer actually runs before pushing —
|
||||
`DodoSSH.SystemTests` does fetch both anonymously, but only with a Docker daemon and several
|
||||
containers. That matters because FastEndpoints authenticates by default and both routes are
|
||||
unreachable-by-design if they 401.
|
||||
- FastEndpoints' serialiser is a **copy** of the host's JSON options, taken once while the pipeline
|
||||
is built, and the copy is process-wide and undocumented. `AddDodoJson` still configures the host,
|
||||
and `EndpointRegistration` applies the same settings to the copy explicitly rather than trusting
|
||||
it. A second `WebApplicationFactory` in the same test process would silently inherit the first
|
||||
host's JSON configuration; there is one host per assembly today, so this is inert — but it is the
|
||||
kind of thing that costs an afternoon.
|
||||
- `FluentValidation`, `FastEndpoints.JobQueues` and `FastEndpoints.Messaging` are now in the
|
||||
dependency graph and none of them is used. They are left as plain transitives.
|
||||
- [ADR 0005](0005-no-application-layer.md) still stands and needed no amendment. No mediator, no
|
||||
behaviour pipeline, no validators and no mappers were introduced. An endpoint class replaced a
|
||||
static local function and still calls the same feature service inline, so "reading an endpoint
|
||||
means reading one file" is as true as it was — the file now holds two to four classes instead of
|
||||
one, and the ~80-line rule that ADR 0005 sets for extracting a service is unaffected.
|
||||
- The explicit type list is, as it happens, also the AOT-supported path. The two scanning overloads
|
||||
carry `[RequiresUnreferencedCode]`/`[RequiresDynamicCode]` and their message points at
|
||||
`AddFastEndpoints(DiscoveredTypes.All)`; the `List<Type>` overload this uses carries neither. If
|
||||
trimming ever becomes a requirement, `FastEndpoints.Generator` produces that same list from source
|
||||
and nothing else here has to change.
|
||||
|
||||
### Rejected
|
||||
|
||||
- **Assembly-scanning discovery** (`AddFastEndpoints()`), for the reasons above.
|
||||
- **`Validator<T>` / FluentValidation**, which would replace every coded 400 with an uncoded one.
|
||||
- **FastEndpoints' `ErrorResponse` and its `ProblemDetails`.** Neither carries a `code`; the latter
|
||||
also points `type` at RFC 7231 anchors and makes `errors` an array of objects.
|
||||
- **The default request binder.** Its extra binding sources are not a feature this API wants, and
|
||||
the one that matters is not obvious from reading an endpoint: nothing in a `Configure()` method
|
||||
hints that the query string can reach the DTO. Rejected globally rather than per endpoint so that
|
||||
a future endpoint cannot opt into it by forgetting.
|
||||
- **`FastEndpoints.OpenApi`**, and **`Group<T>` route prefixes**, and **the `Send.*` response API**.
|
||||
- **Staying on minimal APIs.** Defensible — the objection is to a shape that has not hurt yet. The
|
||||
reason not to wait is that this migration is cheap at eight endpoints and gets linearly more
|
||||
expensive, and the contract hazards it surfaced — `/_test_url_cache_`, the query-string binding
|
||||
widening, and a strict unmapped-member rule that turned out to have no test at all — were worth
|
||||
finding either way.
|
||||
@@ -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!
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Metadata;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Every route the server exposes, and the authorization decision behind each one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The inventory test ADR 0002 asked for. Endpoints are registered from an explicit type list in
|
||||
/// <c>Setup/EndpointRegistration.cs</c> rather than found by scanning, which trades one failure mode for
|
||||
/// another: nothing can appear by accident, but a type left off the list is a route that quietly does
|
||||
/// not exist, with no compile error and — without this test — no failure either. Asserting the whole set
|
||||
/// rather than a subset is the point. A new endpoint cannot ship until somebody writes down, here, who
|
||||
/// is allowed to call it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is also the only coverage <c>/api/v1/meta</c> and <c>/.well-known/dodossh-configuration</c> have
|
||||
/// in <em>this</em> assembly — <c>DodoSSH.SystemTests</c> does fetch both anonymously, but that suite
|
||||
/// needs a Docker daemon and several containers, so it is not what a developer runs before pushing.
|
||||
/// Both are anonymous only because they say so, and a client has nothing to authenticate with at the
|
||||
/// point it reads them, so a 401 on either is unrecoverable rather than merely wrong.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What this does <em>not</em> cover: <see cref="Describe"/> reads only <see cref="IAuthorizeData.Policy"/>,
|
||||
/// so a role, claim, scope or authentication-scheme requirement could be added or removed without
|
||||
/// failing here. Nothing uses those today; the day something does, this table needs a column.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class EndpointInventoryTests(ApiFixture fixture)
|
||||
{
|
||||
private static readonly string[] Expected =
|
||||
[
|
||||
// Anonymous by necessity: discovery has to work before a token exists.
|
||||
"GET /api/v1/meta name=GetMeta tags= policies= anon=True",
|
||||
"GET /.well-known/dodossh-configuration name=GetDodoSshConfiguration tags= policies= anon=True",
|
||||
|
||||
// Authenticated, not Enrolled: these three are how a caller discovers it must enroll, does so,
|
||||
// and — for revocation — withdraws a lost machine even if its enrollment state is in doubt.
|
||||
"GET /api/v1/me name=GetMe tags=Identity policies=Authenticated anon=False",
|
||||
"POST /api/v1/me/enrollment name=Enroll tags=Identity policies=Authenticated anon=False",
|
||||
"DELETE /api/v1/me/devices/{deviceId:guid} name=RevokeDevice tags=Identity policies=Authenticated anon=False",
|
||||
|
||||
// The one endpoint in the /me area that needs a key bundle to already exist.
|
||||
"POST /api/v1/me/devices name=RegisterDevice tags=Identity policies=Authenticated,Enrolled anon=False",
|
||||
|
||||
// Enrolled: a caller with no identity key can neither write ciphertext anyone can read nor read
|
||||
// what is there.
|
||||
"POST /api/v1/vaults/{vaultId:guid}/sync/pull name=SyncPull tags=Sync policies=Enrolled anon=False",
|
||||
"POST /api/v1/vaults/{vaultId:guid}/sync/push name=SyncPush tags=Sync policies=Enrolled anon=False",
|
||||
|
||||
// Anonymous on purpose, and load-bearing: DodoSSH.SystemTests waits on /healthz/ready before any
|
||||
// token exists, and an orchestrator probe that needs credentials reports the wrong thing.
|
||||
// MapHealthChecks constrains no verb, hence ANY.
|
||||
"ANY /healthz/live name= tags= policies= anon=True",
|
||||
"ANY /healthz/ready name= tags= policies= anon=True",
|
||||
"ANY /healthz/startup name= tags= policies= anon=True",
|
||||
|
||||
// Not ours. FastEndpoints maps this one itself, in every environment, with no way to opt out; it
|
||||
// answers with the server's whole endpoint-name-to-route table. It stays registered and is
|
||||
// short-circuited to 404 instead — see RouteTableIsNotReachable below. Listed so that a version
|
||||
// bump which adds a second hidden route, or renames this one out from under the block, fails
|
||||
// here rather than in production.
|
||||
"GET _test_url_cache_ name= tags= policies= anon=False",
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void TheServerExposesExactlyTheEndpointsWeMeantTo()
|
||||
{
|
||||
// Forces the pipeline to be built. FastEndpoints registers its routes when the application is
|
||||
// built, not when its services are, so resolving the data sources from an unstarted host finds
|
||||
// the health checks and nothing else.
|
||||
using var client = fixture.CreateClient();
|
||||
|
||||
var actual = fixture.Services.GetServices<EndpointDataSource>()
|
||||
.SelectMany(source => source.Endpoints)
|
||||
.OfType<RouteEndpoint>()
|
||||
.Select(Describe)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
actual.ShouldBe([.. Expected.Order(StringComparer.Ordinal)]);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Distinct from the inventory above, which only proves the route is registered. This proves it does
|
||||
/// not answer.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every spelling routing accepts is asserted, not just the canonical one. Routing matches literal
|
||||
/// segments case-insensitively and tolerates a trailing slash, so a block that compares the request
|
||||
/// path with <c>StringComparison.Ordinal</c> passes a canonical-spelling test while leaving the
|
||||
/// listing fully readable at <c>/_TEST_URL_CACHE_</c>. That is not hypothetical — it is what the
|
||||
/// first version of this guard did, and a single-spelling test is what let it look correct.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData("/_test_url_cache_")]
|
||||
[InlineData("/_TEST_URL_CACHE_")]
|
||||
[InlineData("/_Test_Url_Cache_")]
|
||||
[InlineData("/_test_url_cache_/")]
|
||||
public async Task RouteTableIsNotReachable(string path)
|
||||
{
|
||||
// Authenticated on purpose: the deny-by-default policy already stops an anonymous caller, so a
|
||||
// 404 for one would prove nothing about whether the listing is exposed.
|
||||
var client = fixture.CreateClientFor(subject: $"route-table-{Guid.CreateVersion7()}");
|
||||
|
||||
var response = await client.GetAsync(
|
||||
new Uri(path, UriKind.Relative),
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
|
||||
|
||||
// A 404 with the listing in the body would satisfy the status assertion on its own.
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
body.ShouldNotContain("Endpoint", Case.Insensitive);
|
||||
}
|
||||
|
||||
private static string Describe(RouteEndpoint endpoint)
|
||||
{
|
||||
var methods = endpoint.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods;
|
||||
var verbs = methods is { Count: > 0 } ? string.Join(",", methods) : "ANY";
|
||||
|
||||
var name = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName ?? string.Empty;
|
||||
var tags = string.Join(",", endpoint.Metadata.GetMetadata<ITagsMetadata>()?.Tags ?? []);
|
||||
|
||||
// FastEndpoints adds a synthetic "epPolicy:<full type name>" beside the named policies, and
|
||||
// including it would couple this table to endpoint class names. Dropping it is not free: that
|
||||
// policy is also where FastEndpoints folds Roles(), Claims() and Permissions(), so those become
|
||||
// invisible here. Nothing calls them — the two named policies carry the whole authorization
|
||||
// decision — and the class remarks say so, rather than this filter pretending it discards nothing.
|
||||
var policies = string.Join(
|
||||
",",
|
||||
endpoint.Metadata.OfType<IAuthorizeData>()
|
||||
.Select(data => data.Policy)
|
||||
.Where(policy => !string.IsNullOrEmpty(policy))
|
||||
.Where(policy => !policy!.StartsWith("epPolicy:", StringComparison.Ordinal)));
|
||||
|
||||
var anonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>() is not null;
|
||||
|
||||
return $"{verbs} {endpoint.RoutePattern.RawText} name={name} tags={tags} policies={policies} anon={anonymous}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.OpenApi;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.OpenApi;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The generated OpenAPI document, which ADR 0002 promises to third parties and a future CLI.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Generated through <see cref="IOpenApiDocumentProvider"/> rather than fetched from
|
||||
/// <c>/openapi/v1.json</c>: that route is mapped only in Development and sits behind the deny-by-default
|
||||
/// policy, so it is not reachable from this fixture. The document is the same one either way.
|
||||
/// </remarks>
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class OpenApiDocumentTests(ApiFixture fixture)
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("/api/v1/vaults/{vaultId}/sync/pull", "post", "vaultId")]
|
||||
[InlineData("/api/v1/vaults/{vaultId}/sync/push", "post", "vaultId")]
|
||||
[InlineData("/api/v1/me/devices/{deviceId}", "delete", "deviceId")]
|
||||
public async Task RouteParametersAreDeclared(string path, string verb, string parameterName)
|
||||
{
|
||||
// These endpoints read their route value with Route<T>("name") rather than binding it onto the
|
||||
// request DTO, so nothing in the endpoint signature mentions it and the generator emits the path
|
||||
// template with no matching parameter — which is invalid OpenAPI and unusable by any client
|
||||
// generator. RouteParameterTransformer puts them back; without it this document silently rots.
|
||||
var document = await GenerateAsync();
|
||||
|
||||
var operation = document
|
||||
.GetProperty("paths").GetProperty(path).GetProperty(verb);
|
||||
|
||||
var parameters = operation.GetProperty("parameters").EnumerateArray()
|
||||
.Where(p => string.Equals(p.GetProperty("in").GetString(), "path", StringComparison.Ordinal))
|
||||
.ToArray();
|
||||
|
||||
var declared = parameters.SingleOrDefault(
|
||||
p => string.Equals(p.GetProperty("name").GetString(), parameterName, StringComparison.Ordinal));
|
||||
|
||||
declared.ValueKind.ShouldNotBe(JsonValueKind.Undefined, $"{verb} {path} declares no {parameterName}");
|
||||
declared.GetProperty("required").GetBoolean().ShouldBeTrue();
|
||||
|
||||
// Recovered from the :guid route constraint, not guessed from the name.
|
||||
declared.GetProperty("schema").GetProperty("format").GetString().ShouldBe("uuid");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EveryPathTemplateExpressionHasAParameter()
|
||||
{
|
||||
// The general form of the rule above: no operation may name a template expression it does not
|
||||
// declare. Asserted over the whole document so a new endpoint cannot reintroduce the defect.
|
||||
var document = await GenerateAsync();
|
||||
|
||||
var offenders = new List<string>();
|
||||
|
||||
foreach (var path in document.GetProperty("paths").EnumerateObject())
|
||||
{
|
||||
var expected = path.Name.Split('/')
|
||||
.Where(segment => segment.StartsWith('{') && segment.EndsWith('}'))
|
||||
.Select(segment => segment[1..^1])
|
||||
.ToArray();
|
||||
|
||||
if (expected.Length == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var operation in path.Value.EnumerateObject())
|
||||
{
|
||||
var declared = operation.Value.TryGetProperty("parameters", out var parameters)
|
||||
? parameters.EnumerateArray()
|
||||
.Where(p => string.Equals(p.GetProperty("in").GetString(), "path", StringComparison.Ordinal))
|
||||
.Select(p => p.GetProperty("name").GetString())
|
||||
.ToArray()
|
||||
: [];
|
||||
|
||||
offenders.AddRange(
|
||||
expected.Except(declared, StringComparer.Ordinal)
|
||||
.Select(missing => $"{operation.Name.ToUpperInvariant()} {path.Name} -> {missing}"));
|
||||
}
|
||||
}
|
||||
|
||||
offenders.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
private async Task<JsonElement> GenerateAsync()
|
||||
{
|
||||
await using var scope = fixture.CreateScope();
|
||||
// Keyed on the document name: AddOpenApi registers one provider per document.
|
||||
var provider = scope.ServiceProvider.GetRequiredKeyedService<IOpenApiDocumentProvider>("v1");
|
||||
|
||||
var document = await provider.GetOpenApiDocumentAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
var json = await document.SerializeAsJsonAsync(
|
||||
OpenApiSpecVersion.OpenApi3_1,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
return JsonDocument.Parse(json).RootElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// What the server answers when a request body cannot be read at all.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is the one error the endpoint framework produces rather than a handler, so it is the one place
|
||||
/// the problem-document contract could drift without any other test noticing. The strict
|
||||
/// unmapped-member rule in particular has never had a test: it is configured in
|
||||
/// <see cref="DodoSshJsonContext"/>, applied by <c>AddDodoJson</c>, and until now nothing asserted the
|
||||
/// server actually enforced it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Enrollment is the subject only because it is a POST with a body that an authenticated caller can
|
||||
/// reach without being enrolled. Nothing here is about enrollment.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class RequestBindingTests(ApiFixture fixture)
|
||||
{
|
||||
private const string EnrollUrl = "/api/v1/me/enrollment";
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnknownJsonMemberIsRejected()
|
||||
{
|
||||
// A misspelled or renamed client property must be a 400, not a silently missing value that
|
||||
// shows up later as data loss.
|
||||
var problem = await PostRawAsync("""{"statement":null,"thisMemberDoesNotExist":1}""");
|
||||
|
||||
problem.Code.ShouldBe(ProblemCodes.MalformedRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MalformedJsonIsRejected()
|
||||
{
|
||||
var problem = await PostRawAsync("""{"statement":""" + " ");
|
||||
|
||||
problem.Code.ShouldBe(ProblemCodes.MalformedRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANumberSentAsAStringIsRejected()
|
||||
{
|
||||
// JsonNumberHandling.Strict. The web defaults would read "1" as 1, which is how two
|
||||
// implementations end up disagreeing about what a number is.
|
||||
//
|
||||
// keyGeneration is reached through statement on purpose: it is a member of KeyStatement, not of
|
||||
// EnrollmentRequest, so posting it at the top level is rejected as an unmapped member and never
|
||||
// reaches a number-parsing decision at all. This test asserted nothing until that was spotted.
|
||||
var problem = await PostRawAsync("""{"statement":{"keyGeneration":"1"}}""");
|
||||
|
||||
problem.Code.ShouldBe(ProblemCodes.MalformedRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheQueryStringCannotSupplyRequestFields()
|
||||
{
|
||||
// FastEndpoints' default binder writes query-string values over the deserialised body; minimal
|
||||
// APIs read the body alone. Left on, that would let ?identityProviderToken=... put an ID token
|
||||
// in a URL, and from there into every proxy log and browser history on the path.
|
||||
//
|
||||
// Asserted on the cursor because it is the one field where the two behaviours give visibly
|
||||
// different answers: a body cursor of null means "start from the beginning" and succeeds, while
|
||||
// the garbage in the query string fails its integrity tag. A test on enrollment could not tell
|
||||
// the difference — every malformed enrollment is rejected either way.
|
||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||
var client = fixture.CreateClientFor(subject);
|
||||
|
||||
var response = await client.PostContractAsync(
|
||||
$"/api/v1/vaults/{vaultId}/sync/pull?cursor=bm90LWEtcmVhbC1jdXJzb3I",
|
||||
new SyncPullRequest(Cursor: null, Limit: null, EntityTypes: null));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.OK);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheRejectionIsARfc9457ProblemDocument()
|
||||
{
|
||||
// The framework's own binding-failure body is neither a problem document nor free of .NET type
|
||||
// names, despite being served as application/problem+json. Asserted in full here because this
|
||||
// response is assembled by hand rather than by TypedResults.Problem, so nothing else keeps the
|
||||
// two shapes in step.
|
||||
var client = fixture.CreateClientFor(subject: $"binding-{Guid.CreateVersion7()}");
|
||||
using var content = new StringContent(
|
||||
"""{"nope":1}""",
|
||||
Encoding.UTF8,
|
||||
"application/json");
|
||||
|
||||
var response = await client.PostAsync(
|
||||
new Uri(EnrollUrl, UriKind.Relative),
|
||||
content,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
response.Content.Headers.ContentType?.MediaType.ShouldBe("application/problem+json");
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
|
||||
body.ShouldNotContain("DodoSSH.Contracts", Case.Sensitive);
|
||||
|
||||
var problem = await response.Content.ReadProblemAsync();
|
||||
problem.ShouldNotBeNull();
|
||||
problem.Type.ShouldBe(ProblemCodes.TypeBaseUri + ProblemCodes.MalformedRequest);
|
||||
problem.Detail.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Seeded directly rather than driven through the enrollment endpoint, for the reason
|
||||
/// <see cref="Seed"/> gives: a failure here should mean the binder is wrong, not that enrollment is.
|
||||
/// </remarks>
|
||||
private async Task<(string Subject, Guid VaultId)> SeedUserWithVaultAsync()
|
||||
{
|
||||
var subject = $"binding-{Guid.CreateVersion7():N}";
|
||||
var now = TimeProvider.System.GetUtcNow();
|
||||
|
||||
await using var scope = fixture.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||
|
||||
var user = new UserAccount
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Issuer = fixture.IdentityProvider.Authority,
|
||||
Subject = subject,
|
||||
Status = UserStatus.Active,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now,
|
||||
};
|
||||
|
||||
var vault = new Vault
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Name = "Personal",
|
||||
OwnerKind = VaultOwnerKind.Personal,
|
||||
OwnerUserId = user.Id,
|
||||
KeyGeneration = 1,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now,
|
||||
};
|
||||
|
||||
database.Users.Add(user);
|
||||
database.UserKeys.Add(Seed.CurrentKey(user.Id, now));
|
||||
database.Vaults.Add(vault);
|
||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
return (subject, vault.Id);
|
||||
}
|
||||
|
||||
private async Task<JsonProblem> PostRawAsync(string json, string query = "")
|
||||
{
|
||||
var client = fixture.CreateClientFor(subject: $"binding-{Guid.CreateVersion7()}");
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
var response = await client.PostAsync(
|
||||
new Uri(EnrollUrl + query, UriKind.Relative),
|
||||
content,
|
||||
TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
|
||||
var problem = await response.Content.ReadProblemAsync();
|
||||
problem.ShouldNotBeNull();
|
||||
return problem;
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,49 @@
|
||||
"resolved": "2.2.1",
|
||||
"contentHash": "21XZo/yuXK1k0EUhdLnjgRD4n0HQYmPFchV6uaORcRc65rasZ1vdm2dmJXPBKZiIBztRRYRmmg/B76W721VWkA=="
|
||||
},
|
||||
"FastEndpoints.Attributes": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.2.0",
|
||||
"contentHash": "ni128Yjqk5cAYTvkHqWvhCoFIDqUNstnNd7SljUKr3m8UdLDcZCUKy0lKe9W8R8KLTEdzwk0nufeZDb3MxAaDg==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.Primitives": "10.0.9"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9",
|
||||
"Microsoft.Extensions.Hosting.Abstractions": "10.0.9"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.9"
|
||||
}
|
||||
},
|
||||
"FastEndpoints.Messaging.Core": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.2.0",
|
||||
"contentHash": "ubGKGIzdSos62ECTNkkPcHubBem7Nbi7T1+f+h6uHhZblwW56SOxeSzQMA3C7/2qIs94bVnlT0bFphcEewH4BQ=="
|
||||
},
|
||||
"FluentValidation": {
|
||||
"type": "Transitive",
|
||||
"resolved": "12.1.1",
|
||||
"contentHash": "EPpkIe1yh1a0OXyC100oOA8WMbZvqUu5plwhvYcb7oSELfyUZzfxV48BLhvs3kKo4NwG7MGLNgy1RJiYtT8Dpw=="
|
||||
},
|
||||
"GraphQL": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.5.0",
|
||||
@@ -1668,6 +1711,7 @@
|
||||
"DodoSSH.Crypto": "[1.0.0, )",
|
||||
"DodoSSH.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Infrastructure": "[1.0.0, )",
|
||||
"FastEndpoints": "[8.2.0, )",
|
||||
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.10, )",
|
||||
"Microsoft.AspNetCore.OpenApi": "[10.0.10, )"
|
||||
}
|
||||
@@ -1709,6 +1753,18 @@
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
|
||||
}
|
||||
},
|
||||
"FastEndpoints": {
|
||||
"type": "CentralTransitive",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.0.22, )",
|
||||
|
||||
Reference in New Issue
Block a user