Public Access
Move the API onto FastEndpoints, without moving the wire
Eight endpoints today, around sixty planned. The minimal-API shape — a static
class per area holding static local functions, route and policy and name
asserted in one fluent chain with the handler somewhere below it — has not hurt
yet, and would. A handler's dependencies are parameters rather than injected, a
group's RequireAuthorization sits far from the handler it governs, and there is
no type to hang an endpoint's own documentation on. FastEndpoints is one class
per endpoint, its route and authorization in Configure(), its handler a method
on the same type.
Nothing about the wire moves, and the evidence is that the 94 existing HTTP
tests pass with zero edits to any of them. Same routes, verbs, route
constraints, status codes, operation ids, and the same RFC 9457 bodies with the
same code values. Every place the idiomatic FastEndpoints answer would have
changed one of those, it was refused:
Endpoints are registered from an explicit List<Type>, not found by scanning.
ADR 0002 rejected reflection discovery by name, and the reason it gave is
sharper here than in general — under WebApplicationFactory the scan reaches the
test assembly, so an endpoint written in a test would be registered into the
host under test. The cost is a line per endpoint that can be forgotten, which is
what the endpoint-inventory test is for. That test is the one ADR 0002 promised
and never got.
Handlers still return Results<Ok<T>, NotFound, ProblemHttpResult> 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 an endpoint can
produce. No Send.* call appears anywhere; the moment one does, a response has
left the host's serialiser.
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. Twenty-odd
tests assert a specific code on a 400. It is banned in BannedSymbols.txt rather
than merely avoided, because the framework's documentation leads straight to it
and it looks like an improvement.
Three defects arrived with the framework and were caught in review. All three
were green at the time, which is the part worth remembering. FastEndpoints maps
GET /_test_url_cache_ unconditionally, in every environment, with no policy and
no way to opt out; it answers with the whole endpoint-name-to-route table. It is
short-circuited to 404 — by asking routing which endpoint it selected, after the
first attempt compared the request path with Ordinal and was therefore bypassable
at /_TEST_URL_CACHE_, certified by a test that only ever tried one spelling. The
default request binder writes query-string values over the deserialised body,
which would have let ?identityProviderToken=... put an ID token in a URL and from
there into every proxy log on the path; every endpoint now binds from the body
alone. And a route value read with Route<T>() is invisible to ApiExplorer, so the
generated document named {vaultId} in a path template with nothing declaring it —
invalid OpenAPI, and unusable by the client generators the document exists for.
Two changes to the surface, both deliberate. A body that cannot be deserialised
now answers with a problem document carrying malformed-request, rather than an
empty 400: FastEndpoints' default announces application/problem+json while
sending something else, and names the failing .NET type on the wire, in a
codebase that sets IncludeErrorDetails = false to prevent exactly that. And the
route table above returns 404 where it would otherwise have answered any
authenticated caller.
Each of the three fixes has a regression test that was checked by reverting the
fix and watching it fail — four failures for the route table and the binder, four
for the document. That check is the whole reason to trust them, since all three
defects passed a full green suite on the way in.
950 tests green across 16 projects, 14 of them new and no existing test edited.
Zero warnings, format clean, locked restore clean. FluentValidation, JobQueues
and Messaging are in the graph now and none is used.
Not verified: the generated document's response schemas, which differ from
before — FastEndpoints contributes its own Produces metadata. Nothing consumes
the document yet, and MapOpenApi runs only in Development behind the fallback
policy. It needs pinning if ADR 0002's build-time artifacts/openapi/v1.json is
ever built.
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.WaitAny;Use Task.WhenAny with await.
|
||||||
M:System.Threading.Tasks.Task.GetAwaiter;Await the task directly rather than blocking on the awaiter.
|
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.
|
## Encoding — must be explicit, never the ambient codepage.
|
||||||
P:System.Text.Encoding.Default;Specify the encoding explicitly; Encoding.Default varies by platform.
|
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" />
|
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||||
</ItemGroup>
|
</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">
|
<ItemGroup Label="Pinned transitive dependencies">
|
||||||
<!--
|
<!--
|
||||||
Microsoft.AspNetCore.OpenApi 10.0.10 resolves Microsoft.OpenApi 2.0.0, which is
|
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
|
# 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
|
- Date: 2026-07-28
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
@@ -48,3 +48,16 @@ capability negotiation:
|
|||||||
need and blur the one-endpoint-one-file structure that keeps the authz surface reviewable.
|
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
|
- **Reflection-based endpoint discovery.** Convenient until a route silently disappears
|
||||||
because an assembly was not loaded, or trimming removes it.
|
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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="FastEndpoints" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -1,88 +1,86 @@
|
|||||||
using DodoSSH.Api.Authorization;
|
using DodoSSH.Api.Authorization;
|
||||||
using DodoSSH.Api.Setup;
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
|
using FastEndpoints;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
|
||||||
namespace DodoSSH.Api.Features.Identity;
|
namespace DodoSSH.Api.Features.Identity;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The caller's own identity: profile, unlock state and enrollment.
|
/// The caller's own profile, unlock state and reachable vaults.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The group runs under <see cref="Auth.AuthenticatedPolicy"/> rather than
|
/// This is the first authenticated call a client makes and the only one that works before enrollment,
|
||||||
/// <see cref="Auth.EnrolledPolicy"/>, and must: <c>GET /</c> and <c>POST /enrollment</c> are how a client
|
/// so it must answer "what do I do next" — either enroll, or unlock with these parameters and open
|
||||||
/// discovers that it needs to enroll and then does so, so gating them on enrollment would make enrollment
|
/// these vaults.
|
||||||
/// unreachable. <c>POST /devices</c> is the exception and adds the stricter policy itself.
|
|
||||||
/// </remarks>
|
/// </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")
|
// No trailing slash. The desktop client sends exactly "/api/v1/me".
|
||||||
.RequireAuthorization(Auth.AuthenticatedPolicy)
|
Get("/api/v1/me");
|
||||||
.WithTags("Identity");
|
|
||||||
|
|
||||||
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")
|
.WithName("GetMe")
|
||||||
.WithSummary("The caller's profile, enrollment state and reachable vaults.");
|
.WithSummary("The caller's profile, enrollment state and reachable vaults.")
|
||||||
|
.WithTags("Identity"));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Ok<MeResponse>> GetMeAsync(
|
/// <inheritdoc />
|
||||||
ICurrentUserContext currentUser,
|
public override async Task<Ok<MeResponse>> ExecuteAsync(CancellationToken ct)
|
||||||
IdentityService identity,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
// Provisioning happens here, on the first authenticated request, keyed on (issuer, subject).
|
// 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(
|
return TypedResults.Ok(await identity.GetMeAsync(user, ct).ConfigureAwait(false));
|
||||||
await identity.GetMeAsync(user, cancellationToken).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(
|
/// <inheritdoc />
|
||||||
EnrollmentRequest request,
|
public override async Task<Results<Ok<EnrollmentResponse>, ProblemHttpResult>> ExecuteAsync(
|
||||||
ICurrentUserContext currentUser,
|
EnrollmentRequest req,
|
||||||
EnrollmentService enrollment,
|
CancellationToken ct)
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 200 rather than 201. A retry of an identical request returns the same body, so there
|
// 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
|
// is no single moment of creation to point a Location header at, and the client already
|
||||||
// knows the vault id — it chose it.
|
// knows the vault id — it chose it.
|
||||||
var response = await enrollment.EnrollAsync(user, request, cancellationToken)
|
var response = await enrollment.EnrollAsync(user, req, ct).ConfigureAwait(false);
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
return TypedResults.Ok(response);
|
return TypedResults.Ok(response);
|
||||||
}
|
}
|
||||||
catch (EnrollmentInvalidException exception)
|
catch (EnrollmentInvalidException exception)
|
||||||
{
|
{
|
||||||
return Problem(
|
return Problems.Coded(
|
||||||
StatusCodes.Status400BadRequest,
|
StatusCodes.Status400BadRequest,
|
||||||
ProblemCodes.InvalidEnrollment,
|
ProblemCodes.InvalidEnrollment,
|
||||||
exception.Message);
|
exception.Message);
|
||||||
@@ -91,71 +89,109 @@ internal static class IdentityEndpoints
|
|||||||
{
|
{
|
||||||
// 400, not 401: the access token authenticated the caller perfectly well. What failed is
|
// 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.
|
// the separate assertion they supplied about their own keys, which is request content.
|
||||||
return Problem(
|
return Problems.Coded(
|
||||||
StatusCodes.Status400BadRequest,
|
StatusCodes.Status400BadRequest,
|
||||||
ProblemCodes.IdentityBindingInvalid,
|
ProblemCodes.IdentityBindingInvalid,
|
||||||
exception.Message);
|
exception.Message);
|
||||||
}
|
}
|
||||||
catch (AlreadyEnrolledException exception)
|
catch (AlreadyEnrolledException exception)
|
||||||
{
|
{
|
||||||
return Problem(
|
return Problems.Coded(
|
||||||
StatusCodes.Status409Conflict,
|
StatusCodes.Status409Conflict,
|
||||||
ProblemCodes.AlreadyEnrolled,
|
ProblemCodes.AlreadyEnrolled,
|
||||||
exception.Message);
|
exception.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <summary>Registers a device key so this machine can unlock without the passphrase.</summary>
|
||||||
/// 200 rather than 201, for the reason enrollment gives: re-registering the same public key returns the
|
/// <remarks>
|
||||||
/// existing device, so there is no single moment of creation to point a Location header at.
|
/// 200 rather than 201, for the reason enrollment gives: re-registering the same public key returns the
|
||||||
/// </remarks>
|
/// existing device, so there is no single moment of creation to point a Location header at.
|
||||||
private static async Task<Results<Ok<RegisterDeviceResponse>, ProblemHttpResult>> RegisterDeviceAsync(
|
/// </remarks>
|
||||||
RegisterDeviceRequest request,
|
internal sealed class RegisterDeviceEndpoint(ICurrentUserContext currentUser, DeviceService devices)
|
||||||
ICurrentUserContext currentUser,
|
: Endpoint<RegisterDeviceRequest, Results<Ok<RegisterDeviceResponse>, ProblemHttpResult>>
|
||||||
DeviceService devices,
|
{
|
||||||
CancellationToken cancellationToken)
|
/// <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
|
try
|
||||||
{
|
{
|
||||||
var response = await devices.RegisterAsync(user, request, cancellationToken)
|
var response = await devices.RegisterAsync(user, req, ct).ConfigureAwait(false);
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
return TypedResults.Ok(response);
|
return TypedResults.Ok(response);
|
||||||
}
|
}
|
||||||
catch (DeviceRegistrationInvalidException exception)
|
catch (DeviceRegistrationInvalidException exception)
|
||||||
{
|
{
|
||||||
return Problem(
|
return Problems.Coded(
|
||||||
StatusCodes.Status400BadRequest,
|
StatusCodes.Status400BadRequest,
|
||||||
ProblemCodes.InvalidDeviceRegistration,
|
ProblemCodes.InvalidDeviceRegistration,
|
||||||
exception.Message);
|
exception.Message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <summary>Withdraws a device key, so that machine can no longer unlock without the passphrase.</summary>
|
||||||
/// 404 for a device that is not there, rather than a bland 204. A revocation is one of the few calls
|
/// <remarks>
|
||||||
/// where succeeding on a typo would be a real disservice — "revoked" is what the user reads, and reading
|
/// 404 for a device that is not there, rather than a bland 204. A revocation is one of the few calls
|
||||||
/// it about the wrong id is worse than being told to look again. Clients that are only driving towards
|
/// where succeeding on a typo would be a real disservice — "revoked" is what the user reads, and reading
|
||||||
/// "this machine cannot unlock" can treat 404 as having arrived, which is what the desktop client does.
|
/// it about the wrong id is worse than being told to look again. Clients that are only driving towards
|
||||||
/// </remarks>
|
/// "this machine cannot unlock" can treat 404 as having arrived, which is what the desktop client does.
|
||||||
private static async Task<Results<NoContent, NotFound>> RevokeDeviceAsync(
|
/// </remarks>
|
||||||
Guid deviceId,
|
internal sealed class RevokeDeviceEndpoint(ICurrentUserContext currentUser, DeviceService devices)
|
||||||
ICurrentUserContext currentUser,
|
: EndpointWithoutRequest<Results<NoContent, NotFound>>
|
||||||
DeviceService devices,
|
{
|
||||||
CancellationToken cancellationToken)
|
/// <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();
|
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.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Crypto;
|
using DodoSSH.Crypto;
|
||||||
|
using FastEndpoints;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
using Microsoft.Extensions.Options;
|
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
|
/// the normal case for self-hosted software — a client needs to ask what this particular server
|
||||||
/// supports rather than assume. See ADR 0002.
|
/// supports rather than assume. See ADR 0002.
|
||||||
/// </remarks>
|
/// </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>
|
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
|
||||||
internal const int SyncProtocolVersion = 1;
|
internal const int SyncProtocolVersion = 1;
|
||||||
@@ -23,27 +28,23 @@ internal static class MetaEndpoints
|
|||||||
/// <summary>Feature flag for the relay.</summary>
|
/// <summary>Feature flag for the relay.</summary>
|
||||||
internal const string RelayFeature = "relay";
|
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
|
// Anonymous by necessity: a client must be able to discover how to authenticate before it
|
||||||
// can authenticate.
|
// can authenticate. Load-bearing rather than decorative — FastEndpoints authenticates every
|
||||||
app.MapGet("/api/v1/meta", GetMeta)
|
// endpoint that does not say otherwise, so removing this line is a 401 nobody can recover from.
|
||||||
.AllowAnonymous()
|
AllowAnonymous();
|
||||||
|
|
||||||
|
Description(b => b
|
||||||
.WithName("GetMeta")
|
.WithName("GetMeta")
|
||||||
.WithSummary("Server capabilities, versions and limits.");
|
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Ok<MetaResponse> GetMeta(
|
/// <inheritdoc />
|
||||||
IOptions<SyncOptions> sync,
|
public override Task<Ok<MetaResponse>> ExecuteAsync(CancellationToken ct)
|
||||||
IOptions<RelayOptions> relay,
|
|
||||||
IOptions<ServerOptions> server)
|
|
||||||
{
|
{
|
||||||
List<string> features = ["teams"];
|
List<string> features = ["teams"];
|
||||||
if (relay.Value.Enabled)
|
if (relay.Value.Enabled)
|
||||||
@@ -51,7 +52,7 @@ internal static class MetaEndpoints
|
|||||||
features.Add(RelayFeature);
|
features.Add(RelayFeature);
|
||||||
}
|
}
|
||||||
|
|
||||||
return TypedResults.Ok(new MetaResponse(
|
return Task.FromResult(TypedResults.Ok(new MetaResponse(
|
||||||
ServerVersion: ServerVersion,
|
ServerVersion: ServerVersion,
|
||||||
ApiVersions: [1],
|
ApiVersions: [1],
|
||||||
SyncProtocolVersion: SyncProtocolVersion,
|
SyncProtocolVersion: SyncProtocolVersion,
|
||||||
@@ -60,17 +61,48 @@ internal static class MetaEndpoints
|
|||||||
MinClientVersion: server.Value.MinClientVersion,
|
MinClientVersion: server.Value.MinClientVersion,
|
||||||
MaxOperationsPerPush: sync.Value.MaxOperationsPerPush,
|
MaxOperationsPerPush: sync.Value.MaxOperationsPerPush,
|
||||||
MaxPayloadBytes: sync.Value.MaxPayloadBytes,
|
MaxPayloadBytes: sync.Value.MaxPayloadBytes,
|
||||||
MaxItemPayloadBytes: sync.Value.MaxItemPayloadBytes));
|
MaxItemPayloadBytes: sync.Value.MaxItemPayloadBytes)));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Ok<DodoSshConfiguration> GetConfiguration(
|
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<OidcOptions> oidc,
|
||||||
IOptions<RelayOptions> relay,
|
IOptions<RelayOptions> relay,
|
||||||
IOptions<ServerOptions> server)
|
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;
|
var relayOptions = relay.Value;
|
||||||
|
|
||||||
return TypedResults.Ok(new DodoSshConfiguration(
|
return Task.FromResult(TypedResults.Ok(new DodoSshConfiguration(
|
||||||
ApiBaseUrl: new Uri(server.Value.PublicBaseUrl, UriKind.Absolute),
|
ApiBaseUrl: new Uri(server.Value.PublicBaseUrl, UriKind.Absolute),
|
||||||
Oidc: new OidcConfiguration(
|
Oidc: new OidcConfiguration(
|
||||||
Authority: new Uri(oidc.Value.Authority, UriKind.Absolute),
|
Authority: new Uri(oidc.Value.Authority, UriKind.Absolute),
|
||||||
@@ -81,11 +113,6 @@ internal static class MetaEndpoints
|
|||||||
Enabled: relayOptions.Enabled,
|
Enabled: relayOptions.Enabled,
|
||||||
WebSocketUrl: relayOptions.Enabled && relayOptions.WebSocketUrl is not null
|
WebSocketUrl: relayOptions.Enabled && relayOptions.WebSocketUrl is not null
|
||||||
? new Uri(relayOptions.WebSocketUrl, UriKind.Absolute)
|
? 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.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Domain.Authorization;
|
using DodoSSH.Domain.Authorization;
|
||||||
|
using FastEndpoints;
|
||||||
using Microsoft.AspNetCore.Http.HttpResults;
|
using Microsoft.AspNetCore.Http.HttpResults;
|
||||||
|
|
||||||
namespace DodoSSH.Api.Features.Sync;
|
namespace DodoSSH.Api.Features.Sync;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The vault write path, and the delta read that pairs with it.
|
/// The delta read that pairs with the vault write path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Push is the <em>only</em> way vault items change; there are no per-entity POST, PUT or DELETE
|
/// 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
|
/// 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.
|
/// halves both the endpoint count and the authorization surface. See ADR 0003.
|
||||||
/// </remarks>
|
/// </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
|
// 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.
|
// 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.
|
// 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")
|
Policies(Auth.EnrolledPolicy);
|
||||||
.RequireAuthorization(Auth.EnrolledPolicy)
|
|
||||||
.WithTags("Sync");
|
|
||||||
|
|
||||||
// POST rather than GET: the filters live in the body, cursors are opaque, and no caching is
|
Description(b => b
|
||||||
// wanted. Non-mutating despite the verb.
|
|
||||||
group.MapPost("/pull", PullAsync)
|
|
||||||
.WithName("SyncPull")
|
.WithName("SyncPull")
|
||||||
.WithSummary("Reads vault changes after a cursor.");
|
.WithSummary("Reads vault changes after a cursor.")
|
||||||
|
.WithTags("Sync"));
|
||||||
group.MapPost("/push", PushAsync)
|
|
||||||
.WithName("SyncPush")
|
|
||||||
.WithSummary("Applies a batch of vault changes.");
|
|
||||||
|
|
||||||
return app;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Results<Ok<SyncPullResponse>, NotFound, ProblemHttpResult>> PullAsync(
|
/// <inheritdoc />
|
||||||
Guid vaultId,
|
public override async Task<Results<Ok<SyncPullResponse>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||||
SyncPullRequest request,
|
SyncPullRequest req,
|
||||||
ICurrentUserContext currentUser,
|
CancellationToken ct)
|
||||||
IVaultAccessService vaultAccess,
|
|
||||||
SyncService sync,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
|
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||||
var access = await vaultAccess.ResolveAsync(user.Id, vaultId, cancellationToken)
|
var access = await vaultAccess
|
||||||
|
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
// 404 rather than 403, and identically for "absent" and "forbidden": distinguishing them is
|
// 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))
|
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
|
||||||
{
|
{
|
||||||
return TypedResults.NotFound();
|
return TypedResults.NotFound();
|
||||||
@@ -59,27 +60,51 @@ internal static class SyncEndpoints
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await sync.PullAsync(access.Vault!, request, cancellationToken)
|
var response = await sync.PullAsync(access.Vault!, req, ct).ConfigureAwait(false);
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
return TypedResults.Ok(response);
|
return TypedResults.Ok(response);
|
||||||
}
|
}
|
||||||
catch (InvalidCursorException exception)
|
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(
|
/// <summary>
|
||||||
Guid vaultId,
|
/// The vault write path.
|
||||||
SyncPushRequest request,
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// See <see cref="SyncPullEndpoint"/> and ADR 0003 for why every mutation arrives here.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class SyncPushEndpoint(
|
||||||
ICurrentUserContext currentUser,
|
ICurrentUserContext currentUser,
|
||||||
IVaultAccessService vaultAccess,
|
IVaultAccessService vaultAccess,
|
||||||
SyncService sync,
|
SyncService sync)
|
||||||
CancellationToken cancellationToken)
|
: Endpoint<SyncPushRequest, Results<Ok<SyncPushResponse>, NotFound, ProblemHttpResult>>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public override void Configure()
|
||||||
{
|
{
|
||||||
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
|
Post("/api/v1/vaults/{vaultId:guid}/sync/push");
|
||||||
var access = await vaultAccess.ResolveAsync(user.Id, vaultId, cancellationToken)
|
|
||||||
|
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);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
|
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.
|
// Read but not Write: the vault exists and is visible, so 403 leaks nothing here.
|
||||||
if (!access.Permissions.HasFlag(PermissionFlags.Write))
|
if (!access.Permissions.HasFlag(PermissionFlags.Write))
|
||||||
{
|
{
|
||||||
return Problem(
|
return Problems.Coded(
|
||||||
StatusCodes.Status403Forbidden,
|
StatusCodes.Status403Forbidden,
|
||||||
ProblemCodes.Forbidden,
|
ProblemCodes.Forbidden,
|
||||||
"You do not have permission to modify this vault.");
|
"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
|
// 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.
|
// single stale item cannot block everything else a client queued while offline.
|
||||||
var response = await sync.PushAsync(access.Vault!, user.Id, request, cancellationToken)
|
var response = await sync.PushAsync(access.Vault!, user.Id, req, ct).ConfigureAwait(false);
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
return TypedResults.Ok(response);
|
return TypedResults.Ok(response);
|
||||||
}
|
}
|
||||||
catch (PushBatchTooLargeException exception)
|
catch (PushBatchTooLargeException exception)
|
||||||
{
|
{
|
||||||
return Problem(
|
return Problems.Coded(
|
||||||
StatusCodes.Status413PayloadTooLarge,
|
StatusCodes.Status413PayloadTooLarge,
|
||||||
ProblemCodes.PushBatchTooLarge,
|
ProblemCodes.PushBatchTooLarge,
|
||||||
exception.Message);
|
exception.Message);
|
||||||
}
|
}
|
||||||
catch (PushBatchInvalidException exception)
|
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.AddDodoOptions();
|
||||||
builder.Services.AddDodoPersistence(builder.Configuration);
|
builder.Services.AddDodoPersistence(builder.Configuration);
|
||||||
builder.Services.AddDodoAuthentication();
|
builder.Services.AddDodoAuthentication();
|
||||||
|
builder.Services.AddDodoEndpoints();
|
||||||
builder.Services.AddDodoOpenApi();
|
builder.Services.AddDodoOpenApi();
|
||||||
builder.Services.AddDodoHealthChecks();
|
builder.Services.AddDodoHealthChecks();
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ builder.Services.AddDodoHealthChecks();
|
|||||||
// TimeProvider so time can be faked in tests.
|
// TimeProvider so time can be faked in tests.
|
||||||
builder.Services.AddSingleton(TimeProvider.System);
|
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.AddHttpContextAccessor();
|
||||||
builder.Services.AddScoped<ICurrentUserContext, CurrentUserContext>();
|
builder.Services.AddScoped<ICurrentUserContext, CurrentUserContext>();
|
||||||
builder.Services.AddScoped<IVaultAccessService, VaultAccessService>();
|
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
|
// produces redirect loops behind a proxy. HTTPS in development comes from the
|
||||||
// launch profile instead.
|
// launch profile instead.
|
||||||
|
|
||||||
|
app.BlockFastEndpointsRouteTable();
|
||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
|
||||||
@@ -54,7 +55,12 @@ internal static class Auth
|
|||||||
options.MapInboundClaims = false;
|
options.MapInboundClaims = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddAuthorization(options =>
|
services.AddAuthorization(AddDodoPolicies);
|
||||||
|
|
||||||
|
return services;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddDodoPolicies(AuthorizationOptions options)
|
||||||
{
|
{
|
||||||
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
|
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
|
||||||
|
|
||||||
@@ -70,8 +76,13 @@ internal static class Auth
|
|||||||
|
|
||||||
// Deny by default: an endpoint without an explicit policy still requires a caller.
|
// Deny by default: an endpoint without an explicit policy still requires a caller.
|
||||||
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
||||||
});
|
|
||||||
|
|
||||||
return services;
|
// 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.Identity;
|
||||||
using DodoSSH.Api.Features.Meta;
|
using DodoSSH.Api.Features.Meta;
|
||||||
using DodoSSH.Api.Features.Sync;
|
using DodoSSH.Api.Features.Sync;
|
||||||
|
using DodoSSH.Contracts;
|
||||||
|
using FastEndpoints;
|
||||||
|
|
||||||
namespace DodoSSH.Api.Setup;
|
namespace DodoSSH.Api.Setup;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The single, explicit list of every endpoint module.
|
/// The single, explicit list of every endpoint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Deliberately not reflection-based discovery. Explicit registration gives predictable startup,
|
/// FastEndpoints can find endpoints by scanning assemblies. It is deliberately not asked to. Explicit
|
||||||
/// survives trimming, and makes every route greppable — and a route that silently disappears
|
/// registration gives predictable startup, survives trimming, and makes every route greppable from one
|
||||||
/// because an assembly was not scanned is a genuinely nasty failure. The cost is one line per
|
/// file — and a route that silently disappears because an assembly was not scanned is a genuinely nasty
|
||||||
/// module.
|
/// 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>
|
/// </remarks>
|
||||||
internal static class EndpointRegistration
|
internal static class EndpointRegistration
|
||||||
{
|
{
|
||||||
internal static WebApplication MapDodoEndpoints(this WebApplication app)
|
/// <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>
|
||||||
{
|
{
|
||||||
app.MapMetaEndpoints();
|
typeof(GetMetaEndpoint),
|
||||||
app.MapIdentityEndpoints();
|
typeof(GetDodoSshConfigurationEndpoint),
|
||||||
app.MapSyncEndpoints();
|
typeof(GetMeEndpoint),
|
||||||
|
typeof(EnrollEndpoint),
|
||||||
|
typeof(RegisterDeviceEndpoint),
|
||||||
|
typeof(RevokeDeviceEndpoint),
|
||||||
|
typeof(SyncPullEndpoint),
|
||||||
|
typeof(SyncPushEndpoint),
|
||||||
|
|
||||||
// Registered as each feature lands:
|
// Registered as each feature lands:
|
||||||
// Identity — key rotation, devices, passphrase change
|
// Identity — key rotation, passphrase change
|
||||||
// Directory — public-key lookup
|
// Directory — public-key lookup
|
||||||
// Vaults — grants, rekey, ACL
|
// Vaults — grants, rekey, ACL
|
||||||
// Relay — tickets and the WebSocket
|
// Relay — tickets and the WebSocket
|
||||||
// Teams, Audit, Admin
|
// 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.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<>));
|
||||||
|
});
|
||||||
|
|
||||||
return app;
|
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
|
internal static class Json
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies <see cref="DodoSshJsonContext"/>'s settings to the minimal-API serialiser.
|
/// Applies <see cref="DodoSshJsonContext"/>'s settings to the host's serialiser.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <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
|
/// serialised its requests with <c>PostAsJsonAsync</c>'s defaults, so both sides agreed on integers and
|
||||||
/// nothing disagreed with anything.
|
/// nothing disagreed with anything.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
internal static IServiceCollection AddDodoJson(this IServiceCollection services) =>
|
internal static IServiceCollection AddDodoJson(this IServiceCollection services) =>
|
||||||
services.ConfigureHttpJsonOptions(options => DodoSshJsonContext.ApplyTo(options.SerializerOptions));
|
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;
|
namespace DodoSSH.Api.Setup;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -9,13 +13,15 @@ namespace DodoSSH.Api.Setup;
|
|||||||
/// contract change fails the pull request. The desktop client's actual contract is the
|
/// contract change fails the pull request. The desktop client's actual contract is the
|
||||||
/// <c>DodoSSH.Contracts</c> assembly, guarded by PublicApiAnalyzers.
|
/// <c>DodoSSH.Contracts</c> assembly, guarded by PublicApiAnalyzers.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static class OpenApi
|
internal static partial class OpenApi
|
||||||
{
|
{
|
||||||
internal const string DocumentName = "v1";
|
internal const string DocumentName = "v1";
|
||||||
|
|
||||||
internal static IServiceCollection AddDodoOpenApi(this IServiceCollection services)
|
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:
|
// Added in M1, once there are endpoints to describe:
|
||||||
// - a document transformer contributing the OAuth2 authorizationCode + PKCE
|
// - a document transformer contributing the OAuth2 authorizationCode + PKCE
|
||||||
@@ -25,3 +31,88 @@ internal static class OpenApi
|
|||||||
return services;
|
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,
|
"version": 2,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"net10.0": {
|
"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": {
|
"Meziantou.Analyzer": {
|
||||||
"type": "Direct",
|
"type": "Direct",
|
||||||
"requested": "[3.0.134, )",
|
"requested": "[3.0.134, )",
|
||||||
@@ -32,6 +44,43 @@
|
|||||||
"resolved": "5.6.0",
|
"resolved": "5.6.0",
|
||||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
"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": {
|
"Microsoft.Bcl.Cryptography": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "10.0.2",
|
"resolved": "10.0.2",
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ public static class ProblemCodes
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public const string InvalidDeviceRegistration = "invalid-device-registration";
|
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>
|
/// <summary>The relay refused the requested target. Never states why, to avoid a probe oracle.</summary>
|
||||||
public const string RelayTargetRejected = "relay-target-rejected";
|
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.InvalidCursor = "invalid-cursor" -> string!
|
||||||
const DodoSSH.Contracts.ProblemCodes.InvalidDeviceRegistration = "invalid-device-registration" -> string!
|
const DodoSSH.Contracts.ProblemCodes.InvalidDeviceRegistration = "invalid-device-registration" -> string!
|
||||||
const DodoSSH.Contracts.ProblemCodes.InvalidEnrollment = "invalid-enrollment" -> 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.PushBatchTooLarge = "push-batch-too-large" -> string!
|
||||||
const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string!
|
const DodoSSH.Contracts.ProblemCodes.RelayLimitReached = "relay-limit-reached" -> string!
|
||||||
const DodoSSH.Contracts.ProblemCodes.RelayTargetRejected = "relay-target-rejected" -> 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",
|
"resolved": "2.2.1",
|
||||||
"contentHash": "21XZo/yuXK1k0EUhdLnjgRD4n0HQYmPFchV6uaORcRc65rasZ1vdm2dmJXPBKZiIBztRRYRmmg/B76W721VWkA=="
|
"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": {
|
"GraphQL": {
|
||||||
"type": "Transitive",
|
"type": "Transitive",
|
||||||
"resolved": "8.5.0",
|
"resolved": "8.5.0",
|
||||||
@@ -1668,6 +1711,7 @@
|
|||||||
"DodoSSH.Crypto": "[1.0.0, )",
|
"DodoSSH.Crypto": "[1.0.0, )",
|
||||||
"DodoSSH.Domain": "[1.0.0, )",
|
"DodoSSH.Domain": "[1.0.0, )",
|
||||||
"DodoSSH.Infrastructure": "[1.0.0, )",
|
"DodoSSH.Infrastructure": "[1.0.0, )",
|
||||||
|
"FastEndpoints": "[8.2.0, )",
|
||||||
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.10, )",
|
"Microsoft.AspNetCore.Authentication.JwtBearer": "[10.0.10, )",
|
||||||
"Microsoft.AspNetCore.OpenApi": "[10.0.10, )"
|
"Microsoft.AspNetCore.OpenApi": "[10.0.10, )"
|
||||||
}
|
}
|
||||||
@@ -1709,6 +1753,18 @@
|
|||||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.1"
|
"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": {
|
"libsodium": {
|
||||||
"type": "CentralTransitive",
|
"type": "CentralTransitive",
|
||||||
"requested": "[1.0.22, )",
|
"requested": "[1.0.22, )",
|
||||||
|
|||||||
Reference in New Issue
Block a user