diff --git a/Documentation/configuration/authentication.md b/Documentation/configuration/authentication.md index 755ed8e9..37ffe4d4 100644 --- a/Documentation/configuration/authentication.md +++ b/Documentation/configuration/authentication.md @@ -659,6 +659,110 @@ For machine-to-machine calls, configure a JWT Bearer handler: } ``` +The handler applies to every path, but a token from an issuer configured on a +[bearer route](#bearer-routes-access-tokens-from-an-authorization-server) never reaches it: such a token is refused +on every path outside its bearer routes. + +--- + +## Bearer routes: access tokens from an authorization server + +A service can declare [bearer routes](services.md#bearer-routes): path prefixes such as `/mcp` or `/v1` that are +authenticated by an access token from an external authorization server — for example Cratis Identity — rather +than by a browser session. AuthProxy remains an edge and relying party: it validates the token and forwards the +request with the same trusted headers a browser session gets. + +### Validation + +A token is accepted only when all of these hold: + +- It is a signed JWT (JWS); encrypted tokens and `alg: none` are refused. The algorithm is `RS256` or `ES256`. +- Its `iss` is exactly one of the route's issuers. +- Its signature verifies against a key in the issuer's JWKS. The metadata document (RFC 8414, or OpenID Connect + discovery) must name exactly the configured issuer. Metadata and keys are cached and refreshed periodically, + and a token naming an unknown key triggers a refresh at most every 30 seconds per issuer, so key rotation needs + no restart. An allowed refresh completes before validation is retried in the same request. Known-key lookups + use cached keys without waiting for retrieval; due automatic refreshes and their retries run in the background. + A failed refresh + keeps the last trusted keys in use and backs off retrieval for 30 seconds, including before the first success. + Metadata naming another issuer is never trusted. When no trusted keys have been retrieved, tokens are refused + with `503`, not as invalid. +- Its `typ` header is an access-token type: the issuer's `TokenTypes`, `at+jwt` or `application/at+jwt` by + default. +- Its `aud` names one of the route's audiences. +- It has an `exp`, and it is within its lifetime allowing the route's clock skew (30 seconds by default). +- It carries every scope the route requires, a single `sub`, and a single usable tenant. + +Then the deployment's [claim requirements](authorization.md) apply — the proxy-wide `Authorization` section and +the route's service's own — to the principal after the route's `ClaimMappings`, exactly as they apply to a +browser session, together with the route's own `RequiredClaims`. A route that sets +`IgnoreDeploymentRequiredClaims` is held to its own requirements only, for a deployment whose requirements name a +claim the issuer does not mint; AuthProxy logs a warning at startup for it. Role claims are dropped from the token +first, so a requirement on a role can never be met by a bearer token. A mapping replaces every case variant of +its target. Mapped `sub`, `preferred_username` and `name` must each have one usable source value; multiple values +refuse the token. Mapping into the route's tenant claim, or declaring targets differing only by case, is refused +at startup. + +### Responses + +| Situation | Response | +|-----------|----------| +| Path still percent-encoded after decoding, or containing a backslash, repeated `/` separators, a `;` (path parameter, as in `/mcp/..;/api`) or a `.`/`..` segment, on any route when bearer routes are configured | `400` before route selection, no challenge | +| No bearer token (a browser session does not count) | `401`, `WWW-Authenticate: Bearer resource_metadata=""` | +| Token invalid, expired, wrongly signed, from another issuer or for another audience; without a single `sub`; or without a claim a `ClaimMappings` entry reads | `401`, `WWW-Authenticate: Bearer error="invalid_token", resource_metadata="…"` | +| Token lacks a required scope | `403`, `WWW-Authenticate: Bearer error="insufficient_scope", scope="", resource_metadata="…"` | +| Token does not satisfy the claim requirements that apply on the route | `403` | +| Token carries no single usable tenant, or the tenant fails verification | `403` | +| The issuer's metadata or keys have never been retrieved, or the metadata names another issuer | `503` with `Retry-After: 30` | +| Bearer-route token on any other path — whatever the case of the scheme or the whitespace after it, and in any of several `Authorization` headers | `401`, `WWW-Authenticate: Bearer error="invalid_token"` | + +`resource_metadata` is omitted when the route declares no `ResourceMetadataUrl`. Every refusal in the table +carries `Cache-Control: no-store` and an empty body; the reason is logged, not returned. + +The path of `ResourceMetadataUrl` (for example `/.well-known/oauth-protected-resource/mcp`) is forwarded to the +service backend for `GET` and `HEAD` without authentication and without any identity headers, `Cookie` or +`Authorization`, so a client can discover the authorization server before it has a token. The backend serves the +document. Any other method on that path is answered `405` with `Allow: GET, HEAD` and `Cache-Control: no-store`. + +### Forwarded headers + +A request accepted on a bearer route is forwarded to the service backend with: + +| Header | Value | +|--------|-------| +| `x-ms-client-principal` | The principal: `identityProvider` from the route's `IdentityProvider`, `userId` from `sub`, `userDetails` from `preferred_username`, else `name`, else `sub`, roles `anonymous` and `authenticated`, and the token's claims — after the route's `ClaimMappings` have applied. | +| `x-ms-client-principal-id`, `x-ms-client-principal-name` | As for a browser session, from the same principal. | +| `Tenant-ID` | The tenant from the token. | +| `x-cratis-token-scope` | The granted scopes, space-separated. | +| `x-cratis-token-client-id` | The client the token was issued to, from `azp` or else `client_id`. This is the client's *asserted* identity: a public client cannot prove which program is using it. | + +The principal also carries claims AuthProxy writes itself: `urn:cratis:bearer:issuer`, `urn:cratis:bearer:subject` +(the token's own `sub`, kept when a mapping rewrites `sub`), `urn:cratis:bearer:client-id` and one +`urn:cratis:bearer:scope` per scope. A token cannot supply any `urn:cratis:bearer:` or `urn:cratis:identity:` +claim, and role claims in the token are not forwarded: a token never grants a role. + +Every inbound copy of these headers is removed on every route, bearer or not. The `Cookie` header is not +forwarded on a bearer route, and neither is `Authorization` unless the route sets `ForwardAuthorizationHeader`. +Every other request header is passed through as for any proxied request; AuthProxy adds no `Service-ID`. + +### Identity verification is not applied + +A bearer route **never calls `/.cratis/me`**, whatever `IdentityVerification` says. That endpoint answers for +browser sessions, and a product cannot be assumed to answer it correctly for a principal authenticated by a token. +The consequences: + +- Under explicitly configured `IdentityVerification: BestEffort`, a service answering `403` on `/.cratis/me` refuses a + browser session — but not a token on a bearer route. A user whose browser session a service refuses there can + still reach the service with a token. **The backend must enforce tenant membership** and what the user may do + with the scopes the client was granted. AuthProxy logs a warning at startup for every bearer route in a + deployment where some service answers `/.cratis/me`, unless the route sets `AcceptWithoutIdentityVerification` + to state that this is intended. +- Under `IdentityVerification: Required` (the default), AuthProxy refuses to start with a bearer route unless the route sets + `AcceptWithoutIdentityVerification`. +- No identity details are resolved, so no `.cratis-identity` cookie is written. + +See [Bearer routes](services.md#bearer-routes). + --- ## Back-channel client credentials diff --git a/Documentation/configuration/authorization.md b/Documentation/configuration/authorization.md index 3245ac89..a36c0694 100644 --- a/Documentation/configuration/authorization.md +++ b/Documentation/configuration/authorization.md @@ -227,6 +227,13 @@ GitHub Enterprise works without further configuration: the membership endpoints ## What a refused caller sees +On a [bearer route](services.md#bearer-routes) the requirements apply to the access token's principal, after the +route's claim mappings, and a refusal is a bare `403` with no page — the caller is a program, not a person. A route +can declare requirements of its own, and can leave the deployment's out with `IgnoreDeploymentRequiredClaims` when +they name a claim the token issuer does not mint — see [BearerRouteConfig properties](services.md#bearerrouteconfig-properties). + +For a browser session: + The [`not-authorized.html`](well-known-pages.md) page, at `403`, with a **Sign out** link. Both halves are deliberate. A redirect back to the identity provider — the reflex for "not allowed" — is diff --git a/Documentation/configuration/index.md b/Documentation/configuration/index.md index f139597e..9572742d 100644 --- a/Documentation/configuration/index.md +++ b/Documentation/configuration/index.md @@ -28,7 +28,7 @@ Cratis AuthProxy is configured entirely through the `Cratis:AuthProxy` section o | Topic | Description | |-------|-------------| -| [Authentication](authentication.md) | OIDC providers, OAuth 2.0 providers such as GitHub, and JWT Bearer configuration. | +| [Authentication](authentication.md) | OIDC providers, OAuth 2.0 providers such as GitHub, JWT Bearer configuration, and bearer routes for access tokens from an external authorization server. | | [Authorization](authorization.md) | Requiring a claim — a role, a group, a GitHub organization or team — before any request is forwarded. | | [Admission](admission.md) | Answering nothing at all until a caller presents a capability your own verifier admits, for a deployment whose existence is not meant to be discoverable. | | [Tenancy](tenancy.md) | How the auth proxy resolves the current tenant from each request, and how to verify tenant existence. | diff --git a/Documentation/configuration/services.md b/Documentation/configuration/services.md index 9466f23a..8fda27a2 100644 --- a/Documentation/configuration/services.md +++ b/Documentation/configuration/services.md @@ -46,6 +46,7 @@ Services are configured under `Cratis:AuthProxy:Services`, keyed by a friendly n | `ActivityTimeout` | `TimeSpan` | The root `ActivityTimeout`, then `00:05:00` | How long a request proxied to this service may sit idle before AuthProxy cancels it. See [Timeouts and streaming](#timeouts-and-streaming). | | `AnonymousPaths` | `string[]` | `[]` | Path prefixes on this service served to unauthenticated callers. See [Anonymous paths](#anonymous-paths). | | `ClientCredentials` | `ServiceClientCredentialsConfig` | `null` | Enables back-channel client-credentials verification and token minting for this service. | +| `BearerRoutes` | `BearerRouteConfig[]` | `[]` | Path prefixes authenticated by an access token from an external authorization server instead of a browser session. See [Bearer routes](#bearer-routes). | | `AccessToken` | `ServiceAccessTokenConfig` | `null` | Forwards the signed-in user's access token for this service's audience to its backend. See [Forwarding the user's access token](#forwarding-the-users-access-token). | ### ServiceEndpointConfig properties @@ -610,6 +611,168 @@ token, tenant-resolution, and refresh-token flow. --- +## Bearer routes + +A bearer route is a path prefix on a service that is called by programs — a CLI, an MCP client, another +service — with an access token issued by an external authorization server such as Cratis Identity. AuthProxy +stays a relying party: it validates the token and forwards the request, and it never issues these tokens. + +```json +{ + "Cratis": { + "AuthProxy": { + "Authorization": { + "RequiredClaims": [ + { "Claim": "urn:github:team", "AnyOf": [ "Cratis/direct" ] } + ] + }, + "Services": { + "direct": { + "Backend": { "BaseUrl": "http://direct:8080/" }, + "Frontend": { "BaseUrl": "http://direct:8080/" }, + "BearerRoutes": [ + { + "PathPrefix": "/mcp", + "Issuers": [ { "Issuer": "https://auth.example/" } ], + "Audiences": [ "direct-api" ], + "RequiredScopes": [ "direct:read" ], + "ResourceMetadataUrl": "https://cratis.direct/.well-known/oauth-protected-resource/mcp", + "IdentityProvider": "github", + "ClaimMappings": { + "sub": "github_id", + "preferred_username": "github_login" + }, + "IgnoreDeploymentRequiredClaims": true, + "AcceptWithoutIdentityVerification": true + }, + { + "PathPrefix": "/v1", + "Issuers": [ { "Issuer": "https://auth.example/" } ], + "Audiences": [ "direct-api" ], + "ResourceMetadataUrl": "https://cratis.direct/.well-known/oauth-protected-resource/v1", + "IdentityProvider": "github", + "ClaimMappings": { + "sub": "github_id", + "preferred_username": "github_login" + }, + "IgnoreDeploymentRequiredClaims": true, + "AcceptWithoutIdentityVerification": true + } + ] + } + } + } + } +} +``` + +This is Direct's shape: Cratis Identity issues tokens with `aud=direct-api` for both `https://cratis.direct/mcp` +and `https://cratis.direct/v1`. The claim mappings make a token-authenticated request carry the same +`x-ms-client-principal-id` (the numeric GitHub id) and `x-ms-client-principal-name` (the GitHub login) as a +browser session signed in through Direct's GitHub provider, so Direct resolves the same user either way. The +Cratis account id from the token's `sub` is still forwarded, as the `urn:cratis:bearer:subject` claim. + +Direct's deployment also requires the `urn:github:team` claim. A browser session gets it from Direct's GitHub +sign-in, which reads team membership from the GitHub API; a Cratis Identity access token does not carry it. Claim +requirements apply to bearer routes by default, so without `IgnoreDeploymentRequiredClaims` every token on these +routes would be refused with `403`. With it, the proxy-wide and service requirements are left out on the route, +AuthProxy logs a warning at startup naming them, and Direct's backend is what decides whether the caller is a member +of the tenant. When Cratis Identity mints a team or membership claim, either remove +`IgnoreDeploymentRequiredClaims` (if the claim is `urn:github:team` itself), or keep it and require the new claim +on the route: + +```json +"RequiredClaims": [ + { "Claim": "urn:cratis:membership", "AnyOf": [ "direct" ] } +] +``` + +`AcceptWithoutIdentityVerification` states the same thing for `/.cratis/me`: Direct answers it for browser +sessions, a bearer route never calls it, and Direct's backend checks the caller's membership of the tenant on +every token-authenticated request instead. + +### BearerRouteConfig properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `PathPrefix` | `string` | — | The prefix this route covers, for example `/mcp`. Matched case-insensitively on segment boundaries, with the same rules as [anonymous paths](#what-a-valid-entry-looks-like). The longest matching prefix wins. | +| `Issuers` | `BearerIssuerConfig[]` | — | The authorization servers whose tokens are accepted. At least one. | +| `Audiences` | `string[]` | — | The token's `aud` must exactly match at least one of these, including any trailing slash. At least one. | +| `RequiredScopes` | `string[]` | `[]` | Scopes the token must carry, every one of them. | +| `ResourceMetadataUrl` | `string` | `null` | Absolute URL of the RFC 9728 protected-resource metadata document. Named in every challenge; its path is forwarded to the backend without authentication and must be outside every bearer-route prefix. | +| `TenantClaimType` | `string` | `tid` | The token claim the tenant is read from. See [Tenancy](tenancy.md#bearer-routes). | +| `ClaimMappings` | `map` | `{}` | Forwarded claim type → token claim it is read from, replacing all case variants of the target. A missing source refuses the token; mapped `sub`, `preferred_username` and `name` require one usable source value. Targets may not differ only by case or overwrite the route's tenant claim. Claim types containing `:` cannot be keys, because `:` separates configuration sections. | +| `IdentityProvider` | `string` | `bearer` | The identity provider named in the forwarded principal. | +| `ForwardAuthorizationHeader` | `bool` | `false` | Whether the backend also receives the `Authorization` header. | +| `ClockSkew` | `TimeSpan` | `00:00:30` | Allowed clock skew for `exp` and `nbf`. At most `00:05:00`. | +| `RequiredClaims` | `{ Claim, AnyOf }[]` | `[]` | Claim requirements of the route's own, checked against the token's principal after `ClaimMappings`, in addition to the deployment's. Same shape and rules as [`Authorization:RequiredClaims`](authorization.md); a requirement on a role claim is refused at startup, because a token never carries a role. | +| `IgnoreDeploymentRequiredClaims` | `bool` | `false` | Leave the deployment's claim requirements — proxy-wide and the service's — out on this route. For a deployment whose requirements name a claim the token issuer does not mint. Logged as a warning at startup. | +| `AcceptWithoutIdentityVerification` | `bool` | `false` | State that this route's callers are accepted without any service's `/.cratis/me` being asked about them. Required when a service declares `IdentityVerification: Required`; under `BestEffort` it silences the startup warning. See [What a bearer route changes](#what-a-bearer-route-changes). | + +### BearerIssuerConfig properties + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Issuer` | `string` | — | The issuer identifier. The token's `iss` and the issuer metadata's `issuer` must both be exactly this value. HTTPS, or plain HTTP on a loopback host for development. | +| `MetadataAddress` | `string` | RFC 8414 address | The metadata document. Defaults to `/.well-known/oauth-authorization-server` inserted between the issuer's host and path. An OpenID Connect discovery document works too. | +| `TokenTypes` | `string[]` | `at+jwt`, `application/at+jwt` | Accepted JWT `typ` header values, so an ID token cannot be presented as an access token. | + +### What a bearer route changes + +- A request on a bearer route is answered before static files, authentication, provider selection, tenant + selection and identity enrichment — none of them apply. [Admission](admission.md) and the trusted-proxy + boundary run first and apply as to any request. +- The deployment's [claim requirements](authorization.md) — proxy-wide and the route's service's — apply to + the token's principal after `ClaimMappings`, unless the route sets `IgnoreDeploymentRequiredClaims`. The + route's own `RequiredClaims` apply on top either way. A token that does not satisfy them is refused with `403`. + A requirement on a role can never be met: roles are dropped from every token. +- A bearer route **never calls `/.cratis/me`**, in either identity-verification mode: the endpoint answers for + browser sessions, not for principals authenticated by a token. The backend must enforce tenant membership. + - Under explicitly configured `BestEffort`, a `403` from a service's `/.cratis/me` refuses a browser session but not a + token. AuthProxy starts, and logs a warning for each bearer route in a deployment where some service answers + `/.cratis/me`, unless the route sets `AcceptWithoutIdentityVerification: true`. + - Under `Required` (the default), AuthProxy **refuses to start** with a bearer route unless the route sets + `AcceptWithoutIdentityVerification: true`. + + Setting it is the operator's statement that, on this route, the validated token, its scopes and the claim + requirements are the whole decision at the edge and the backend answers for the rest. +- Bearer prefixes are full external paths claimed on **every host**, not relative to the owning service's + `PathPrefix` or restricted by its `Hosts`. They may lie under their own service's `PathPrefix`, but cannot + overlap another service's `PathPrefix`, even on different hosts. AuthProxy refuses such overlaps at startup. +- A service with bearer routes cannot set `StripPathPrefix: true`: a browser request such as + `/app/api/mcp/tools` could otherwise be stripped to `/api/mcp/tools` and bypass the bearer policy there. + AuthProxy refuses this configuration at startup. Set `StripPathPrefix: false` and have the backend serve + the full external paths, or remove the bearer routes from that service; do not expose the same bearer-only + backend resource through another service that strips a prefix. +- An accepted request is forwarded straight to the service **backend**, whichever of the service's endpoints + would otherwise serve that path, with its path and query unchanged. AuthProxy adds no `Service-ID` header; one + the caller sent is passed through like any other request header that is not an identity header. The backend's + [activity timeout](#timeouts-and-streaming) applies, with backend → service → root → default precedence, + including after configuration reloads. +- The session cookie is never read, and the `Cookie` header is not forwarded. +- When any bearer route is configured, a request whose path still carries percent-encoding after the server + has decoded it (such as an encoded `/`), a backslash, a `;` anywhere in it, repeated `/` separators, or a `.` or + `..` segment is refused with `400` **before route selection**, including on browser-session paths. A backend + that decoded or normalized it differently could otherwise serve a bearer resource under browser-session + authentication or a weaker bearer policy. The `;` starts a path parameter, which Tomcat, Jetty and Spring + strip before resolving dot segments, so they read `/mcp/..;/api/items` as `/api/items`. Repeated separators can + hide a stricter nested route, such as `/v1//admin`, or the only bearer prefix, such as `/api//mcp/tools` when + `/api/mcp` is configured. Clients must send unambiguous paths; there is no browser-session fallback. +- Every refusal is an API-style `400`, `401`, `403` or `503` — or `405` for a method other than `GET` or `HEAD` + on the `ResourceMetadataUrl` path — never a redirect or a page. See + [Bearer routes](authentication.md#bearer-routes-access-tokens-from-an-authorization-server). +- A token from a bearer-route issuer is refused with `401` on every path that is **not** one of the issuer's + bearer routes, even where another bearer scheme (the [JWT Bearer](authentication.md#jwt-bearer-api) handler) + would accept it. A browser-only surface such as `/api` stays browser-only. +- A route that overlaps an anonymous path, repeats another route's prefix, names no issuer or audience, or + belongs to a service without a backend is refused at startup, as is one that does not accept callers without + identity verification in a deployment that requires it. +- With no bearer route configured, the bearer gate leaves requests untouched. Browser sessions still strip + inbound `x-cratis-token-scope` and `x-cratis-token-client-id` headers and claims in the `urn:cratis:bearer:` + namespace, which only bearer routes set. + +--- + ## Forwarding the user's access token By default a backend learns who the user is from the identity headers only. A backend that has to call another diff --git a/Documentation/configuration/tenancy.md b/Documentation/configuration/tenancy.md index 5c2878ff..749659c4 100644 --- a/Documentation/configuration/tenancy.md +++ b/Documentation/configuration/tenancy.md @@ -254,3 +254,18 @@ If a strategy provides a strategy-specific verification URL template (for exampl When verification fails, AuthProxy serves `tenant-not-found.html`. See [Error pages](error-pages.md) to override this page. + +--- + +## Bearer routes + +On a [bearer route](services.md#bearer-routes) the tenant comes from the access token alone: the claim named by +`TenantClaimType` (`tid` by default). None of the resolution strategies above run, the tenant-selection cookie is +never read and the tenant-selection page is never served. + +- A token that does not carry exactly one tenant, or carries one that is not made of letters, digits, `-`, `.`, + `_` or `~` (at most 256 characters), is refused with `403`. There is no fallback to a default tenant. +- When [tenant verification](#tenant-verification) is configured, the tenant is verified the same way; a tenant + that fails verification is refused with `403` rather than the tenant-not-found page. +- The tenant is forwarded as `Tenant-ID`. It records which tenant the user chose when the token was granted; the + backend still decides whether the user is a member of it. diff --git a/README.md b/README.md index c3aa8ad5..ab466fa8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Without a gateway, every service re-implements the same boilerplate: an OIDC han ## What it handles -- **Authentication** — OpenID Connect (single or multiple providers with a built-in provider-selection page; Microsoft Entra ID, Google, GitHub, Apple, or any custom OIDC provider), OAuth 2.0, JWT Bearer for machine-to-machine calls, and back-channel client credentials exchanged at `/.cratis/token`. +- **Authentication** — OpenID Connect (single or multiple providers with a built-in provider-selection page; Microsoft Entra ID, Google, GitHub, Apple, or any custom OIDC provider), OAuth 2.0, JWT Bearer for machine-to-machine calls, back-channel client credentials exchanged at `/.cratis/token`, and per-service bearer routes that accept access tokens from an external authorization server such as Cratis Identity. - **Authorization** — require a claim (a role, a group, a GitHub organization or team) before any request is forwarded. - **Multi-tenancy** — resolve the current tenant per request from the host, a subdomain, a claim, the route, a selection page, or a fixed value, with optional remote tenant verification. Downstream services receive the tenant on an `x-cratis-tenant-id` header, the name Arc resolves by default. - **Identity enrichment** — calls a `/.cratis/me` endpoint on your service and attaches the enriched identity to forwarded requests as a trusted header. diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_does_not_accept_callers_without_required_identity_verification.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_does_not_accept_callers_without_required_identity_verification.cs new file mode 100644 index 00000000..473676de --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_does_not_accept_callers_without_required_identity_verification.cs @@ -0,0 +1,35 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Options; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A deployment that requires identity verification of every forwarded request does not start with a bearer route +/// that would forward requests without it, unless the route says so. The mistake is named at startup rather than +/// found in production. +/// +public class when_a_bearer_route_does_not_accept_callers_without_required_identity_verification : IDisposable +{ + readonly VerificationRequiringHarness _harness = new(); + readonly Exception? _startup; + + public when_a_bearer_route_does_not_accept_callers_without_required_identity_verification() => + _startup = Record.Exception(() => _harness.CreateClient().Dispose()); + + [Fact] public void should_refuse_to_start() => Assert.IsType(_startup); + [Fact] public void should_name_the_setting_that_accepts_it() => Assert.Contains(nameof(C.BearerRoute.AcceptWithoutIdentityVerification), _startup!.Message, StringComparison.Ordinal); + + public void Dispose() + { + _harness.Dispose(); + GC.SuppressFinalize(this); + } + + sealed class VerificationRequiringHarness : BearerRouteHarness + { + protected override void AddSettings(IDictionary settings) => + settings[$"{C.AuthProxy.SectionKey}:Services:app:IdentityVerification"] = nameof(C.IdentityVerificationMode.Required); + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_has_a_browser_prefix_alias.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_has_a_browser_prefix_alias.cs new file mode 100644 index 00000000..74d9a7fc --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_has_a_browser_prefix_alias.cs @@ -0,0 +1,67 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Options; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A stripped browser prefix must not create cookie-authenticated access to a bearer-only backend resource. +/// Bearer prefixes also cannot shadow another service's browser prefix, regardless of host restrictions. +/// +public class when_a_bearer_route_has_a_browser_prefix_alias +{ + [Fact] + public void should_refuse_a_service_whose_browser_prefix_would_be_stripped() + { + using var harness = new StrippedPrefixHarness(); + var error = Assert.Throws(() => harness.CreateBearerClient().Dispose()); + + Assert.Contains(nameof(C.Service.StripPathPrefix), error.Message, StringComparison.Ordinal); + Assert.Empty(harness.Origin.Received); + } + + [Theory] + [InlineData("/MCP", "/mcp")] + [InlineData("/mcp/tools", "/mcp")] + [InlineData(" /mcp/ ", "/mcp")] + [InlineData("/app", "/app/mcp")] + public void should_refuse_overlap_with_another_services_prefix(string otherPrefix, string bearerPrefix) + { + using var harness = new OverlappingPrefixHarness(otherPrefix, bearerPrefix); + var error = Assert.Throws(() => harness.CreateBearerClient().Dispose()); + + Assert.Contains("overlaps service 'other' PathPrefix", error.Message, StringComparison.Ordinal); + Assert.Empty(harness.Origin.Received); + } + + [Fact] + public void should_allow_distinct_segments() + { + using var harness = new OverlappingPrefixHarness("/mcpx", BearerRouteHarness.RoutePrefix); + using var client = harness.CreateBearerClient(); + } + + sealed class StrippedPrefixHarness : BearerRouteHarness + { + protected override void AddSettings(IDictionary settings) + { + settings[$"{C.AuthProxy.SectionKey}:Services:app:PathPrefix"] = "/app"; + settings[$"{C.AuthProxy.SectionKey}:Services:app:StripPathPrefix"] = "true"; + settings[$"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0:PathPrefix"] = "/api/mcp"; + } + } + + sealed class OverlappingPrefixHarness(string otherPrefix, string bearerPrefix) : BearerRouteHarness + { + protected override void AddSettings(IDictionary settings) + { + settings[$"{C.AuthProxy.SectionKey}:Services:app:Hosts:0"] = "app.example.test"; + settings[$"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0:PathPrefix"] = bearerPrefix; + settings[$"{C.AuthProxy.SectionKey}:Services:other:Hosts:0"] = "other.example.test"; + settings[$"{C.AuthProxy.SectionKey}:Services:other:PathPrefix"] = otherPrefix; + settings[$"{C.AuthProxy.SectionKey}:Services:other:Backend:BaseUrl"] = Origin.BaseUrl; + settings[$"{C.AuthProxy.SectionKey}:Services:other:ResolveIdentityDetails"] = "false"; + } + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_ignores_the_deployment_requirements.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_ignores_the_deployment_requirements.cs new file mode 100644 index 00000000..d5fdd264 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_ignores_the_deployment_requirements.cs @@ -0,0 +1,54 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A deployment whose claim requirements name a claim the token issuer does not mint would refuse every token on +/// its bearer routes. A route that leaves the deployment's requirements out serves a token that satisfies its own +/// requirements alone, and still refuses one that does not — however well it satisfies the deployment's. +/// +/// The running proxy, with claim requirements declared. +[Collection(GatedBearerRouteSpecCollection.Name)] +public class when_a_bearer_route_ignores_the_deployment_requirements(GatedBearerRouteHarness harness) : IAsyncLifetime +{ + const string MemberPath = $"{BearerRouteHarness.ForwardingRoutePrefix}/member"; + const string NotAMemberPath = $"{BearerRouteHarness.ForwardingRoutePrefix}/not-a-member"; + + HttpResponseMessage? _member; + HttpResponseMessage? _notAMember; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + // Neither the proxy-wide organization nor the service's preferred_username: only the route's membership. + _member = await Send(client, MemberPath, harness.Issuer.Token(new Dictionary + { + [GatedBearerRouteHarness.MembershipClaim] = GatedBearerRouteHarness.MembershipValue, + ["preferred_username"] = "someone-else", + })); + + // Everything the deployment requires, but not the route's membership. + _notAMember = await Send(client, NotAMemberPath, harness.Issuer.Token(new Dictionary + { + [GatedBearerRouteHarness.RequiredClaim] = GatedBearerRouteHarness.RequiredValue, + ["preferred_username"] = BearerRouteHarness.GitHubLogin, + })); + } + + public Task DisposeAsync() + { + _member?.Dispose(); + _notAMember?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_forward_a_token_satisfying_the_route_requirements() => Assert.Equal(HttpStatusCode.OK, _member!.StatusCode); + [Fact] public void should_refuse_a_token_missing_the_route_requirement() => Assert.Equal(HttpStatusCode.Forbidden, _notAMember!.StatusCode); + [Fact] public void should_not_forward_the_refused_token() => Assert.False(harness.Origin.ReceivedAnythingFor(NotAMemberPath)); + + static Task Send(HttpClient client, string path, string token) => + client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, path, token)); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_is_misconfigured.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_is_misconfigured.cs new file mode 100644 index 00000000..1cfbd6bf --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_route_is_misconfigured.cs @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Options; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A bearer route AuthProxy could not enforce as written stops the host from starting, rather than silently leaving +/// its path on the browser-session model: a route overlapping an anonymous path, repeating another route's prefix, +/// or naming no issuer or audience is named in the startup failure. +/// +public class when_a_bearer_route_is_misconfigured : IDisposable +{ + readonly MisconfiguredHarness _harness = new(); + readonly Exception? _startup; + + public when_a_bearer_route_is_misconfigured() => + _startup = Record.Exception(() => _harness.CreateClient().Dispose()); + + [Fact] public void should_refuse_to_start() => Assert.IsType(_startup); + [Fact] public void should_name_the_overlap_with_an_anonymous_path() => Assert.Contains("overlaps the anonymous path", _startup!.Message, StringComparison.Ordinal); + [Fact] public void should_name_the_repeated_prefix() => Assert.Contains("is already a bearer route", _startup!.Message, StringComparison.Ordinal); + [Fact] public void should_name_the_missing_issuer() => Assert.Contains($"{nameof(C.BearerRoute.Issuers)} is empty", _startup!.Message, StringComparison.Ordinal); + [Fact] public void should_name_the_missing_audience() => Assert.Contains($"{nameof(C.BearerRoute.Audiences)} is empty", _startup!.Message, StringComparison.Ordinal); + + public void Dispose() + { + _harness.Dispose(); + GC.SuppressFinalize(this); + } + + sealed class MisconfiguredHarness : BearerRouteHarness + { + protected override void AddSettings(IDictionary settings) + { + settings[$"{C.AuthProxy.SectionKey}:Services:app:AnonymousPaths:0"] = $"{RoutePrefix}/public"; + settings[$"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:3:PathPrefix"] = ForwardingRoutePrefix; + } + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_off_its_routes_with_unusual_formatting.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_off_its_routes_with_unusual_formatting.cs new file mode 100644 index 00000000..1a7b3e95 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_off_its_routes_with_unusual_formatting.cs @@ -0,0 +1,76 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Primitives; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A bearer-route token stays off browser-only surfaces however the Authorization header is +/// written. The JWT Bearer handler trims what follows the scheme, so a second space or a tab would otherwise carry +/// the token past the refusal and into a handler that trusts its issuer. The same holds when the token is one of +/// several Authorization headers. +/// +/// The headers are set on the server-side request directly: an would parse and re-write +/// them into the canonical form this spec is about avoiding. +/// +/// +/// The running proxy, with the JWT Bearer handler trusting the bearer-route issuer. +[Collection(JwtBearerAlongsideBearerRoutesSpecCollection.Name)] +public class when_a_bearer_token_is_presented_off_its_routes_with_unusual_formatting(JwtBearerAlongsideBearerRoutesHarness harness) : IAsyncLifetime +{ + const string Path = "/api/items"; + + HttpResponseMessage? _control; + HttpContext? _doubleSpace; + HttpContext? _tab; + HttpContext? _spaceAndTab; + HttpContext? _lowerCase; + HttpContext? _secondHeader; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + // A token the JWT Bearer handler accepts and no bearer route names: the handler is live on this path. + _control = await client.SendAsync(BearerRouteHarness.WithToken( + HttpMethod.Get, + Path, + harness.Issuer.Token(issuer: JwtBearerAlongsideBearerRoutesHarness.OtherIssuer))); + + harness.Origin.Clear(); + var token = harness.Issuer.Token(); + _doubleSpace = await SendWithRawAuthorization($"Bearer {token}"); + _tab = await SendWithRawAuthorization($"Bearer\t{token}"); + _spaceAndTab = await SendWithRawAuthorization($"Bearer \t{token} "); + _lowerCase = await SendWithRawAuthorization($"bearer {token}"); + _secondHeader = await SendWithRawAuthorization("Basic dXNlcjpwYXNz", $"Bearer {token}"); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _control?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_let_the_jwt_bearer_handler_accept_a_token_from_another_issuer() => Assert.Equal(HttpStatusCode.OK, _control!.StatusCode); + [Fact] public void should_refuse_it_after_two_spaces() => Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)_doubleSpace!.Response.StatusCode); + [Fact] public void should_refuse_it_after_a_tab() => Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)_tab!.Response.StatusCode); + [Fact] public void should_refuse_it_after_a_space_and_a_tab() => Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)_spaceAndTab!.Response.StatusCode); + [Fact] public void should_refuse_it_under_a_lower_case_scheme() => Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)_lowerCase!.Response.StatusCode); + [Fact] public void should_refuse_it_as_one_of_several_headers() => Assert.Equal(HttpStatusCode.Unauthorized, (HttpStatusCode)_secondHeader!.Response.StatusCode); + [Fact] public void should_challenge_with_invalid_token() => Assert.Equal("Bearer error=\"invalid_token\"", _doubleSpace!.Response.Headers.WWWAuthenticate.ToString()); + [Fact] public void should_not_forward_any_of_them() => Assert.False(_forwarded); + + Task SendWithRawAuthorization(params string[] values) => + harness.Server.SendAsync(context => + { + context.Request.Method = HttpMethods.Get; + context.Request.Path = Path; + context.Request.Headers.Authorization = new StringValues(values); + }); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_on_a_browser_route.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_on_a_browser_route.cs new file mode 100644 index 00000000..6b083b5e --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_bearer_token_is_presented_on_a_browser_route.cs @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token accepted on a bearer route is accepted there and nowhere else. On a browser-only surface such as +/// /api it is refused outright, even alongside a browser session, so an API token can never reach +/// what only a person signed in through the browser may use. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_a_bearer_token_is_presented_on_a_browser_route(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = "/api/items"; + + HttpResponseMessage? _response; + HttpResponseMessage? _withSession; + HttpResponseMessage? _sessionOnly; + bool _forwardedWithToken; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token())); + + var withSession = BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token()); + withSession.Headers.TryAddWithoutValidation(SecurityHarness.AuthenticatedUserHeader, "bearer-spec-user"); + _withSession = await client.SendAsync(withSession); + _forwardedWithToken = harness.Origin.ReceivedAnythingFor(Path); + + _sessionOnly = await client.SendAsync(SecurityHarness.Authenticated(HttpMethod.Get, Path, "bearer-spec-user")); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + _withSession?.Dispose(); + _sessionOnly?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode); + [Fact] public void should_refuse_it_alongside_a_browser_session() => Assert.Equal(HttpStatusCode.Unauthorized, _withSession!.StatusCode); + [Fact] public void should_not_forward_it() => Assert.False(_forwardedWithToken); + [Fact] public void should_challenge_with_invalid_token() => Assert.Equal("Bearer error=\"invalid_token\"", _response!.Headers.WwwAuthenticate.ToString()); + [Fact] public void should_still_serve_the_browser_session() => Assert.Equal(HttpStatusCode.OK, _sessionOnly!.StatusCode); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_service_would_refuse_the_caller_through_its_identity_endpoint.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_service_would_refuse_the_caller_through_its_identity_endpoint.cs new file mode 100644 index 00000000..e9dbce10 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_service_would_refuse_the_caller_through_its_identity_endpoint.cs @@ -0,0 +1,58 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Http; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Under explicitly configured best-effort identity verification, a service answering 403 on +/// /.cratis/me refuses a browser session. A bearer route never calls that endpoint — it answers for +/// browser sessions, not for principals authenticated by a token — so the refusal does not apply there: the token is +/// forwarded, and the backend decides membership. This is the documented behavior, reported at startup, not an +/// oversight a token can exploit without the operator knowing. +/// +/// The running proxy, with explicitly configured best-effort identity verification. +[Collection(BearerRouteSpecCollection.Name)] +public class when_a_service_would_refuse_the_caller_through_its_identity_endpoint(BearerRouteHarness harness) : IAsyncLifetime +{ + const string BearerPath = $"{BearerRouteHarness.RoutePrefix}/tools"; + const string BrowserPath = "/api/items"; + + HttpResponseMessage? _bearer; + HttpResponseMessage? _browser; + bool _identityEndpointCalledForTheToken; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + harness.Origin.IdentityResponse = () => Results.StatusCode(StatusCodes.Status403Forbidden); + + try + { + _bearer = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, BearerPath, harness.Issuer.Token())); + _identityEndpointCalledForTheToken = harness.Origin.ReceivedAnythingFor(WellKnownPaths.IdentityDetails); + + using var withSession = new HttpRequestMessage(HttpMethod.Get, BrowserPath); + withSession.Headers.TryAddWithoutValidation(SecurityHarness.AuthenticatedUserHeader, "refused-browser-user"); + _browser = await client.SendAsync(withSession); + } + finally + { + harness.Origin.IdentityResponse = () => Results.Json(new { }); + } + } + + public Task DisposeAsync() + { + _bearer?.Dispose(); + _browser?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_forward_the_token() => Assert.Equal(HttpStatusCode.OK, _bearer!.StatusCode); + [Fact] public void should_not_call_the_identity_endpoint_for_the_token() => Assert.False(_identityEndpointCalledForTheToken); + [Fact] public void should_still_refuse_the_browser_session() => Assert.Equal(HttpStatusCode.Forbidden, _browser!.StatusCode); + [Fact] public void should_not_forward_the_refused_browser_session() => Assert.False(harness.Origin.ReceivedAnythingFor(BrowserPath)); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_token_audience_differs_by_a_trailing_slash.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_token_audience_differs_by_a_trailing_slash.cs new file mode 100644 index 00000000..86489fac --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_token_audience_differs_by_a_trailing_slash.cs @@ -0,0 +1,41 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Audience identifiers are exact: adding or removing a trailing slash names a different resource. +/// +public class when_a_token_audience_differs_by_a_trailing_slash +{ + [Theory] + [InlineData("direct-api", "direct-api/", false, HttpStatusCode.Unauthorized)] + [InlineData("direct-api/", "direct-api", false, HttpStatusCode.Unauthorized)] + [InlineData("direct-api", "direct-api", false, HttpStatusCode.OK)] + [InlineData("direct-api/", "direct-api/", false, HttpStatusCode.OK)] + [InlineData("direct-api", "direct-api/", true, HttpStatusCode.OK)] + [InlineData("direct-api/", "direct-api", true, HttpStatusCode.OK)] + public async Task should_accept_only_an_explicitly_configured_audience(string configuredAudience, string tokenAudience, bool includeTokenAudience, HttpStatusCode expected) + { + const string path = "/mcp/exact-audience"; + await using var harness = new ExactAudienceHarness(configuredAudience, includeTokenAudience ? tokenAudience : null); + using var client = harness.CreateBearerClient(); + using var request = BearerRouteHarness.WithToken(HttpMethod.Get, path, harness.Issuer.Token(audience: tokenAudience)); + using var response = await client.SendAsync(request); + + Assert.Equal(expected, response.StatusCode); + Assert.Equal(expected == HttpStatusCode.OK, harness.Origin.ReceivedAnythingFor(path)); + } + + sealed class ExactAudienceHarness(string audience, string? additionalAudience) : BearerRouteHarness + { + protected override void AddSettings(IDictionary settings) + { + settings[$"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0:Audiences:0"] = audience; + if (additionalAudience is not null) + { + settings[$"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0:Audiences:1"] = additionalAudience; + } + } + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_valid_token_is_presented.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_valid_token_is_presented.cs new file mode 100644 index 00000000..ca71f604 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_a_valid_token_is_presented.cs @@ -0,0 +1,54 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Identity; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A valid access token on a bearer route is the whole credential: the request is forwarded with the identity a +/// browser session of the same deployment would carry — the GitHub id and login, through the route's claim +/// mappings — the tenant the token names, and the scopes and client the token was issued with. The token itself, +/// and any cookie, stay at the edge. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_a_valid_token_is_presented(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + ForwardedRequest? _forwarded; + ClientPrincipal? _principal; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + var request = BearerRouteHarness.WithToken(HttpMethod.Post, Path, harness.Issuer.Token()); + request.Headers.TryAddWithoutValidation("Cookie", ".cratis-session=some-session"); + _response = await client.SendAsync(request); + _forwarded = harness.Origin.LastRequestTo(Path); + + ClientPrincipal.TryFromBase64(_forwarded?.Value(Headers.Principal), out _principal); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_answer_with_the_origin_response() => Assert.Equal(HttpStatusCode.OK, _response!.StatusCode); + [Fact] public void should_forward_the_request() => Assert.NotNull(_forwarded); + [Fact] public void should_forward_the_github_id_as_the_principal_id() => Assert.Equal(BearerRouteHarness.GitHubId, _forwarded!.Value(Headers.PrincipalId)); + [Fact] public void should_forward_the_github_login_as_the_principal_name() => Assert.Equal(BearerRouteHarness.GitHubLogin, _forwarded!.Value(Headers.PrincipalName)); + [Fact] public void should_forward_the_tenant_from_the_token() => Assert.Equal(BearerRouteHarness.TenantId, _forwarded!.Value(Headers.TenantId)); + [Fact] public void should_forward_the_granted_scopes() => Assert.Equal("direct:read direct:work", _forwarded!.Value(Headers.TokenScope)); + [Fact] public void should_forward_the_client_the_token_was_issued_to() => Assert.Equal(BearerRouteHarness.ClientId, _forwarded!.Value(Headers.TokenClientId)); + [Fact] public void should_name_the_route_identity_provider() => Assert.Equal("github", _principal!.IdentityProvider); + [Fact] public void should_keep_the_account_id_in_the_principal() => Assert.Contains(_principal!.Claims, _ => _.Type == "urn:cratis:bearer:subject" && _.Value == BearerRouteHarness.AccountId); + [Fact] public void should_not_forward_the_token() => Assert.False(_forwarded!.Has("Authorization")); + [Fact] public void should_not_forward_the_cookie() => Assert.False(_forwarded!.Has("Cookie")); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_configuration_has_surrounding_whitespace.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_configuration_has_surrounding_whitespace.cs new file mode 100644 index 00000000..a279a804 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_configuration_has_surrounding_whitespace.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Startup validation and runtime authentication agree on trimmed tenant claims and issuer identifiers. +/// +public class when_bearer_configuration_has_surrounding_whitespace +{ + [Fact] + public async Task should_validate_and_forward_the_original_tenant_claim() + { + await using var harness = new PaddedBearerRouteHarness(); + using var client = harness.CreateBearerClient(); + using var response = await client.SendAsync(BearerRouteHarness.WithToken( + HttpMethod.Get, + "/mcp/trimmed-configuration", + harness.Issuer.Token(new Dictionary { ["tenant"] = BearerRouteHarness.TenantId }, without: ["tid"]))); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(BearerRouteHarness.TenantId, harness.Origin.LastRequestTo("/mcp/trimmed-configuration")?.Value(Headers.TenantId)); + } + + [Fact] + public async Task should_not_accept_a_padded_issuer_inside_a_token() + { + await using var harness = new PaddedBearerRouteHarness(); + using var client = harness.CreateBearerClient(); + using var response = await client.SendAsync(BearerRouteHarness.WithToken( + HttpMethod.Get, + "/mcp/padded-token-issuer", + harness.Issuer.Token(issuer: $" {harness.Issuer.Issuer} "))); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.False(harness.Origin.ReceivedAnythingFor("/mcp/padded-token-issuer")); + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs new file mode 100644 index 00000000..794901f1 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_bearer_route_paths_are_matched.cs @@ -0,0 +1,89 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Http; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A bearer route covers its prefix case-insensitively and on segment boundaries only: /mcpx and +/// /api/mcp are not /mcp, so a token presented there is refused as off its routes. A +/// path that a backend could decode or normalize into some other path — an encoded separator, any +/// other remaining percent-encoding, a backslash, a dot segment, a path parameter (;), which +/// Tomcat, Jetty and Spring strip before resolving the dot segment it hides in — is refused before the token is looked at, because +/// the principal forwarded with it would be vouched for at a path that is not a bearer route. +/// +/// Paths are set on the server-side request directly, as the server would hand them to the gate after decoding; +/// an would normalize some of them away first. +/// +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_bearer_route_paths_are_matched(BearerRouteHarness harness) : IAsyncLifetime +{ + HttpContext? _upperCase; + HttpContext? _exactPrefix; + HttpContext? _trailingSeparator; + HttpContext? _longerSegment; + HttpContext? _nestedUnderAnotherPath; + HttpContext? _encodedSeparator; + HttpContext? _doubleEncodedDot; + HttpContext? _dotSegment; + HttpContext? _backslash; + HttpContext? _backslashAfterThePrefix; + HttpContext? _parentSegmentWithPathParameter; + HttpContext? _currentSegmentWithPathParameter; + HttpContext? _pathParameterOnAnOrdinarySegment; + + public async Task InitializeAsync() + { + harness.Origin.Clear(); + + _upperCase = await Send("/MCP/upper-case"); + _exactPrefix = await Send(BearerRouteHarness.RoutePrefix); + _trailingSeparator = await Send($"{BearerRouteHarness.RoutePrefix}/"); + _longerSegment = await Send("/mcpx/tools"); + _nestedUnderAnotherPath = await Send("/api/mcp/tools"); + _encodedSeparator = await Send("/mcp/..%2Fapi/items"); + _doubleEncodedDot = await Send("/mcp/%2E%2E/api/items"); + _dotSegment = await Send("/mcp/../api/items"); + _backslash = await Send("/mcp/tools\\..\\..\\api\\items"); + _backslashAfterThePrefix = await Send("/mcp\\..\\api\\items"); + _parentSegmentWithPathParameter = await Send("/mcp/..;/api/items"); + _currentSegmentWithPathParameter = await Send("/mcp/.;/x"); + _pathParameterOnAnOrdinarySegment = await Send("/mcp/tools;jsessionid=abc"); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] public void should_match_the_prefix_case_insensitively() => Assert.Equal(StatusCodes.Status200OK, _upperCase!.Response.StatusCode); + [Fact] public void should_forward_the_case_insensitive_match() => Assert.True(harness.Origin.ReceivedAnythingFor("/MCP/upper-case")); + [Fact] public void should_match_the_prefix_itself() => Assert.Equal(StatusCodes.Status200OK, _exactPrefix!.Response.StatusCode); + [Fact] public void should_match_the_prefix_with_a_trailing_separator() => Assert.Equal(StatusCodes.Status200OK, _trailingSeparator!.Response.StatusCode); + [Fact] public void should_not_match_a_longer_segment() => Assert.Equal(StatusCodes.Status401Unauthorized, _longerSegment!.Response.StatusCode); + [Fact] public void should_refuse_the_token_off_its_routes_on_a_longer_segment() => Assert.Equal("Bearer error=\"invalid_token\"", _longerSegment!.Response.Headers.WWWAuthenticate.ToString()); + [Fact] public void should_not_match_the_prefix_under_another_path() => Assert.Equal(StatusCodes.Status401Unauthorized, _nestedUnderAnotherPath!.Response.StatusCode); + [Fact] public void should_refuse_an_encoded_separator() => Assert.Equal(StatusCodes.Status400BadRequest, _encodedSeparator!.Response.StatusCode); + [Fact] public void should_not_let_the_refusal_be_cached() => Assert.Equal("no-store", _encodedSeparator!.Response.Headers.CacheControl.ToString()); + [Fact] public void should_refuse_without_a_challenge() => Assert.False(_encodedSeparator!.Response.Headers.ContainsKey("WWW-Authenticate")); + [Fact] public void should_refuse_remaining_percent_encoding() => Assert.Equal(StatusCodes.Status400BadRequest, _doubleEncodedDot!.Response.StatusCode); + [Fact] public void should_refuse_a_dot_segment() => Assert.Equal(StatusCodes.Status400BadRequest, _dotSegment!.Response.StatusCode); + [Fact] public void should_refuse_a_backslash() => Assert.Equal(StatusCodes.Status400BadRequest, _backslash!.Response.StatusCode); + [Fact] public void should_refuse_a_backslash_before_matching_the_prefix() => Assert.Equal(StatusCodes.Status400BadRequest, _backslashAfterThePrefix!.Response.StatusCode); + [Fact] public void should_refuse_a_parent_segment_hidden_by_a_path_parameter() => Assert.Equal(StatusCodes.Status400BadRequest, _parentSegmentWithPathParameter!.Response.StatusCode); + [Fact] public void should_refuse_a_current_segment_hidden_by_a_path_parameter() => Assert.Equal(StatusCodes.Status400BadRequest, _currentSegmentWithPathParameter!.Response.StatusCode); + [Fact] public void should_refuse_a_path_parameter_on_any_segment() => Assert.Equal(StatusCodes.Status400BadRequest, _pathParameterOnAnOrdinarySegment!.Response.StatusCode); + [Fact] public void should_forward_none_of_the_path_parameter_paths() => Assert.DoesNotContain(harness.Origin.Received, _ => _.Path.Contains(';', StringComparison.Ordinal)); + [Fact] public void should_forward_none_of_the_refused_paths() => Assert.DoesNotContain(harness.Origin.Received, _ => _.Path.Contains("api", StringComparison.OrdinalIgnoreCase) || _.Path.Contains("mcpx", StringComparison.OrdinalIgnoreCase)); + + Task Send(string path) + { + var token = harness.Issuer.Token(); + return harness.Server.SendAsync(context => + { + context.Request.Method = HttpMethods.Get; + context.Request.Path = new PathString(path); + context.Request.Headers.Authorization = $"Bearer {token}"; + }); + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_claims_are_mapped.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_claims_are_mapped.cs new file mode 100644 index 00000000..c2a5096d --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_claims_are_mapped.cs @@ -0,0 +1,67 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Identity; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Mapped identity fields are single-valued and cannot be overridden by a surviving case variant. +/// +/// The proxy mapping the GitHub identity. +[Collection(BearerRouteSpecCollection.Name)] +public class when_identity_claims_are_mapped(BearerRouteHarness harness) +{ + [Theory] + [InlineData("github_id")] + [InlineData("github_login")] + public async Task should_refuse_multiple_values_for_a_mapped_identity_field(string source) + { + using var client = harness.CreateBearerClient(); + var path = $"/mcp/multiple-{source}"; + harness.Origin.Clear(); + using var response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, path, harness.Issuer.Token(new Dictionary + { + [source] = new[] { "one", "two" }, + }))); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Contains("invalid_token", response.Headers.WwwAuthenticate.ToString(), StringComparison.Ordinal); + Assert.False(harness.Origin.ReceivedAnythingFor(path)); + } + + [Fact] + public async Task should_replace_all_case_variants_of_mapped_identity_claims() + { + using var client = harness.CreateBearerClient(); + const string path = "/mcp/case-variant-subject"; + using var response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, path, harness.Issuer.Token(new Dictionary + { + ["SUB"] = "another-user", + ["PREFERRED_USERNAME"] = "another-name", + }))); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var forwarded = harness.Origin.LastRequestTo(path)!; + Assert.Equal(BearerRouteHarness.GitHubId, forwarded.Value(Headers.PrincipalId)); + Assert.Equal(BearerRouteHarness.GitHubLogin, forwarded.Value(Headers.PrincipalName)); + Assert.True(ClientPrincipal.TryFromBase64(forwarded.Value(Headers.Principal), out var principal)); + Assert.Single(principal!.Claims, _ => string.Equals(_.Type, "sub", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(principal.Claims, _ => _.Type == "SUB" || _.Type == "PREFERRED_USERNAME"); + } + + [Fact] + public async Task should_read_unmapped_jwt_identity_fields_with_ordinal_comparison() + { + using var client = harness.CreateBearerClient(); + const string path = "/v1/case-variant-subject"; + var token = harness.Issuer.TokenShaped(_ => _.Claims = new Dictionary + { + ["SUB"] = "another-user", + ["PREFERRED_USERNAME"] = "another-name", + }.Concat(StubIssuer.DefaultClaims()).ToDictionary(_ => _.Key, _ => _.Value)); + using var response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, path, token)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var forwarded = harness.Origin.LastRequestTo(path)!; + Assert.Equal(BearerRouteHarness.AccountId, forwarded.Value(Headers.PrincipalId)); + Assert.Equal(BearerRouteHarness.GitHubLogin, forwarded.Value(Headers.PrincipalName)); + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_bearer_route.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_bearer_route.cs new file mode 100644 index 00000000..d834878d --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_bearer_route.cs @@ -0,0 +1,54 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A caller with a valid token cannot add to what the token says: every inbound copy of a header the backend +/// trusts is replaced by what AuthProxy vouches for, and one the token does not supply is not forwarded at all. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_identity_headers_are_spoofed_on_a_bearer_route(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + ForwardedRequest? _forwarded; + string _forgedPrincipal = string.Empty; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _forgedPrincipal = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes( + /*lang=json,strict*/ """{"userId":"attacker","userRoles":["Administrator"]}""")); + + var request = BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token(without: "azp")); + request.Headers.TryAddWithoutValidation(Headers.Principal, _forgedPrincipal); + request.Headers.TryAddWithoutValidation(Headers.PrincipalId, "attacker"); + request.Headers.TryAddWithoutValidation(Headers.PrincipalName, "attacker"); + request.Headers.TryAddWithoutValidation(Headers.PrincipalNameExtended, "UTF-8''attacker"); + request.Headers.TryAddWithoutValidation(Headers.TenantId, "victim-tenant"); + request.Headers.TryAddWithoutValidation(Headers.LegacyTenantId, "another-victim-tenant"); + request.Headers.TryAddWithoutValidation("x-ms-client-principal-idp", "forged-provider"); + request.Headers.TryAddWithoutValidation(Headers.TokenScope, "direct:admin"); + request.Headers.TryAddWithoutValidation(Headers.TokenClientId, "trusted-client"); + + using var response = await client.SendAsync(request); + _forwarded = harness.Origin.LastRequestTo(Path); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] public void should_forward_the_request() => Assert.NotNull(_forwarded); + [Fact] public void should_replace_the_principal() => Assert.NotEqual(_forgedPrincipal, _forwarded!.Value(Headers.Principal)); + [Fact] public void should_replace_the_principal_id() => Assert.Equal(BearerRouteHarness.GitHubId, _forwarded!.Value(Headers.PrincipalId)); + [Fact] public void should_replace_the_principal_name() => Assert.Equal(BearerRouteHarness.GitHubLogin, _forwarded!.Value(Headers.PrincipalName)); + [Fact] public void should_drop_the_extended_principal_name() => Assert.False(_forwarded!.Has(Headers.PrincipalNameExtended)); + [Fact] public void should_replace_the_tenant() => Assert.Equal(BearerRouteHarness.TenantId, _forwarded!.Value(Headers.TenantId)); + [Fact] public void should_replace_the_legacy_tenant() => Assert.Equal(BearerRouteHarness.TenantId, _forwarded!.Value(Headers.LegacyTenantId)); + [Fact] public void should_drop_a_principal_header_the_proxy_never_writes() => Assert.False(_forwarded!.Has("x-ms-client-principal-idp")); + [Fact] public void should_replace_the_scopes() => Assert.Equal("direct:read direct:work", _forwarded!.Value(Headers.TokenScope)); + [Fact] public void should_take_the_client_from_the_token_alone() => Assert.Equal(BearerRouteHarness.ClientId, _forwarded!.Value(Headers.TokenClientId)); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_browser_route.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_browser_route.cs new file mode 100644 index 00000000..dec97d6f --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_identity_headers_are_spoofed_on_a_browser_route.cs @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// The token headers mean "authenticated on a bearer route", so only a bearer route may send them. On a browser +/// route a caller's own copies are removed with every other identity header, and the principal forwarded is the +/// session's. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_identity_headers_are_spoofed_on_a_browser_route(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = "/api/spoofed"; + + HttpResponseMessage? _response; + ForwardedRequest? _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + var request = SecurityHarness.Authenticated(HttpMethod.Get, Path, "browser-user"); + request.Headers.TryAddWithoutValidation(Headers.TokenScope, "direct:admin"); + request.Headers.TryAddWithoutValidation(Headers.TokenClientId, "trusted-client"); + request.Headers.TryAddWithoutValidation(Headers.PrincipalId, "attacker"); + request.Headers.TryAddWithoutValidation(Headers.TenantId, "victim-tenant"); + + _response = await client.SendAsync(request); + _forwarded = harness.Origin.LastRequestTo(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_forward_the_request() => Assert.NotNull(_forwarded); + [Fact] public void should_not_forward_the_scope_header() => Assert.False(_forwarded!.Has(Headers.TokenScope)); + [Fact] public void should_not_forward_the_client_header() => Assert.False(_forwarded!.Has(Headers.TokenClientId)); + [Fact] public void should_forward_the_session_principal() => Assert.Equal("browser-user", _forwarded!.Value(Headers.PrincipalId)); + [Fact] public void should_forward_the_session_tenant() => Assert.Equal(BearerRouteHarness.SessionTenantId, _forwarded!.Value(Headers.TenantId)); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_no_token_is_presented.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_no_token_is_presented.cs new file mode 100644 index 00000000..5e10aa3e --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_no_token_is_presented.cs @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A bearer route is called by programs. Without a token the answer is an RFC 6750 challenge naming the RFC 9728 +/// resource metadata an MCP client discovers its authorization server from — never a redirect to provider +/// selection, and never a browser session standing in for the missing token. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_no_token_is_presented(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _anonymous; + HttpResponseMessage? _withSession; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _anonymous = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, Path)); + _withSession = await client.SendAsync(SecurityHarness.Authenticated(HttpMethod.Get, Path)); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _anonymous?.Dispose(); + _withSession?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_as_unauthorized() => Assert.Equal(HttpStatusCode.Unauthorized, _anonymous!.StatusCode); + [Fact] public void should_challenge_with_the_resource_metadata() => + Assert.Equal($"Bearer resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", _anonymous!.Headers.WwwAuthenticate.ToString()); + [Fact] public void should_not_let_a_browser_session_stand_in_for_the_token() => Assert.Equal(HttpStatusCode.Unauthorized, _withSession!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_not_be_cached() => Assert.True(_anonymous!.Headers.CacheControl?.NoStore); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_a_stricter_route.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_a_stricter_route.cs new file mode 100644 index 00000000..e2ed81f1 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_a_stricter_route.cs @@ -0,0 +1,47 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +public class when_repeated_separators_hide_a_stricter_route +{ + [Theory] + [InlineData("/v1//admin")] + [InlineData("/v1///admin/tools")] + public async Task should_not_forward_a_broadly_authorized_token_to_a_normalizing_backend(string path) + { + await using var harness = new NestedRoutesHarness(); + using var client = harness.CreateBearerClient(); + var token = harness.Issuer.Token(); + + // Demonstrate that this backend resolves the ambiguous path to the stricter route. + using var origin = new HttpClient(); + using var direct = await origin.GetAsync($"{harness.Origin.BaseUrl.TrimEnd('/')}{path}"); + Assert.Equal(HttpStatusCode.OK, direct.StatusCode); + Assert.True(harness.Origin.ReceivedAnythingFor(path.Replace("///", "/", StringComparison.Ordinal).Replace("//", "/", StringComparison.Ordinal))); + harness.Origin.Clear(); + + using var broad = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/v1/items", token)); + Assert.Equal(HttpStatusCode.OK, broad.StatusCode); + using var narrow = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/v1/admin", token)); + Assert.Equal(HttpStatusCode.Forbidden, narrow.StatusCode); + harness.Origin.Clear(); + + using var ambiguous = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, path, token)); + Assert.Equal(HttpStatusCode.BadRequest, ambiguous.StatusCode); + Assert.Equal("no-store", ambiguous.Headers.CacheControl?.ToString()); + Assert.Empty(harness.Origin.Received); + } + + sealed class NestedRoutesHarness() : BearerRouteHarness(normalizeRepeatedSeparators: true) + { + protected override void AddSettings(IDictionary settings) + { + const string route = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:3"; + settings[$"{route}:PathPrefix"] = "/v1/admin"; + settings[$"{route}:Issuers:0:Issuer"] = Issuer.Issuer; + settings[$"{route}:Audiences:0"] = Audience; + settings[$"{route}:RequiredScopes:0"] = "admin:write"; + } + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_the_only_bearer_route.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_the_only_bearer_route.cs new file mode 100644 index 00000000..2dcd745f --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_repeated_separators_hide_the_only_bearer_route.cs @@ -0,0 +1,56 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +public class when_repeated_separators_hide_the_only_bearer_route +{ + [Theory] + [InlineData("/api//mcp/tools")] + [InlineData("/api///mcp/tools")] + public async Task should_not_forward_a_browser_session_to_a_normalizing_backend(string path) + { + await using var harness = new SingleRouteHarness(); + using var client = harness.CreateBearerClient(); + + // This origin serves the bearer resource when it collapses the repeated separators. + using var origin = new HttpClient(); + using var direct = await origin.GetAsync($"{harness.Origin.BaseUrl.TrimEnd('/')}{path}"); + Assert.Equal(HttpStatusCode.OK, direct.StatusCode); + Assert.True(harness.Origin.ReceivedAnythingFor("/api/mcp/tools")); + harness.Origin.Clear(); + + using var browserRequest = SecurityHarness.Authenticated(HttpMethod.Get, "/api/items", "browser-user"); + using var browser = await client.SendAsync(browserRequest); + Assert.Equal(HttpStatusCode.OK, browser.StatusCode); + Assert.NotNull(harness.Origin.LastRequestTo("/api/items")); + + using var canonicalRequest = SecurityHarness.Authenticated(HttpMethod.Get, "/api/mcp/tools", "browser-user"); + using var canonical = await client.SendAsync(canonicalRequest); + Assert.Equal(HttpStatusCode.Unauthorized, canonical.StatusCode); + harness.Origin.Clear(); + + using var request = SecurityHarness.Authenticated(HttpMethod.Get, path, "browser-user"); + using var response = await client.SendAsync(request); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("no-store", response.Headers.CacheControl?.ToString()); + Assert.Empty(harness.Origin.Received); + } + + sealed class SingleRouteHarness() : BearerRouteHarness(normalizeRepeatedSeparators: true) + { + protected override void AddSettings(IDictionary settings) + { + const string routes = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes"; + foreach (var key in settings.Keys.Where(_ => _.StartsWith($"{routes}:1:", StringComparison.Ordinal) + || _.StartsWith($"{routes}:2:", StringComparison.Ordinal)).ToArray()) + { + settings.Remove(key); + } + + settings[$"{routes}:0:PathPrefix"] = "/api/mcp"; + settings[$"{routes}:0:RequiredClaims:0:Claim"] = "membership"; + settings[$"{routes}:0:RequiredClaims:0:AnyOf:0"] = "allowed"; + } + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_claims.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_claims.cs new file mode 100644 index 00000000..af46bf2b --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_claims.cs @@ -0,0 +1,76 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A deployment's claim requirements apply to every forwarded request, and a bearer route is no exception: a valid +/// token whose principal does not satisfy them is refused with a bare 403 and never forwarded. +/// They are checked against the principal the backend would receive — after the route's claim mappings — so a +/// token cannot satisfy a requirement with a raw claim the mapping replaces. +/// +/// The running proxy, with claim requirements declared. +[Collection(GatedBearerRouteSpecCollection.Name)] +public class when_the_deployment_requires_claims(GatedBearerRouteHarness harness) : IAsyncLifetime +{ + const string QualifiedPath = $"{BearerRouteHarness.RoutePrefix}/qualified"; + const string WithoutOrganizationPath = $"{BearerRouteHarness.RoutePrefix}/without-organization"; + const string OtherOrganizationPath = $"{BearerRouteHarness.RoutePrefix}/other-organization"; + const string MappedPath = $"{BearerRouteHarness.RoutePrefix}/mapped"; + const string UnmappedPath = $"{BearerRouteHarness.RoutePrefix}/unmapped"; + + HttpResponseMessage? _qualified; + HttpResponseMessage? _withoutOrganization; + HttpResponseMessage? _otherOrganization; + HttpResponseMessage? _mapped; + HttpResponseMessage? _unmapped; + HttpResponseMessage? _caseVariant; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + var organization = new Dictionary { [GatedBearerRouteHarness.RequiredClaim] = GatedBearerRouteHarness.RequiredValue }; + + _qualified = await Send(client, QualifiedPath, harness.Issuer.Token(organization)); + _withoutOrganization = await Send(client, WithoutOrganizationPath, harness.Issuer.Token()); + _otherOrganization = await Send(client, OtherOrganizationPath, harness.Issuer.Token(new Dictionary { [GatedBearerRouteHarness.RequiredClaim] = "Elsewhere" })); + + // The token's own preferred_username is someone else; the route maps it from github_login. + _mapped = await Send(client, MappedPath, harness.Issuer.Token(new Dictionary(organization) { ["preferred_username"] = "someone-else" })); + + // The token's own preferred_username qualifies, but the route replaces it with github_login. + _unmapped = await Send(client, UnmappedPath, harness.Issuer.Token(new Dictionary(organization) { ["github_login"] = "mallory" })); + _caseVariant = await Send(client, $"{UnmappedPath}-case-variant", harness.Issuer.Token(new Dictionary(organization) + { + ["github_login"] = "mallory", + ["PREFERRED_USERNAME"] = BearerRouteHarness.GitHubLogin, + })); + } + + public Task DisposeAsync() + { + _qualified?.Dispose(); + _withoutOrganization?.Dispose(); + _otherOrganization?.Dispose(); + _mapped?.Dispose(); + _unmapped?.Dispose(); + _caseVariant?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_forward_a_token_satisfying_every_requirement() => Assert.Equal(HttpStatusCode.OK, _qualified!.StatusCode); + [Fact] public void should_refuse_a_token_without_the_required_claim() => Assert.Equal(HttpStatusCode.Forbidden, _withoutOrganization!.StatusCode); + [Fact] public void should_refuse_a_token_with_an_unaccepted_value() => Assert.Equal(HttpStatusCode.Forbidden, _otherOrganization!.StatusCode); + [Fact] public void should_refuse_without_a_challenge() => Assert.Empty(_withoutOrganization!.Headers.WwwAuthenticate); + [Fact] public void should_not_forward_a_refused_token() => Assert.False(harness.Origin.ReceivedAnythingFor(WithoutOrganizationPath) || harness.Origin.ReceivedAnythingFor(OtherOrganizationPath)); + [Fact] public void should_check_the_service_requirement_after_the_claim_mappings() => Assert.Equal(HttpStatusCode.OK, _mapped!.StatusCode); + [Fact] public void should_not_let_a_mapped_away_claim_satisfy_the_service_requirement() => Assert.Equal(HttpStatusCode.Forbidden, _unmapped!.StatusCode); + + [Fact] public void should_not_let_a_case_variant_of_a_mapped_claim_satisfy_the_service_requirement() => Assert.Equal(HttpStatusCode.Forbidden, _caseVariant!.StatusCode); + [Fact] public void should_not_forward_a_token_qualifying_only_through_a_case_variant() => Assert.False(harness.Origin.ReceivedAnythingFor($"{UnmappedPath}-case-variant")); + + static Task Send(HttpClient client, string path, string token) => + client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, path, token)); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_identity_verification.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_identity_verification.cs new file mode 100644 index 00000000..092d29a2 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_deployment_requires_identity_verification.cs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A bearer route has no browser session to verify through /.cratis/me. In a deployment that +/// requires identity verification it serves requests only because the route says it accepts callers without it, and +/// it then forwards without asking the backend for a verdict. +/// +/// The running proxy, with identity verification required. +[Collection(GatedBearerRouteSpecCollection.Name)] +public class when_the_deployment_requires_identity_verification(GatedBearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _verified; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken( + HttpMethod.Get, + Path, + harness.Issuer.Token(new Dictionary { [GatedBearerRouteHarness.RequiredClaim] = GatedBearerRouteHarness.RequiredValue }))); + _verified = harness.Origin.ReceivedAnythingFor(WellKnownPaths.IdentityDetails); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_forward_the_request() => Assert.Equal(HttpStatusCode.OK, _response!.StatusCode); + [Fact] public void should_not_ask_the_backend_for_a_verdict() => Assert.False(_verified); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_cannot_be_reached.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_cannot_be_reached.cs new file mode 100644 index 00000000..8e5dc413 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_cannot_be_reached.cs @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token that cannot be checked is not a token that is wrong. When the issuer's metadata and keys cannot be +/// retrieved the request is refused with 503 and a Retry-After, never forwarded, and +/// not answered with an invalid_token challenge that would send the client to sign in again. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_issuer_cannot_be_reached(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.UnreachableRoutePrefix}/items"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken( + HttpMethod.Get, + Path, + harness.Issuer.Token(issuer: BearerRouteHarness.UnreachableIssuer))); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_answer_service_unavailable() => Assert.Equal(HttpStatusCode.ServiceUnavailable, _response!.StatusCode); + [Fact] public void should_ask_the_client_to_retry_later() => Assert.Equal(TimeSpan.FromSeconds(30), _response!.Headers.RetryAfter?.Delta); + [Fact] public void should_not_challenge() => Assert.Empty(_response!.Headers.WwwAuthenticate); + [Fact] public void should_not_be_cached() => Assert.True(_response!.Headers.CacheControl?.NoStore); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_is_slow_to_refresh.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_is_slow_to_refresh.cs new file mode 100644 index 00000000..95e9f630 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_is_slow_to_refresh.cs @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Known-key tokens keep working while another request awaits a slow issuer's refresh response. +/// +public class when_the_issuer_is_slow_to_refresh +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task should_answer_known_key_requests_before_the_refresh_finishes(bool unavailable) + { + await using var harness = new BearerRouteHarness(); + using var client = harness.CreateBearerClient(); + var knownToken = harness.Issuer.Token(); + using var initial = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/initial", knownToken)); + Assert.Equal(HttpStatusCode.OK, initial.StatusCode); + + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + harness.Issuer.RotateKey(); + harness.Issuer.MetadataUnavailable = unavailable; + harness.Issuer.BeforeMetadataResponse = () => + { + started.TrySetResult(); + return release.Task; + }; + var refresh = client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/refresh", harness.Issuer.Token())); + try + { + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + using var known = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/known-during-refresh", knownToken)) + .WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(HttpStatusCode.OK, known.StatusCode); + Assert.True(harness.Origin.ReceivedAnythingFor("/mcp/known-during-refresh")); + Assert.False(refresh.IsCompleted); + } + finally + { + release.TrySetResult(); + using var refreshed = await refresh.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(unavailable ? HttpStatusCode.Unauthorized : HttpStatusCode.OK, refreshed.StatusCode); + } + + Assert.Equal(2, harness.Issuer.MetadataRequests); + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_rotates_its_key.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_rotates_its_key.cs new file mode 100644 index 00000000..cfd88fd1 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_issuer_rotates_its_key.cs @@ -0,0 +1,50 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token naming the issuer's new key succeeds on its first request after the old keys were cached. +/// +public class when_the_issuer_rotates_its_key +{ + [Fact] + public async Task should_refresh_before_retrying_the_first_rotated_token() + { + await using var harness = new BearerRouteHarness(); + using var client = harness.CreateBearerClient(); + using var initial = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/before-rotation", harness.Issuer.Token())); + Assert.Equal(HttpStatusCode.OK, initial.StatusCode); + + harness.Issuer.RotateKey(); + using var rotated = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/after-rotation", harness.Issuer.Token())); + Assert.Equal(HttpStatusCode.OK, rotated.StatusCode); + Assert.True(harness.Origin.ReceivedAnythingFor("/mcp/after-rotation")); + Assert.Equal(2, harness.Issuer.MetadataRequests); + + harness.Issuer.RotateKey(); + using var throttled = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/second-rotation", harness.Issuer.Token())); + Assert.Equal(HttpStatusCode.Unauthorized, throttled.StatusCode); + Assert.Equal(2, harness.Issuer.MetadataRequests); + } + + [Fact] + public async Task should_keep_known_keys_and_back_off_when_a_refresh_fails() + { + await using var harness = new BearerRouteHarness(); + using var client = harness.CreateBearerClient(); + var knownToken = harness.Issuer.Token(); + using var initial = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/known-key", knownToken)); + Assert.Equal(HttpStatusCode.OK, initial.StatusCode); + + harness.Issuer.RotateKey(); + harness.Issuer.MetadataUnavailable = true; + using var unknown = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/unknown-key", harness.Issuer.Token())); + Assert.Equal(HttpStatusCode.Unauthorized, unknown.StatusCode); + using var known = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/known-key-during-outage", knownToken)); + Assert.Equal(HttpStatusCode.OK, known.StatusCode); + using var retry = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, "/mcp/retry-unknown-key", harness.Issuer.Token())); + Assert.Equal(HttpStatusCode.Unauthorized, retry.StatusCode); + Assert.Equal(2, harness.Issuer.MetadataRequests); + } +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested.cs new file mode 100644 index 00000000..30bd3096 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested.cs @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// The protected-resource metadata document is how a client learns which authorization server to get a token +/// from, so it is served before the client has one: it passes through to the backend without authentication, +/// and without any identity, spoofed or otherwise. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_resource_metadata_is_requested(BearerRouteHarness harness) : IAsyncLifetime +{ + HttpResponseMessage? _response; + HttpResponseMessage? _post; + ForwardedRequest? _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + var request = new HttpRequestMessage(HttpMethod.Get, BearerRouteHarness.ResourceMetadataPath); + request.Headers.TryAddWithoutValidation(Headers.PrincipalId, "attacker"); + request.Headers.TryAddWithoutValidation(Headers.TenantId, "victim-tenant"); + _response = await client.SendAsync(request); + _forwarded = harness.Origin.LastRequestTo(BearerRouteHarness.ResourceMetadataPath); + + _post = await client.SendAsync(new HttpRequestMessage(HttpMethod.Post, BearerRouteHarness.ResourceMetadataPath)); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + _post?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_answer_with_the_origin_response() => Assert.Equal(HttpStatusCode.OK, _response!.StatusCode); + [Fact] public void should_forward_it() => Assert.NotNull(_forwarded); + [Fact] public void should_forward_no_principal() => Assert.False(_forwarded!.Has(Headers.PrincipalId)); + [Fact] public void should_forward_no_tenant() => Assert.False(_forwarded!.Has(Headers.TenantId)); + [Fact] public void should_only_serve_reads() => Assert.Equal(HttpStatusCode.MethodNotAllowed, _post!.StatusCode); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested_otherwise.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested_otherwise.cs new file mode 100644 index 00000000..6f7772db --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_resource_metadata_is_requested_otherwise.cs @@ -0,0 +1,77 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// The protected-resource metadata path is the one path of a bearer route served without a token, so it is exactly +/// that path and nothing more: read with GET or HEAD, matched case-insensitively and +/// with or without a trailing separator, and every other method refused with 405 without reaching +/// the backend. Nothing beneath the path is served without a session. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_resource_metadata_is_requested_otherwise(BearerRouteHarness harness) : IAsyncLifetime +{ + const string UpperCasePath = "/.WELL-KNOWN/oauth-protected-resource/MCP"; + const string BeneathPath = $"{BearerRouteHarness.ResourceMetadataPath}/beneath"; + + HttpResponseMessage? _head; + HttpResponseMessage? _put; + HttpResponseMessage? _delete; + HttpResponseMessage? _options; + HttpResponseMessage? _upperCase; + HttpResponseMessage? _trailingSeparator; + HttpResponseMessage? _beneath; + bool _headForwarded; + bool _writesForwarded; + bool _beneathForwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _head = await client.SendAsync(new HttpRequestMessage(HttpMethod.Head, BearerRouteHarness.ResourceMetadataPath)); + _headForwarded = harness.Origin.ReceivedAnythingFor(BearerRouteHarness.ResourceMetadataPath); + + harness.Origin.Clear(); + _put = await client.SendAsync(new HttpRequestMessage(HttpMethod.Put, BearerRouteHarness.ResourceMetadataPath)); + _delete = await client.SendAsync(new HttpRequestMessage(HttpMethod.Delete, BearerRouteHarness.ResourceMetadataPath)); + _options = await client.SendAsync(new HttpRequestMessage(HttpMethod.Options, BearerRouteHarness.ResourceMetadataPath)); + _writesForwarded = harness.Origin.ReceivedAnythingFor(BearerRouteHarness.ResourceMetadataPath); + + _upperCase = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, UpperCasePath)); + _trailingSeparator = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, $"{BearerRouteHarness.ResourceMetadataPath}/")); + + _beneath = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, BeneathPath)); + _beneathForwarded = harness.Origin.ReceivedAnythingFor(BeneathPath); + } + + public Task DisposeAsync() + { + _head?.Dispose(); + _put?.Dispose(); + _delete?.Dispose(); + _options?.Dispose(); + _upperCase?.Dispose(); + _trailingSeparator?.Dispose(); + _beneath?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_serve_head() => Assert.Equal(HttpStatusCode.OK, _head!.StatusCode); + [Fact] public void should_forward_head() => Assert.True(_headForwarded); + [Fact] public void should_refuse_put() => Assert.Equal(HttpStatusCode.MethodNotAllowed, _put!.StatusCode); + [Fact] public void should_refuse_delete() => Assert.Equal(HttpStatusCode.MethodNotAllowed, _delete!.StatusCode); + [Fact] public void should_refuse_options() => Assert.Equal(HttpStatusCode.MethodNotAllowed, _options!.StatusCode); + [Fact] public void should_name_the_methods_it_allows() => Assert.Equal("GET, HEAD", _put!.Content.Headers.Allow.Count > 0 ? string.Join(", ", _put.Content.Headers.Allow) : string.Empty); + [Fact] public void should_not_cache_put_refusals() => Assert.True(_put!.Headers.CacheControl?.NoStore); + [Fact] public void should_not_cache_delete_refusals() => Assert.True(_delete!.Headers.CacheControl?.NoStore); + [Fact] public void should_not_cache_options_refusals() => Assert.True(_options!.Headers.CacheControl?.NoStore); + [Fact] public void should_not_forward_other_methods() => Assert.False(_writesForwarded); + [Fact] public void should_match_case_insensitively() => Assert.Equal(HttpStatusCode.OK, _upperCase!.StatusCode); + [Fact] public void should_match_with_a_trailing_separator() => Assert.Equal(HttpStatusCode.OK, _trailingSeparator!.StatusCode); + [Fact] public void should_not_serve_anything_beneath_it_without_a_session() => Assert.False(_beneathForwarded); + [Fact] public void should_not_answer_beneath_it_with_success() => Assert.NotEqual(HttpStatusCode.OK, _beneath!.StatusCode); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_route_forwards_the_authorization_header.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_route_forwards_the_authorization_header.cs new file mode 100644 index 00000000..619533a9 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_route_forwards_the_authorization_header.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A route that sets ForwardAuthorizationHeader sends the token on to the backend alongside the +/// principal AuthProxy vouches for. It is the only thing that changes: cookies still stay at the edge. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_route_forwards_the_authorization_header(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.ForwardingRoutePrefix}/items"; + + string _token = string.Empty; + ForwardedRequest? _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _token = harness.Issuer.Token(); + var request = BearerRouteHarness.WithToken(HttpMethod.Get, Path, _token); + request.Headers.TryAddWithoutValidation("Cookie", ".cratis-session=some-session"); + + using var response = await client.SendAsync(request); + _forwarded = harness.Origin.LastRequestTo(Path); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] public void should_forward_the_request() => Assert.NotNull(_forwarded); + [Fact] public void should_forward_the_token() => Assert.Equal($"Bearer {_token}", _forwarded!.Value("Authorization")); + [Fact] public void should_still_forward_the_principal() => Assert.Equal(BearerRouteHarness.AccountId, _forwarded!.Value(Headers.PrincipalId)); + [Fact] public void should_not_forward_the_cookie() => Assert.False(_forwarded!.Has("Cookie")); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_carries_claims_it_may_not_forward.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_carries_claims_it_may_not_forward.cs new file mode 100644 index 00000000..3604b1e7 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_carries_claims_it_may_not_forward.cs @@ -0,0 +1,59 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Identity; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token cannot grant a role or speak in AuthProxy's own claim namespaces: its role claims are dropped, the only +/// roles forwarded are anonymous and authenticated, and urn:cratis:bearer: +/// and urn:cratis:identity: claims carry only what AuthProxy itself wrote. A token that names no client +/// forwards no client header, whatever the caller sent. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_carries_claims_it_may_not_forward(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/claims"; + + ForwardedRequest? _forwarded; + ClientPrincipal? _principal; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + var token = harness.Issuer.Token( + new Dictionary + { + ["roles"] = new[] { "Administrator" }, + ["role"] = "Administrator", + ["Roles"] = "Administrator", + ["HTTP://SCHEMAS.MICROSOFT.COM/WS/2008/06/IDENTITY/CLAIMS/ROLE"] = "Administrator", + ["urn:cratis:bearer:scope"] = "direct:admin", + ["urn:cratis:bearer:client-id"] = "trusted-client", + ["urn:cratis:identity:subject"] = "someone-else", + }, + without: ["azp", "client_id"]); + + var request = BearerRouteHarness.WithToken(HttpMethod.Get, Path, token); + request.Headers.TryAddWithoutValidation(Headers.TokenClientId, "trusted-client"); + + using var response = await client.SendAsync(request); + _forwarded = harness.Origin.LastRequestTo(Path); + ClientPrincipal.TryFromBase64(_forwarded?.Value(Headers.Principal), out _principal); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] public void should_forward_the_request() => Assert.NotNull(_principal); + [Fact] public void should_grant_no_role() => Assert.Equal(["anonymous", "authenticated"], _principal!.UserRoles); + [Fact] public void should_drop_the_role_claims() => Assert.DoesNotContain(_principal!.Claims, _ => string.Equals(_.Type, "roles", StringComparison.OrdinalIgnoreCase) || string.Equals(_.Type, "role", StringComparison.OrdinalIgnoreCase) || _.Type.EndsWith("/role", StringComparison.OrdinalIgnoreCase)); + [Fact] public void should_forward_only_the_scopes_it_validated() => + Assert.Equal(["direct:read", "direct:work"], _principal!.Claims.Where(_ => _.Type == "urn:cratis:bearer:scope").Select(_ => _.Value).Order()); + [Fact] public void should_forward_no_client_claim() => Assert.DoesNotContain(_principal!.Claims, _ => _.Type == "urn:cratis:bearer:client-id"); + [Fact] public void should_drop_the_canonical_identity_claims() => Assert.DoesNotContain(_principal!.Claims, _ => _.Type.StartsWith("urn:cratis:identity:", StringComparison.Ordinal)); + [Fact] public void should_forward_no_client_header() => Assert.False(_forwarded!.Has(Headers.TokenClientId)); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_expired.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_expired.cs new file mode 100644 index 00000000..0b030a93 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_expired.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// An expired token is refused; the route allows only a small clock skew, not the lifetime of a session. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_has_expired(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token(expires: DateTime.UtcNow.AddMinutes(-10)))); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_challenge_with_invalid_token() => + Assert.Equal($"Bearer error=\"invalid_token\", resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", _response!.Headers.WwwAuthenticate.ToString()); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_no_tenant.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_no_tenant.cs new file mode 100644 index 00000000..0ef4dbd3 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_has_no_tenant.cs @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// The tenant comes from the token and from nowhere else. A token without tid is refused rather than +/// resolved through the browser tenant selection, and never falls back to a default tenant. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_has_no_tenant(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token(without: "tid"))); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Forbidden, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_for_another_audience.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_for_another_audience.cs new file mode 100644 index 00000000..19d71f15 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_for_another_audience.cs @@ -0,0 +1,38 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token issued for another resource server is not a credential here, even from the same issuer: the audience +/// is what binds a token to the resource it was granted for. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_is_for_another_audience(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token(audience: "studio-api"))); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_challenge_with_invalid_token() => + Assert.Equal($"Bearer error=\"invalid_token\", resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", _response!.Headers.WwwAuthenticate.ToString()); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_from_another_issuer.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_from_another_issuer.cs new file mode 100644 index 00000000..f1590d3b --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_from_another_issuer.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token naming an issuer the route does not accept is refused before any key is looked for. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_is_from_another_issuer(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token(issuer: "https://attacker.example.test/"))); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_challenge_with_invalid_token() => + Assert.Equal($"Bearer error=\"invalid_token\", resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", _response!.Headers.WwwAuthenticate.ToString()); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_not_an_acceptable_access_token.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_not_an_acceptable_access_token.cs new file mode 100644 index 00000000..74f57d0f --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_not_an_acceptable_access_token.cs @@ -0,0 +1,72 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Everything the validation rules say about the token's shape holds end to end: an ID token (any +/// typ but an access-token type), an HMAC token keyed with the issuer's public key, a token without an +/// expiry, an encrypted token and a token missing a claim a mapping reads are refused as invalid; a token naming +/// more than one tenant, or a tenant that is not a plain identifier, is refused as forbidden. None is forwarded. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_is_not_an_acceptable_access_token(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/unacceptable"; + + HttpResponseMessage? _idToken; + HttpResponseMessage? _publicKeyAsSecret; + HttpResponseMessage? _withoutExpiry; + HttpResponseMessage? _encrypted; + HttpResponseMessage? _withoutMappedSource; + HttpResponseMessage? _twoTenants; + HttpResponseMessage? _unusableTenant; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _idToken = await Send(client, harness.Issuer.TokenShaped(_ => _.TokenType = JwtConstants.HeaderType)); + _publicKeyAsSecret = await Send(client, harness.Issuer.TokenSignedWithThePublicKeyAsASecret()); + _withoutExpiry = await Send(client, harness.Issuer.TokenShaped(_ => _.Expires = null, setDefaultTimes: false)); + _encrypted = await Send(client, harness.Issuer.TokenShaped(_ => _.EncryptingCredentials = new EncryptingCredentials( + new SymmetricSecurityKey(RandomNumberGenerator.GetBytes(64)), + JwtConstants.DirectKeyUseAlg, + SecurityAlgorithms.Aes256CbcHmacSha512))); + _withoutMappedSource = await Send(client, harness.Issuer.Token(without: "github_id")); + _twoTenants = await Send(client, harness.Issuer.Token(new Dictionary { ["tid"] = new[] { BearerRouteHarness.TenantId, "33333333-3333-3333-3333-333333333333" } })); + _unusableTenant = await Send(client, harness.Issuer.Token(new Dictionary { ["tid"] = "../other tenant" })); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _idToken?.Dispose(); + _publicKeyAsSecret?.Dispose(); + _withoutExpiry?.Dispose(); + _encrypted?.Dispose(); + _withoutMappedSource?.Dispose(); + _twoTenants?.Dispose(); + _unusableTenant?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_an_id_token() => Assert.Equal(HttpStatusCode.Unauthorized, _idToken!.StatusCode); + [Fact] public void should_refuse_an_hmac_token_keyed_with_the_public_key() => Assert.Equal(HttpStatusCode.Unauthorized, _publicKeyAsSecret!.StatusCode); + [Fact] public void should_refuse_a_token_without_an_expiry() => Assert.Equal(HttpStatusCode.Unauthorized, _withoutExpiry!.StatusCode); + [Fact] public void should_refuse_an_encrypted_token() => Assert.Equal(HttpStatusCode.Unauthorized, _encrypted!.StatusCode); + [Fact] public void should_refuse_a_token_missing_a_mapped_claim() => Assert.Equal(HttpStatusCode.Unauthorized, _withoutMappedSource!.StatusCode); + [Fact] public void should_refuse_a_token_naming_two_tenants() => Assert.Equal(HttpStatusCode.Forbidden, _twoTenants!.StatusCode); + [Fact] public void should_refuse_a_tenant_that_is_not_a_plain_identifier() => Assert.Equal(HttpStatusCode.Forbidden, _unusableTenant!.StatusCode); + [Fact] public void should_forward_none_of_them() => Assert.False(_forwarded); + + static Task Send(HttpClient client, string token) => + client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, token)); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_signed_by_another_key.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_signed_by_another_key.cs new file mode 100644 index 00000000..ef2d661f --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_signed_by_another_key.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A token naming the issuer and its key id but signed by a key the issuer does not publish is a forgery. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_is_signed_by_another_key(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.TokenSignedByAnotherKey())); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_challenge_with_invalid_token() => + Assert.Equal($"Bearer error=\"invalid_token\", resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", _response!.Headers.WwwAuthenticate.ToString()); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_unsigned.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_unsigned.cs new file mode 100644 index 00000000..0a5d2bf8 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_is_unsigned.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// An alg: none token carries every claim a valid one does and proves none of them. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_is_unsigned(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.UnsignedToken())); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Unauthorized, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_challenge_with_invalid_token() => + Assert.Equal($"Bearer error=\"invalid_token\", resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", _response!.Headers.WwwAuthenticate.ToString()); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_lacks_a_required_scope.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_lacks_a_required_scope.cs new file mode 100644 index 00000000..b7149a0c --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_lacks_a_required_scope.cs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// A valid token without a scope the route requires is refused with insufficient_scope, naming the +/// scope to ask for, so a client can request a token that has it. +/// +/// The running proxy, origin and issuer. +[Collection(BearerRouteSpecCollection.Name)] +public class when_the_token_lacks_a_required_scope(BearerRouteHarness harness) : IAsyncLifetime +{ + const string Path = $"{BearerRouteHarness.RoutePrefix}/tools"; + + HttpResponseMessage? _response; + bool _forwarded; + + public async Task InitializeAsync() + { + using var client = harness.CreateBearerClient(); + harness.Origin.Clear(); + + _response = await client.SendAsync(BearerRouteHarness.WithToken(HttpMethod.Get, Path, harness.Issuer.Token(new Dictionary { ["scope"] = "direct:work" }))); + _forwarded = harness.Origin.ReceivedAnythingFor(Path); + } + + public Task DisposeAsync() + { + _response?.Dispose(); + return Task.CompletedTask; + } + + [Fact] public void should_refuse_it() => Assert.Equal(HttpStatusCode.Forbidden, _response!.StatusCode); + [Fact] public void should_not_forward_anything() => Assert.False(_forwarded); + [Fact] public void should_challenge_with_insufficient_scope() => + Assert.Equal( + $"Bearer error=\"insufficient_scope\", scope=\"{BearerRouteHarness.RequiredScope}\", resource_metadata=\"{BearerRouteHarness.ResourceMetadataUrl}\"", + _response!.Headers.WwwAuthenticate.ToString()); +} diff --git a/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_tenant_is_a_dot_segment.cs b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_tenant_is_a_dot_segment.cs new file mode 100644 index 00000000..2a9742bf --- /dev/null +++ b/Source/AuthProxy.Security.Specs/for_BearerRoutes/when_the_token_tenant_is_a_dot_segment.cs @@ -0,0 +1,46 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.for_BearerRoutes; + +/// +/// Dot-segment tenant identifiers must not be normalized to an unrelated successful verification endpoint. +/// +public class when_the_token_tenant_is_a_dot_segment +{ + [Theory] + [InlineData(".")] + [InlineData("..")] + public async Task should_refuse_without_verification_or_forwarding(string tenantId) + { + const string path = "/mcp/dot-tenant"; + await using var harness = new VerifyingTenantHarness(); + using var client = harness.CreateBearerClient(); + using var request = BearerRouteHarness.WithToken(HttpMethod.Get, path, harness.Issuer.Token(new Dictionary { ["tid"] = tenantId })); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode); + Assert.Empty(harness.Origin.Received); + } + + [Fact] + public async Task should_still_verify_and_forward_a_tenant_containing_a_dot() + { + const string tenantId = "tenant.one"; + const string path = "/mcp/valid-dot-tenant"; + await using var harness = new VerifyingTenantHarness(); + using var client = harness.CreateBearerClient(); + using var request = BearerRouteHarness.WithToken(HttpMethod.Get, path, harness.Issuer.Token(new Dictionary { ["tid"] = tenantId })); + using var response = await client.SendAsync(request); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.True(harness.Origin.ReceivedAnythingFor($"/api/tenants/{tenantId}/exists")); + Assert.True(harness.Origin.ReceivedAnythingFor(path)); + } + + sealed class VerifyingTenantHarness : BearerRouteHarness + { + protected override void AddSettings(IDictionary settings) => + settings[$"{C.AuthProxy.SectionKey}:TenantVerification:UrlTemplate"] = $"{Origin.BaseUrl}/api/tenants/{{tenantId}}/exists"; + } +} diff --git a/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs b/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs new file mode 100644 index 00000000..86aa7e34 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/BearerRouteHarness.cs @@ -0,0 +1,225 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net.Http.Headers; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Cratis.AuthProxy.Security.given; + +/// +/// A running AuthProxy with one bearer route, in front of a recording origin, trusting a stub issuer. +/// +/// +/// Configured the way Direct would be: /mcp accepts access tokens for the direct-api +/// audience from the issuer, requires direct:read, and forwards the GitHub id and login the browser +/// sessions of the same deployment carry. Everything else — /api, the frontend — stays on the +/// browser-session model, where a caller becomes authenticated by sending +/// . +/// +public class BearerRouteHarness : WebApplicationFactory +{ + /// The bearer route's path prefix. + public const string RoutePrefix = "/mcp"; + + /// The audience the route accepts. + public const string Audience = "direct-api"; + + /// The scope the route requires. + public const string RequiredScope = "direct:read"; + + /// The protected-resource metadata URL the route names in its challenges. + public const string ResourceMetadataUrl = "https://direct.example.test/.well-known/oauth-protected-resource/mcp"; + + /// The path of . + public const string ResourceMetadataPath = "/.well-known/oauth-protected-resource/mcp"; + + /// The Cratis account id the tokens carry as sub. + public const string AccountId = "7d4f9a52-1c1e-4bb8-9d0b-0f5c7b3e2a10"; + + /// The tenant the tokens carry as tid. + public const string TenantId = "22222222-2222-2222-2222-222222222222"; + + /// The GitHub id the tokens carry. + public const string GitHubId = "134365"; + + /// The GitHub login the tokens carry. + public const string GitHubLogin = "einari"; + + /// The client the tokens were issued to. + public const string ClientId = "cratis-cli"; + + /// The tenant a browser session resolves to. + public const string SessionTenantId = "11111111-1111-1111-1111-111111111111"; + + /// A second bearer route, which forwards the Authorization header to the backend. + public const string ForwardingRoutePrefix = "/v1"; + + /// A third bearer route, whose issuer cannot be reached. + public const string UnreachableRoutePrefix = "/offline"; + + /// The issuer of : a loopback port nothing listens on. + public const string UnreachableIssuer = "http://127.0.0.1:1/"; + + readonly string _pagesPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + /// + /// Initializes a new instance of the class. + /// + public BearerRouteHarness() : this(false) + { + } + + /// + /// Initializes a new instance of the class with backend path normalization. + /// + /// Whether the backend collapses repeated path separators. + protected BearerRouteHarness(bool normalizeRepeatedSeparators) + { + Directory.CreateDirectory(_pagesPath); + File.WriteAllText(Path.Combine(_pagesPath, "select-provider.html"), "Select Provider"); + File.WriteAllText(Path.Combine(_pagesPath, "forbidden.html"), "Forbidden"); + + Origin = RecordingBackend.Start(normalizeRepeatedSeparators).GetAwaiter().GetResult(); + Issuer = StubIssuer.Start().GetAwaiter().GetResult(); + } + + /// + /// Gets the origin AuthProxy forwards to, and the record of what reached it. + /// + public RecordingBackend Origin { get; } + + /// + /// Gets the authorization server whose tokens the bearer route accepts. + /// + public StubIssuer Issuer { get; } + + /// + /// Builds a request carrying a bearer token. + /// + /// The HTTP method. + /// The path and query to request. + /// The token. + /// The request. + public static HttpRequestMessage WithToken(HttpMethod method, string pathAndQuery, string token) + { + var request = new HttpRequestMessage(method, pathAndQuery); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + return request; + } + + /// + /// Creates a client that neither follows redirects nor carries cookies between requests. + /// + /// A configured . + public HttpClient CreateBearerClient() => + CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false, HandleCookies = false }); + + /// + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (!disposing) + { + return; + } + + Origin.DisposeAsync().AsTask().GetAwaiter().GetResult(); + Issuer.DisposeAsync().AsTask().GetAwaiter().GetResult(); + + if (Directory.Exists(_pagesPath)) + { + Directory.Delete(_pagesPath, recursive: true); + } + } + + /// + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder + .UseEnvironment("Production") + .ConfigureAppConfiguration((_, config) => config.AddInMemoryCollection(Settings())); + + if (UsesHeaderAuthentication) + { + builder.ConfigureTestServices(services => services + .AddAuthentication(HeaderAuthenticationHandler.Scheme) + .AddScheme( + HeaderAuthenticationHandler.Scheme, + _ => { })); + } + + ConfigureHost(builder); + } + + /// + /// Gets whether browser sessions are stood in for by , replacing the + /// proxy's own default authentication scheme. A harness that needs the real scheme selection turns it off. + /// + protected virtual bool UsesHeaderAuthentication => true; + + /// + /// Adds configuration a derived harness needs on top of the shared bearer-route deployment. + /// + /// The settings to add to. + protected virtual void AddSettings(IDictionary settings) + { + } + + /// + /// Configures the host further, for settings that must be visible while the application is being built. + /// + /// The web host builder. + protected virtual void ConfigureHost(IWebHostBuilder builder) + { + } + + Dictionary Settings() + { + const string route = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0"; + const string forwarding = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:1"; + const string unreachable = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:2"; + + var settings = new Dictionary + { + [$"{C.AuthProxy.SectionKey}:Services:app:Backend:BaseUrl"] = Origin.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:app:Frontend:BaseUrl"] = Origin.BaseUrl, + [$"{C.AuthProxy.SectionKey}:Services:app:IdentityVerification"] = nameof(C.IdentityVerificationMode.BestEffort), + + [$"{route}:PathPrefix"] = RoutePrefix, + [$"{route}:Issuers:0:Issuer"] = Issuer.Issuer, + [$"{route}:Audiences:0"] = Audience, + [$"{route}:RequiredScopes:0"] = RequiredScope, + [$"{route}:ResourceMetadataUrl"] = ResourceMetadataUrl, + [$"{route}:IdentityProvider"] = "github", + [$"{route}:ClaimMappings:sub"] = "github_id", + [$"{route}:ClaimMappings:preferred_username"] = "github_login", + + [$"{C.AuthProxy.SectionKey}:PagesPath"] = _pagesPath, + + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Strategy"] = nameof(C.TenantSourceIdentifierResolverType.Specified), + [$"{C.AuthProxy.SectionKey}:TenantResolutions:0:Options:TenantId"] = SessionTenantId, + + [$"{C.Authentication.SectionKey}:OidcProviders:0:Name"] = "Provider One", + [$"{C.Authentication.SectionKey}:OidcProviders:0:Authority"] = "https://login.example.test/one", + [$"{C.Authentication.SectionKey}:OidcProviders:0:ClientId"] = "client-one", + + [$"{forwarding}:PathPrefix"] = ForwardingRoutePrefix, + [$"{forwarding}:Issuers:0:Issuer"] = Issuer.Issuer, + [$"{forwarding}:Audiences:0"] = Audience, + [$"{forwarding}:ForwardAuthorizationHeader"] = "true", + + [$"{unreachable}:PathPrefix"] = UnreachableRoutePrefix, + [$"{unreachable}:Issuers:0:Issuer"] = UnreachableIssuer, + [$"{unreachable}:Audiences:0"] = Audience, + }; + + AddSettings(settings); + return settings; + } +} diff --git a/Source/AuthProxy.Security.Specs/given/BearerRouteSpecCollection.cs b/Source/AuthProxy.Security.Specs/given/BearerRouteSpecCollection.cs new file mode 100644 index 00000000..dc7c9251 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/BearerRouteSpecCollection.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.given; + +/// +/// Runs every bearer-route spec against one shared proxy, origin and issuer, in sequence, so no spec reads +/// another's traffic from the recording origin. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class BearerRouteSpecCollection : ICollectionFixture +{ + /// The collection name every bearer-route spec joins. + public const string Name = "BearerRoutes"; +} diff --git a/Source/AuthProxy.Security.Specs/given/GatedBearerRouteHarness.cs b/Source/AuthProxy.Security.Specs/given/GatedBearerRouteHarness.cs new file mode 100644 index 00000000..7524250c --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/GatedBearerRouteHarness.cs @@ -0,0 +1,56 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.given; + +/// +/// The bearer-route deployment of , with the gates the deployment declares for every +/// forwarded request: a proxy-wide claim requirement, a requirement of the service, and identity verification +/// required of the service. +/// +/// +/// The service requires preferred_username to be . The +/// bearer route maps preferred_username from the token's github_login, so what is +/// checked is the principal after the mapping, not the token as issued. Every bearer route states that it +/// accepts callers without identity verification, which a deployment requiring verification must say. +/// +/// The route leaves the deployment's requirements out and +/// requires instead — the shape of a deployment whose requirements name a claim the +/// token issuer does not mint. +/// +/// +public class GatedBearerRouteHarness : BearerRouteHarness +{ + /// The claim the whole deployment requires. + public const string RequiredClaim = "org"; + + /// The value of the deployment accepts. + public const string RequiredValue = "Cratis"; + + /// The claim the route ignoring the deployment's requirements requires instead. + public const string MembershipClaim = "membership"; + + /// The value of that route accepts. + public const string MembershipValue = "direct"; + + /// + protected override void AddSettings(IDictionary settings) + { + const string service = $"{C.AuthProxy.SectionKey}:Services:app"; + + settings[$"{C.Authorization.SectionKey}:RequiredClaims:0:Claim"] = RequiredClaim; + settings[$"{C.Authorization.SectionKey}:RequiredClaims:0:AnyOf:0"] = RequiredValue; + settings[$"{service}:Authorization:RequiredClaims:0:Claim"] = "preferred_username"; + settings[$"{service}:Authorization:RequiredClaims:0:AnyOf:0"] = GitHubLogin; + settings[$"{service}:IdentityVerification"] = nameof(C.IdentityVerificationMode.Required); + + settings[$"{service}:BearerRoutes:1:{nameof(C.BearerRoute.IgnoreDeploymentRequiredClaims)}"] = "true"; + settings[$"{service}:BearerRoutes:1:{nameof(C.BearerRoute.RequiredClaims)}:0:Claim"] = MembershipClaim; + settings[$"{service}:BearerRoutes:1:{nameof(C.BearerRoute.RequiredClaims)}:0:AnyOf:0"] = MembershipValue; + + for (var route = 0; route < 3; route++) + { + settings[$"{service}:BearerRoutes:{route}:{nameof(C.BearerRoute.AcceptWithoutIdentityVerification)}"] = "true"; + } + } +} diff --git a/Source/AuthProxy.Security.Specs/given/GatedBearerRouteSpecCollection.cs b/Source/AuthProxy.Security.Specs/given/GatedBearerRouteSpecCollection.cs new file mode 100644 index 00000000..b6cb20cf --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/GatedBearerRouteSpecCollection.cs @@ -0,0 +1,15 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.given; + +/// +/// Runs every spec against the bearer-route deployment that declares claim requirements and identity verification, +/// in sequence. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class GatedBearerRouteSpecCollection : ICollectionFixture +{ + /// The collection name every spec against that deployment joins. + public const string Name = "GatedBearerRoutes"; +} diff --git a/Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesHarness.cs b/Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesHarness.cs new file mode 100644 index 00000000..5dc35ee6 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesHarness.cs @@ -0,0 +1,44 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Hosting; + +namespace Cratis.AuthProxy.Security.given; + +/// +/// The bearer-route deployment of , with the general JWT Bearer handler also +/// configured to trust the bearer routes' issuer. +/// +/// +/// This is the deployment in which a bearer-route token could reach a browser-only surface at all: the JWT Bearer +/// handler would accept it on any path. The proxy's own authentication scheme selection is kept — no stand-in +/// session handler — so the scheme that would accept the token is the one that actually runs. +/// +/// The JWT Bearer settings are given with UseSetting because the proxy decides whether to +/// register the handler while the application is being built. +/// +/// +public class JwtBearerAlongsideBearerRoutesHarness : BearerRouteHarness +{ + /// + /// An issuer the JWT Bearer handler also trusts and no bearer route names, so a spec can show the handler + /// accepts a token at all. + /// + public const string OtherIssuer = "https://machine-to-machine.example.test/"; + + /// + protected override bool UsesHeaderAuthentication => false; + + /// + protected override void ConfigureHost(IWebHostBuilder builder) + { + const string jwtBearer = $"{C.Authentication.SectionKey}:JwtBearer"; + + builder + .UseSetting($"{jwtBearer}:Authority", Issuer.Issuer) + .UseSetting($"{jwtBearer}:MetadataAddress", $"{Issuer.Issuer}.well-known/oauth-authorization-server") + .UseSetting($"{jwtBearer}:RequireHttpsMetadata", "false") + .UseSetting($"{jwtBearer}:Audience", Audience) + .UseSetting($"{jwtBearer}:TokenValidationParameters:ValidIssuers:0", OtherIssuer); + } +} diff --git a/Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesSpecCollection.cs b/Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesSpecCollection.cs new file mode 100644 index 00000000..8d1c4b41 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/JwtBearerAlongsideBearerRoutesSpecCollection.cs @@ -0,0 +1,14 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.given; + +/// +/// Runs every spec against the deployment where the JWT Bearer handler trusts a bearer-route issuer, in sequence. +/// +[CollectionDefinition(Name, DisableParallelization = true)] +public class JwtBearerAlongsideBearerRoutesSpecCollection : ICollectionFixture +{ + /// The collection name every spec against that deployment joins. + public const string Name = "JwtBearerAlongsideBearerRoutes"; +} diff --git a/Source/AuthProxy.Security.Specs/given/PaddedBearerRouteHarness.cs b/Source/AuthProxy.Security.Specs/given/PaddedBearerRouteHarness.cs new file mode 100644 index 00000000..eabde585 --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/PaddedBearerRouteHarness.cs @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Security.given; + +/// +/// A bearer route whose configured tenant claim and issuer include surrounding whitespace. +/// +public class PaddedBearerRouteHarness : BearerRouteHarness +{ + /// + protected override void AddSettings(IDictionary settings) + { + const string route = $"{C.AuthProxy.SectionKey}:Services:app:BearerRoutes:0"; + settings[$"{route}:TenantClaimType"] = " tenant "; + settings[$"{route}:Issuers:0:Issuer"] = $" {Issuer.Issuer} "; + } +} diff --git a/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs b/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs index 6d618e7e..de1929f5 100644 --- a/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs +++ b/Source/AuthProxy.Security.Specs/given/RecordingBackend.cs @@ -90,8 +90,9 @@ public Func IdentityResponse /// /// Starts a new recording origin. /// + /// Whether to collapse repeated path separators like a slash-normalizing backend. /// The started origin. - public static async Task Start() + public static async Task Start(bool normalizeRepeatedSeparators = false) { var builder = WebApplication.CreateSlimBuilder(); builder.Logging.ClearProviders(); @@ -106,6 +107,16 @@ public static async Task Start() app.UseWebSockets(); app.Use(async (context, next) => { + if (normalizeRepeatedSeparators) + { + var path = context.Request.Path.Value ?? string.Empty; + while (path.Contains("//", StringComparison.Ordinal)) + { + path = path.Replace("//", "/", StringComparison.Ordinal); + } + context.Request.Path = path; + } + state.Record(context); await next(); }); diff --git a/Source/AuthProxy.Security.Specs/given/StubIssuer.cs b/Source/AuthProxy.Security.Specs/given/StubIssuer.cs new file mode 100644 index 00000000..d3f54fdb --- /dev/null +++ b/Source/AuthProxy.Security.Specs/given/StubIssuer.cs @@ -0,0 +1,270 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Cryptography; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.Security.given; + +/// +/// A stand-in authorization server on loopback: publishes RFC 8414 metadata and a JWKS for a local RSA key, +/// and signs access tokens shaped like the ones Cratis Identity issues. +/// +/// +/// It listens on a real socket because AuthProxy reads the metadata and keys over HTTP exactly as it would from +/// a deployed issuer; nothing about discovery is substituted. Plain HTTP is accepted only because the issuer is +/// on a loopback host. +/// +public sealed class StubIssuer : IAsyncDisposable +{ + /// The key id the published key carries. + public const string KeyId = "stub-issuer-key"; + + readonly WebApplication _app; + readonly RSA _rsa; + string _keyId = KeyId; + int _metadataRequests; + + StubIssuer(WebApplication app, RSA rsa, string issuer) + { + _app = app; + _rsa = rsa; + Issuer = issuer; + } + + /// + /// Gets the issuer identifier, with the trailing slash Cratis Identity uses. + /// + public string Issuer { get; } + + /// + /// Gets the number of discovery requests received. + /// + public int MetadataRequests => Volatile.Read(ref _metadataRequests); + + /// + /// Gets or sets whether discovery returns an unavailable document. + /// + public bool MetadataUnavailable { get; set; } + + /// + /// Gets or sets a callback that can hold a discovery response until a spec releases it. + /// + public Func? BeforeMetadataResponse { get; set; } + + /// + /// Starts a new stub issuer. + /// + /// The started issuer. + public static async Task Start() + { + var rsa = RSA.Create(2048); + var builder = WebApplication.CreateSlimBuilder(); + builder.Logging.ClearProviders(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + var app = builder.Build(); + string? issuer = null; + StubIssuer? stub = null; + + app.MapGet("/.well-known/oauth-authorization-server", async () => + { + Interlocked.Increment(ref stub!._metadataRequests); + if (stub.BeforeMetadataResponse is { } beforeResponse) + { + await beforeResponse(); + } + + return stub.MetadataUnavailable + ? Results.NotFound() + : Results.Json(new Dictionary + { + ["issuer"] = issuer!, + ["jwks_uri"] = $"{issuer}.well-known/jwks", + ["authorization_endpoint"] = $"{issuer}connect/authorize", + ["token_endpoint"] = $"{issuer}connect/token", + }); + }); + + app.MapGet("/.well-known/jwks", () => + { + var parameters = rsa.ExportParameters(includePrivateParameters: false); + return Results.Json(new + { + keys = new[] + { + new Dictionary + { + ["kty"] = "RSA", + ["use"] = "sig", + ["alg"] = SecurityAlgorithms.RsaSha256, + ["kid"] = stub!._keyId, + ["n"] = Base64UrlEncoder.Encode(parameters.Modulus), + ["e"] = Base64UrlEncoder.Encode(parameters.Exponent), + }, + }, + }); + }); + + await app.StartAsync(); + + var address = app.Services.GetRequiredService().Features + .Get()! + .Addresses + .First(); + issuer = $"{address.TrimEnd('/')}/"; + + return stub = new StubIssuer(app, rsa, issuer); + } + + /// + /// Signs an access token with the published key. + /// + /// The claims, which replace the defaults of they name. + /// The audience. + /// When the token expires. Defaults to fifteen minutes from now. + /// The issuer to name. Defaults to this issuer. + /// Default claims to leave out. + /// The signed token. + public string Token( + IDictionary? claims = null, + string audience = BearerRouteHarness.Audience, + DateTime? expires = null, + string? issuer = null, + params string[] without) => + Sign(new RsaSecurityKey(_rsa) { KeyId = _keyId }, claims, audience, expires, issuer, without); + + /// + /// Replaces the published signing key and its identifier. + /// + public void RotateKey() + { + using var replacement = RSA.Create(2048); + _rsa.ImportParameters(replacement.ExportParameters(includePrivateParameters: true)); + _keyId = Guid.NewGuid().ToString("N"); + } + + /// + /// Signs an access token with the published key, then lets the caller reshape it before it is written. + /// + /// Changes the descriptor: the type, the signing or encrypting credentials, the lifetime. + /// Whether the handler fills in lifetimes the descriptor leaves unset. + /// The token. + public string TokenShaped(Action shape, bool setDefaultTimes = true) => + Sign(new RsaSecurityKey(_rsa) { KeyId = _keyId }, null, BearerRouteHarness.Audience, null, null, [], shape, setDefaultTimes); + + /// + /// Signs a token with HMAC, keyed with this issuer's published public key — the algorithm-confusion attack on + /// a validator that lets the token pick its algorithm. + /// + /// The token. + public string TokenSignedWithThePublicKeyAsASecret() + { + var publicKey = _rsa.ExportSubjectPublicKeyInfo(); + return Sign(new SymmetricSecurityKey(publicKey) { KeyId = KeyId }, null, BearerRouteHarness.Audience, null, null, [], algorithm: SecurityAlgorithms.HmacSha256); + } + + /// + /// Signs an access token with a key this issuer does not publish, but names the published key id. + /// + /// The token. + public string TokenSignedByAnotherKey() + { + using var other = RSA.Create(2048); + return Sign(new RsaSecurityKey(other) { KeyId = KeyId }, null, BearerRouteHarness.Audience, null, null, []); + } + + /// + /// Builds an unsigned token (alg: none) carrying otherwise valid claims. + /// + /// The token. + public string UnsignedToken() + { + var now = DateTimeOffset.UtcNow; + var header = Base64UrlEncoder.Encode(/*lang=json,strict*/ """{"alg":"none","typ":"at+jwt"}"""); + var payload = new Dictionary(DefaultClaims()) + { + ["iss"] = Issuer, + ["aud"] = BearerRouteHarness.Audience, + ["iat"] = now.ToUnixTimeSeconds(), + ["nbf"] = now.ToUnixTimeSeconds(), + ["exp"] = now.AddMinutes(15).ToUnixTimeSeconds(), + }; + + return $"{header}.{Base64UrlEncoder.Encode(System.Text.Json.JsonSerializer.Serialize(payload))}."; + } + + /// + /// Gets the claims a Cratis Identity access token carries. + /// + /// The claims. + public static Dictionary DefaultClaims() => new() + { + ["sub"] = BearerRouteHarness.AccountId, + ["tid"] = BearerRouteHarness.TenantId, + ["scope"] = "direct:read direct:work", + ["azp"] = BearerRouteHarness.ClientId, + ["client_id"] = BearerRouteHarness.ClientId, + ["github_id"] = BearerRouteHarness.GitHubId, + ["github_login"] = BearerRouteHarness.GitHubLogin, + ["name"] = "Einar Ingebrigtsen", + ["preferred_username"] = BearerRouteHarness.GitHubLogin, + ["jti"] = Guid.NewGuid().ToString("N"), + }; + + /// + public async ValueTask DisposeAsync() + { + await _app.StopAsync(); + await _app.DisposeAsync(); + _rsa.Dispose(); + } + + string Sign( + SecurityKey key, + IDictionary? claims, + string audience, + DateTime? expires, + string? issuer, + string[] without, + Action? shape = null, + bool setDefaultTimes = true, + string algorithm = SecurityAlgorithms.RsaSha256) + { + var merged = DefaultClaims(); + foreach (var type in without) + { + merged.Remove(type); + } + + foreach (var (type, value) in claims ?? new Dictionary()) + { + merged[type] = value; + } + + var expiry = expires ?? DateTime.UtcNow.AddMinutes(15); + var issuedAt = expiry.AddMinutes(-15); + + var descriptor = new SecurityTokenDescriptor + { + Issuer = issuer ?? Issuer, + Audience = audience, + Claims = merged, + IssuedAt = issuedAt, + NotBefore = issuedAt, + Expires = expiry, + TokenType = "at+jwt", + SigningCredentials = new SigningCredentials(key, algorithm), + }; + shape?.Invoke(descriptor); + + return new JsonWebTokenHandler { SetDefaultTimesOnTokenCreation = setDefaultTimes }.CreateToken(descriptor); + } +} diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_bearer_route_ignores_the_deployment_requirements.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_bearer_route_ignores_the_deployment_requirements.cs new file mode 100644 index 00000000..e2634f87 --- /dev/null +++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_a_bearer_route_ignores_the_deployment_requirements.cs @@ -0,0 +1,56 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authorization.for_AccessPolicy; + +/// +/// A deployment can require a claim only its browser sign-in produces — a GitHub team, read from the GitHub API — +/// which the token issuer does not mint. A bearer route that says so leaves the root and service requirements out, +/// and is held to its own instead. Its own requirements apply whether or not it leaves the deployment's out. +/// +public class when_a_bearer_route_ignores_the_deployment_requirements : given.an_access_policy +{ + C.AuthProxy _config; + AccessDecision _memberWithoutTheTeam; + AccessDecision _teamWithoutTheMembership; + AccessDecision _byDefault; + AccessDecision _ownRequirementsOnTopOfTheDeployment; + + void Establish() => _config = new C.AuthProxy + { + Authorization = new C.Authorization { RequiredClaims = [Claiming("urn:github:team", "cratis/core")] }, + Services = new Dictionary + { + ["direct"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "http://direct.test/" }, + Authorization = new C.Authorization { RequiredClaims = [Claiming("urn:github:organization", "cratis")] }, + }, + }, + }; + + void Because() + { + var ignoring = new C.BearerRoute + { + IgnoreDeploymentRequiredClaims = true, + RequiredClaims = [Claiming("membership", "direct")], + }; + var applying = new C.BearerRoute { RequiredClaims = [Claiming("membership", "direct")] }; + + var member = Principal(new Claim("membership", "direct")); + var team = Principal(new Claim("urn:github:team", "cratis/core"), new Claim("urn:github:organization", "cratis")); + + _memberWithoutTheTeam = _policy.Evaluate(member, _config, "direct", ignoring); + _teamWithoutTheMembership = _policy.Evaluate(team, _config, "direct", ignoring); + _byDefault = _policy.Evaluate(member, _config, "direct", new C.BearerRoute()); + _ownRequirementsOnTopOfTheDeployment = _policy.Evaluate(team, _config, "direct", applying); + } + + [Fact] void should_grant_a_caller_satisfying_the_route_requirements_alone() => _memberWithoutTheTeam.IsGranted.ShouldBeTrue(); + [Fact] void should_deny_a_caller_missing_the_route_requirement() => _teamWithoutTheMembership.UnsatisfiedClaim.ShouldEqual("membership"); + [Fact] void should_apply_the_deployment_requirements_by_default() => _byDefault.UnsatisfiedClaim.ShouldEqual("urn:github:team"); + [Fact] void should_add_the_route_requirements_to_the_deployment_requirements() => _ownRequirementsOnTopOfTheDeployment.UnsatisfiedClaim.ShouldEqual("membership"); + + static ClaimsPrincipal Principal(params Claim[] claims) => new(new ClaimsIdentity(claims, "spec")); +} diff --git a/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_evaluating_for_a_bearer_route.cs b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_evaluating_for_a_bearer_route.cs new file mode 100644 index 00000000..da15348e --- /dev/null +++ b/Source/AuthProxy.Specs/Authorization/for_AccessPolicy/when_evaluating_for_a_bearer_route.cs @@ -0,0 +1,58 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Authorization.for_AccessPolicy; + +/// +/// A bearer route belongs to one service, so a token presented on it is held to the root requirements and that +/// service's, never to another service's, and never to fewer because the request names some other service. +/// +public class when_evaluating_for_a_bearer_route : given.an_access_policy +{ + C.AuthProxy _config; + AccessDecision _qualified; + AccessDecision _missingTheServiceClaim; + AccessDecision _missingTheRootClaim; + AccessDecision _forAnotherService; + AccessDecision _namingAnotherServiceInTheRequest; + + void Establish() => _config = new C.AuthProxy + { + Authorization = new C.Authorization { RequiredClaims = [Claiming("org", "Cratis")] }, + Services = new Dictionary + { + ["admin"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "http://admin.test/" }, + Authorization = new C.Authorization { RequiredClaims = [Claiming("team", "operations")] }, + }, + ["portal"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "http://portal.test/" }, + }, + }, + }; + + void Because() + { + var qualified = Principal(new Claim("org", "cratis"), new Claim("team", "Operations")); + var orgOnly = Principal(new Claim("org", "Cratis")); + var teamOnly = Principal(new Claim("team", "operations")); + + _qualified = _policy.Evaluate(qualified, _config, "admin", new C.BearerRoute()); + _missingTheServiceClaim = _policy.Evaluate(orgOnly, _config, "admin", new C.BearerRoute()); + _missingTheRootClaim = _policy.Evaluate(teamOnly, _config, "portal", new C.BearerRoute()); + _forAnotherService = _policy.Evaluate(orgOnly, _config, "portal", new C.BearerRoute()); + + _context.Request.Headers[Headers.ServiceId] = "portal"; + _namingAnotherServiceInTheRequest = _policy.Evaluate(orgOnly, _config, "ADMIN", new C.BearerRoute()); + } + + [Fact] void should_grant_a_caller_satisfying_the_root_and_the_service() => _qualified.IsGranted.ShouldBeTrue(); + [Fact] void should_deny_a_caller_missing_the_service_requirement() => _missingTheServiceClaim.UnsatisfiedClaim.ShouldEqual("team"); + [Fact] void should_deny_a_caller_missing_the_root_requirement() => _missingTheRootClaim.UnsatisfiedClaim.ShouldEqual("org"); + [Fact] void should_not_apply_another_service_requirements() => _forAnotherService.IsGranted.ShouldBeTrue(); + [Fact] void should_ignore_what_the_request_names() => _namingAnotherServiceInTheRequest.IsGranted.ShouldBeFalse(); + + static ClaimsPrincipal Principal(params Claim[] claims) => new(new ClaimsIdentity(claims, "spec")); +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/given/a_cached_issuer.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/given/a_cached_issuer.cs new file mode 100644 index 00000000..d0ad6096 --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/given/a_cached_issuer.cs @@ -0,0 +1,93 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net; +using System.Security.Cryptography; +using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerIssuerMetadata.given; + +public class a_cached_issuer : Specification +{ + protected readonly ResolvedBearerIssuer Issuer = new("https://issuer.example.test/", "https://issuer.example.test/metadata", ["at+jwt"], true); + protected readonly MetadataHandler Handler = new(); + protected readonly MetadataClock Clock = new(); + protected BearerIssuerMetadata Metadata; + HttpClient _client; + + async Task Establish() + { + _client = new HttpClient(Handler); + var factory = Substitute.For(); + factory.CreateClient(Arg.Any()).Returns(_client); + Metadata = new BearerIssuerMetadata(factory, NullLogger.Instance, Clock); + await Metadata.GetSigningKeys(Issuer, CancellationToken.None); + } + + void Destroy() + { + Handler.Release.TrySetResult(); + Metadata.Dispose(); + _client.Dispose(); + } + + protected class MetadataClock : TimeProvider + { + DateTimeOffset _now = DateTimeOffset.UtcNow; + + public override DateTimeOffset GetUtcNow() => _now; + internal void Advance(TimeSpan interval) => _now += interval; + } + + protected class MetadataHandler : HttpMessageHandler + { + readonly string _modulus; + readonly string _exponent; + + internal MetadataHandler() + { + using var rsa = RSA.Create(2048); + var parameters = rsa.ExportParameters(false); + _modulus = Base64UrlEncoder.Encode(parameters.Modulus); + _exponent = Base64UrlEncoder.Encode(parameters.Exponent); + } + + internal TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal bool Unavailable { get; set; } + internal bool Delay { get; set; } + internal int Requests { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.RequestUri!.AbsolutePath == "/metadata") + { + Requests++; + if (Delay) + { + Started.TrySetResult(); + await Release.Task.WaitAsync(cancellationToken); + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + issuer = Unavailable ? "https://another-issuer.example.test/" : "https://issuer.example.test/", + jwks_uri = "https://issuer.example.test/keys", + })), + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new + { + keys = new[] { new { kty = "RSA", use = "sig", kid = Requests == 1 ? "known" : "rotated", n = _modulus, e = _exponent } }, + })), + }; + } + } +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_is_due.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_is_due.cs new file mode 100644 index 00000000..7088df05 --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_is_due.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerIssuerMetadata; + +public class when_an_automatic_refresh_is_due : given.a_cached_issuer +{ + IReadOnlyCollection? _keys; + IReadOnlyCollection? _concurrent; + + void Establish() + { + Clock.Advance(TimeSpan.FromHours(13)); + Handler.Delay = true; + } + + async Task Because() + { + var lookup = Metadata.GetSigningKeys(Issuer, CancellationToken.None); + await Handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _keys = await lookup.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _concurrent = await Metadata.GetSigningKeys(Issuer, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + } + + [Fact] void should_answer_the_initiating_lookup_before_the_issuer_responds() => _keys!.Single().KeyId.ShouldEqual("known"); + [Fact] void should_answer_concurrent_lookups_before_the_issuer_responds() => _concurrent!.Single().KeyId.ShouldEqual("known"); + [Fact] void should_start_only_one_background_refresh() => Handler.Requests.ShouldEqual(2); + [Fact] void should_not_need_the_issuer_to_finish() => Handler.Release.Task.IsCompleted.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_retry_is_due.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_retry_is_due.cs new file mode 100644 index 00000000..3bb7238c --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_automatic_refresh_retry_is_due.cs @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerIssuerMetadata; + +public class when_an_automatic_refresh_retry_is_due : given.a_cached_issuer +{ + IReadOnlyCollection? _keys; + + async Task Establish() + { + Clock.Advance(TimeSpan.FromHours(13)); + Handler.Unavailable = true; + await Metadata.GetSigningKeys(Issuer, CancellationToken.None, refresh: true); + Clock.Advance(BearerIssuerMetadata.RefreshInterval + TimeSpan.FromSeconds(1)); + Handler.Delay = true; + } + + async Task Because() + { + var lookup = Metadata.GetSigningKeys(Issuer, CancellationToken.None); + await Handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _keys = await lookup.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + } + + [Fact] void should_answer_the_initiating_lookup_before_the_retry_finishes() => _keys!.Single().KeyId.ShouldEqual("known"); + [Fact] void should_start_the_retry_after_the_failure_backoff() => Handler.Requests.ShouldEqual(3); + [Fact] void should_not_need_the_unavailable_issuer_to_finish() => Handler.Release.Task.IsCompleted.ShouldBeFalse(); +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_ordinary_lookup_precedes_a_requested_refresh.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_ordinary_lookup_precedes_a_requested_refresh.cs new file mode 100644 index 00000000..01c77533 --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_an_ordinary_lookup_precedes_a_requested_refresh.cs @@ -0,0 +1,34 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerIssuerMetadata; + +public class when_an_ordinary_lookup_precedes_a_requested_refresh : given.a_cached_issuer +{ + IReadOnlyCollection? _before; + IReadOnlyCollection? _during; + IReadOnlyCollection? _refreshed; + bool _refreshWaited; + + void Establish() => Handler.Delay = true; + + async Task Because() + { + // Another caller looks up known keys between unknown-key detection and the caller's refresh operation. + _before = await Metadata.GetSigningKeys(Issuer, CancellationToken.None); + var refresh = Metadata.GetSigningKeys(Issuer, CancellationToken.None, refresh: true); + await Handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _during = await Metadata.GetSigningKeys(Issuer, CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + _refreshWaited = !refresh.IsCompleted; + Handler.Release.TrySetResult(); + _refreshed = await refresh.WaitAsync(TimeSpan.FromSeconds(5), TimeProvider.System); + } + + [Fact] void should_not_refresh_for_the_unrelated_lookup() => _before!.Single().KeyId.ShouldEqual("known"); + [Fact] void should_return_cached_keys_to_an_ordinary_caller_during_refresh() => _during!.Single().KeyId.ShouldEqual("known"); + [Fact] void should_keep_the_requesting_caller_waiting_for_its_refresh() => _refreshWaited.ShouldBeTrue(); + [Fact] void should_return_the_rotated_key_to_the_requesting_caller() => _refreshed!.Single().KeyId.ShouldEqual("rotated"); + [Fact] void should_retrieve_only_for_the_initial_lookup_and_requested_refresh() => Handler.Requests.ShouldEqual(2); +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_initial_metadata_retrieval_fails.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_initial_metadata_retrieval_fails.cs new file mode 100644 index 00000000..8662abee --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerIssuerMetadata/when_initial_metadata_retrieval_fails.cs @@ -0,0 +1,59 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Net; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerIssuerMetadata; + +public class when_initial_metadata_retrieval_fails : Specification +{ + readonly ResolvedBearerIssuer _issuer = new("https://issuer.example.test/", "https://issuer.example.test/metadata", ["at+jwt"], true); + readonly FailingMetadataHandler _handler = new(); + HttpClient _client; + BearerIssuerMetadata _metadata; + IReadOnlyCollection?[] _results; + + void Establish() + { + _client = new HttpClient(_handler); + var factory = Substitute.For(); + factory.CreateClient(Arg.Any()).Returns(_client); + _metadata = new BearerIssuerMetadata(factory, NullLogger.Instance); + } + + async Task Because() + { + var first = _metadata.GetSigningKeys(_issuer, CancellationToken.None); + await _handler.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var waiting = Enumerable.Range(0, 8).Select(_ => _metadata.GetSigningKeys(_issuer, CancellationToken.None)).ToArray(); + _handler.Complete.SetResult(); + _results = await Task.WhenAll(waiting.Prepend(first)); + await _metadata.GetSigningKeys(_issuer, CancellationToken.None, refresh: true); + } + + void Destroy() + { + _metadata.Dispose(); + _client.Dispose(); + } + + [Fact] void should_return_unavailable_for_every_waiting_request() => _results.All(_ => _ is null).ShouldBeTrue(); + [Fact] void should_fetch_only_once_during_the_failure_backoff_even_when_refresh_is_requested() => _handler.Requests.ShouldEqual(1); + + sealed class FailingMetadataHandler : HttpMessageHandler + { + internal TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal TaskCompletionSource Complete { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal int Requests { get; private set; } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests++; + Started.TrySetResult(); + await Complete.Task.WaitAsync(cancellationToken); + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + } +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs new file mode 100644 index 00000000..8f07ff16 --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteConfigurationValidator/when_validating_bearer_routes.cs @@ -0,0 +1,201 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerRouteConfigurationValidator; + +/// +/// A bearer route AuthProxy cannot enforce as written is left on the browser-session model, which is closed but +/// invisible: the operator believes an API is served and every client is sent to sign in. Each such mistake is +/// refused at startup instead, and a deployment without bearer routes is not affected at all. +/// +public class when_validating_bearer_routes : Specification +{ + ValidateOptionsResult _valid; + ValidateOptionsResult _withoutRoutes; + ValidateOptionsResult _withoutAudience; + ValidateOptionsResult _withPlainHttpIssuer; + ValidateOptionsResult _overlappingAnonymousPath; + ValidateOptionsResult _withoutBackend; + ValidateOptionsResult _withLargeClockSkew; + ValidateOptionsResult _mappingIntoReservedClaim; + ValidateOptionsResult _withQuotedScope; + ValidateOptionsResult _withoutIssuers; + ValidateOptionsResult _withRootPrefix; + ValidateOptionsResult _withProxyOwnedPrefix; + ValidateOptionsResult _withPlainHttpResourceMetadata; + ValidateOptionsResult _prefixOfAnotherService; + ValidateOptionsResult _resourceMetadataOfAnotherService; + ValidateOptionsResult _withEmptyMapping; + ValidateOptionsResult _mappingIntoRoles; + ValidateOptionsResult _verificationRequired; + ValidateOptionsResult _verificationRequiredByAnotherService; + ValidateOptionsResult _verificationRequiredAndAcceptedWithout; + ValidateOptionsResult _verificationRequiredOfANonParticipant; + ValidateOptionsResult _withRouteRequirement; + ValidateOptionsResult _withRouteRequirementNamingNoClaim; + ValidateOptionsResult _withRouteRequirementOnARole; + ValidateOptionsResult _mappingIntoTenant; + ValidateOptionsResult _mappingIntoCustomTenant; + ValidateOptionsResult _mappingIntoDefaultTenant; + ValidateOptionsResult _conflictingMappingTargets; + ValidateOptionsResult _metadataAtBearerPrefix; + ValidateOptionsResult _metadataUnderBearerPrefix; + ValidateOptionsResult _metadataUnderLaterBearerPrefix; + ValidateOptionsResult _metadataAtSimilarPrefix; + ValidateOptionsResult _verificationRequiredByDefault; + ValidateOptionsResult _verificationRequiredByDefaultAndAcceptedWithout; + + void Because() + { + var validator = new BearerRouteConfigurationValidator(); + + _metadataAtBearerPrefix = validator.Validate(null, Configuration(_ => _.ResourceMetadataUrl = "https://direct.example.test/MCP")); + _metadataUnderBearerPrefix = validator.Validate(null, Configuration(_ => _.ResourceMetadataUrl = "https://direct.example.test/mcp/metadata")); + _metadataUnderLaterBearerPrefix = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = Service(Route(prefix: "/.well-known")))); + _metadataAtSimilarPrefix = validator.Validate(null, Configuration(_ => _.ResourceMetadataUrl = "https://direct.example.test/mcpx/metadata")); + _valid = validator.Validate(null, Configuration(_ => { })); + _withoutRoutes = validator.Validate(null, Configuration(_ => _.PathPrefix = string.Empty, declareRoute: false)); + _withoutAudience = validator.Validate(null, Configuration(_ => _.Audiences = [])); + _withPlainHttpIssuer = validator.Validate(null, Configuration(_ => _.Issuers = [new C.BearerIssuer { Issuer = "http://auth.example.test/" }])); + _overlappingAnonymousPath = validator.Validate(null, Configuration(_ => _.PathPrefix = "/public/mcp")); + _withoutBackend = validator.Validate(null, Configuration(_ => { }, backend: false)); + _withLargeClockSkew = validator.Validate(null, Configuration(_ => _.ClockSkew = TimeSpan.FromHours(1))); + _mappingIntoReservedClaim = validator.Validate(null, Configuration(_ => _.ClaimMappings = new Dictionary { ["urn:cratis:bearer:scope"] = "scope" })); + _withQuotedScope = validator.Validate(null, Configuration(_ => _.RequiredScopes = ["direct:\"read"])); + _withoutIssuers = validator.Validate(null, Configuration(_ => _.Issuers = [])); + _withRootPrefix = validator.Validate(null, Configuration(_ => _.PathPrefix = "/")); + _withProxyOwnedPrefix = validator.Validate(null, Configuration(_ => _.PathPrefix = "/.cratis/mcp")); + _withPlainHttpResourceMetadata = validator.Validate(null, Configuration(_ => _.ResourceMetadataUrl = "http://direct.example.test/.well-known/oauth-protected-resource/mcp")); + _prefixOfAnotherService = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = Service(Route()))); + _resourceMetadataOfAnotherService = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = Service(Route(prefix: "/v1")))); + _withEmptyMapping = validator.Validate(null, Configuration(_ => _.ClaimMappings = new Dictionary { ["sub"] = " " })); + _mappingIntoRoles = validator.Validate(null, Configuration(_ => _.ClaimMappings = new Dictionary { ["roles"] = "groups" })); + _mappingIntoTenant = validator.Validate(null, Configuration(_ => _.ClaimMappings = new Dictionary { ["TID"] = "another_tenant" })); + _mappingIntoCustomTenant = validator.Validate(null, Configuration(_ => + { + _.TenantClaimType = " tenant "; + _.ClaimMappings = new Dictionary { ["tenant"] = "another_tenant" }; + })); + _mappingIntoDefaultTenant = validator.Validate(null, Configuration(_ => + { + _.TenantClaimType = " "; + _.ClaimMappings = new Dictionary { ["tid"] = "another_tenant" }; + })); + _conflictingMappingTargets = validator.Validate(null, Configuration(_ => _.ClaimMappings = new Dictionary + { + ["sub"] = "github_id", + ["SUB"] = "another_id", + })); + _verificationRequiredByDefault = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["main"] = new C.Service + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.example.test" }, + BearerRoutes = [Route()], + })); + _verificationRequiredByDefaultAndAcceptedWithout = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["main"] = new C.Service + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.example.test" }, + BearerRoutes = [new C.BearerRoute + { + PathPrefix = "/mcp", + Issuers = [new C.BearerIssuer { Issuer = "https://auth.example.test/" }], + Audiences = ["direct-api"], + AcceptWithoutIdentityVerification = true, + }], + })); + _verificationRequired = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["main"].IdentityVerification = C.IdentityVerificationMode.Required)); + _verificationRequiredByAnotherService = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = RequiringVerification(new C.Service { Backend = new C.ServiceEndpoint { BaseUrl = "https://other.example.test" } }))); + _verificationRequiredAndAcceptedWithout = validator.Validate(null, Configuration(_ => _.AcceptWithoutIdentityVerification = true, adjustConfiguration: _ => _.Services["main"].IdentityVerification = C.IdentityVerificationMode.Required)); + _withRouteRequirement = validator.Validate(null, Configuration(_ => + { + _.IgnoreDeploymentRequiredClaims = true; + _.RequiredClaims = [new C.ClaimRequirement { Claim = "urn:cratis:membership", AnyOf = ["direct"] }]; + })); + _withRouteRequirementNamingNoClaim = validator.Validate(null, Configuration(_ => _.RequiredClaims = [new C.ClaimRequirement { Claim = " ", AnyOf = ["direct"] }])); + _withRouteRequirementOnARole = validator.Validate(null, Configuration(_ => _.RequiredClaims = [new C.ClaimRequirement { Claim = "roles", AnyOf = ["admin"] }])); + _verificationRequiredOfANonParticipant = validator.Validate(null, Configuration(_ => { }, adjustConfiguration: _ => _.Services["other"] = RequiringVerification(new C.Service { Backend = new C.ServiceEndpoint { BaseUrl = "https://other.example.test" }, ResolveIdentityDetails = false }))); + } + + [Fact] void should_refuse_metadata_at_a_case_variant_of_the_bearer_prefix() => _metadataAtBearerPrefix.Failed.ShouldBeTrue(); + [Fact] void should_refuse_metadata_under_a_bearer_prefix() => _metadataUnderBearerPrefix.Failed.ShouldBeTrue(); + [Fact] void should_refuse_metadata_under_a_later_declared_route_of_another_service() => _metadataUnderLaterBearerPrefix.Failed.ShouldBeTrue(); + [Fact] void should_allow_metadata_on_a_similar_but_distinct_segment() => _metadataAtSimilarPrefix.Succeeded.ShouldBeTrue(); + [Fact] void should_accept_a_complete_route() => _valid.Succeeded.ShouldBeTrue(); + [Fact] void should_leave_a_deployment_without_bearer_routes_alone() => _withoutRoutes.Succeeded.ShouldBeTrue(); + [Fact] void should_refuse_a_route_without_an_audience() => _withoutAudience.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_plain_http_issuer_off_loopback() => _withPlainHttpIssuer.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_route_overlapping_an_anonymous_path() => _overlappingAnonymousPath.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_route_on_a_service_without_a_backend() => _withoutBackend.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_clock_skew_beyond_the_maximum() => _withLargeClockSkew.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_mapping_into_a_claim_authproxy_owns() => _mappingIntoReservedClaim.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_scope_that_is_not_a_scope_token() => _withQuotedScope.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_route_without_an_issuer() => _withoutIssuers.Failed.ShouldBeTrue(); + [Fact] void should_refuse_the_root_as_a_prefix() => _withRootPrefix.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_prefix_under_a_path_the_proxy_owns() => _withProxyOwnedPrefix.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_plain_http_resource_metadata_url_off_loopback() => _withPlainHttpResourceMetadata.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_prefix_another_service_already_routes() => _prefixOfAnotherService.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_resource_metadata_path_another_service_already_serves() => _resourceMetadataOfAnotherService.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_mapping_with_an_empty_claim_type() => _withEmptyMapping.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_mapping_into_a_role_claim() => _mappingIntoRoles.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_route_when_its_service_requires_identity_verification_by_default() => _verificationRequiredByDefault.Failed.ShouldBeTrue(); + [Fact] void should_accept_a_route_accepting_callers_without_the_default_identity_verification() => _verificationRequiredByDefaultAndAcceptedWithout.Succeeded.ShouldBeTrue(); + [Fact] void should_refuse_a_route_when_its_service_requires_identity_verification() => _verificationRequired.Failed.ShouldBeTrue(); + [Fact] void should_name_the_setting_that_accepts_it() => _verificationRequired.FailureMessage.ShouldContain(nameof(C.BearerRoute.AcceptWithoutIdentityVerification)); + [Fact] void should_refuse_a_route_when_another_service_requires_identity_verification() => _verificationRequiredByAnotherService.Failed.ShouldBeTrue(); + [Fact] void should_accept_a_route_that_accepts_callers_without_identity_verification() => _verificationRequiredAndAcceptedWithout.Succeeded.ShouldBeTrue(); + [Fact] void should_accept_a_route_declaring_its_own_claim_requirements() => _withRouteRequirement.Succeeded.ShouldBeTrue(); + [Fact] void should_refuse_a_route_requirement_naming_no_claim() => _withRouteRequirementNamingNoClaim.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_route_requirement_on_a_role_a_token_never_carries() => _withRouteRequirementOnARole.Failed.ShouldBeTrue(); + [Fact] void should_ignore_a_verification_requirement_of_a_service_that_resolves_no_identity() => _verificationRequiredOfANonParticipant.Succeeded.ShouldBeTrue(); + + [Fact] void should_refuse_a_mapping_into_a_case_variant_of_the_tenant_claim() => _mappingIntoTenant.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_mapping_into_the_effective_custom_tenant_claim() => _mappingIntoCustomTenant.Failed.ShouldBeTrue(); + [Fact] void should_refuse_a_mapping_into_the_default_tenant_when_unset() => _mappingIntoDefaultTenant.Failed.ShouldBeTrue(); + [Fact] void should_refuse_mapping_targets_differing_only_by_case() => _conflictingMappingTargets.Failed.ShouldBeTrue(); + + static C.AuthProxy Configuration( + Action adjust, + bool backend = true, + bool declareRoute = true, + Action? adjustConfiguration = null) + { + var route = Route(); + adjust(route); + + var service = Service(declareRoute ? route : null); + service.AnonymousPaths = ["/public"]; + if (!backend) + { + service.Backend = null; + } + + var configuration = new C.AuthProxy + { + Services = new Dictionary { ["main"] = service }, + }; + adjustConfiguration?.Invoke(configuration); + + return configuration; + } + + static C.BearerRoute Route(string prefix = "/mcp") => new() + { + PathPrefix = prefix, + Issuers = [new C.BearerIssuer { Issuer = "https://auth.example.test/" }], + Audiences = ["direct-api"], + RequiredScopes = ["direct:read"], + ResourceMetadataUrl = "https://direct.example.test/.well-known/oauth-protected-resource/mcp", + }; + + static C.Service Service(C.BearerRoute? route) => new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.example.test" }, + IdentityVerification = C.IdentityVerificationMode.BestEffort, + BearerRoutes = route is null ? [] : [route], + }; + + static C.Service RequiringVerification(C.Service service) + { + service.IdentityVerification = C.IdentityVerificationMode.Required; + return service; + } +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteForwarder/when_activity_timeouts_are_configured.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteForwarder/when_activity_timeouts_are_configured.cs new file mode 100644 index 00000000..7e8a3d7b --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteForwarder/when_activity_timeouts_are_configured.cs @@ -0,0 +1,94 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Yarp.ReverseProxy.Forwarder; +using Yarp.ReverseProxy.Transforms.Builder; + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerRouteForwarder; + +public class when_activity_timeouts_are_configured +{ + [Theory] + [InlineData(null, null, null, 300)] + [InlineData(900, null, null, 900)] + [InlineData(900, 600, null, 600)] + [InlineData(900, 600, 45, 45)] + public async Task should_apply_backend_then_service_then_root_then_default_precedence(int? root, int? service, int? backend, int expected) + { + var timeouts = await CaptureTimeouts(Configuration(root, service, backend)); + timeouts.Single().ShouldEqual(TimeSpan.FromSeconds(expected)); + } + + [Theory] + [InlineData(900, null, null, 1200, null, null, 1200)] + [InlineData(900, 600, null, 900, 120, null, 120)] + [InlineData(900, 600, 45, 900, 600, 1800, 1800)] + [InlineData(900, 600, 45, 900, 600, null, 600)] + public async Task should_use_reloaded_configuration_for_the_next_request(int? root, int? service, int? backend, int? nextRoot, int? nextService, int? nextBackend, int expected) + { + var timeouts = await CaptureTimeouts(Configuration(root, service, backend), Configuration(nextRoot, nextService, nextBackend)); + timeouts[^1].ShouldEqual(TimeSpan.FromSeconds(expected)); + } + + static async Task> CaptureTimeouts(C.AuthProxy initial, C.AuthProxy? reloaded = null) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddReverseProxy(); + await using var provider = services.BuildServiceProvider(); + var monitor = Substitute.For>(); + monitor.CurrentValue.Returns(initial); + var transport = Substitute.For(); + var timeouts = new List(); + transport.SendAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any()).Returns(call => + { + timeouts.Add(call.Arg().ActivityTimeout); + return ValueTask.FromResult(ForwarderError.None); + }); + + using var forwarder = new BearerRouteForwarder( + transport, + provider.GetRequiredService(), + provider.GetRequiredService(), + monitor, + NullLogger.Instance); + var route = BearerRouteTable.All(initial).Single(); + await forwarder.Forward(new DefaultHttpContext(), route, identity: null); + if (reloaded is not null) + { + monitor.CurrentValue.Returns(reloaded); + await forwarder.Forward(new DefaultHttpContext(), BearerRouteTable.All(reloaded).Single(), identity: null); + } + + return timeouts; + } + + static C.AuthProxy Configuration(int? root, int? service, int? backend) => new() + { + ActivityTimeout = Seconds(root), + Services = new Dictionary + { + ["app"] = new() + { + ActivityTimeout = Seconds(service), + Backend = new C.ServiceEndpoint { BaseUrl = "https://backend.example.test/", ActivityTimeout = Seconds(backend) }, + BearerRoutes = [new C.BearerRoute + { + PathPrefix = "/mcp", + Issuers = [new C.BearerIssuer { Issuer = "https://issuer.example.test/" }], + Audiences = ["api"], + AcceptWithoutIdentityVerification = true, + }], + }, + }, + }; + + static TimeSpan? Seconds(int? value) => value is { } seconds ? TimeSpan.FromSeconds(seconds) : null; +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_does_not_consult_identity_verification.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_does_not_consult_identity_verification.cs new file mode 100644 index 00000000..136f3c09 --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_does_not_consult_identity_verification.cs @@ -0,0 +1,66 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerRouteWarnings; + +/// +/// A bearer route never calls /.cratis/me, so the 403 veto a best-effort service +/// applies to a browser session never applies to a token. That is reported at startup, naming every service whose +/// veto is not applied, unless the route states it accepts it — and it is not reported when no service would have +/// been asked. +/// +public class when_a_route_does_not_consult_identity_verification : Specification +{ + BearerRouteWarning[] _notAccepting; + BearerRouteWarning[] _accepting; + BearerRouteWarning[] _nothingConsulted; + + void Because() + { + _notAccepting = Warnings(accept: false, participates: true); + _accepting = Warnings(accept: true, participates: true); + _nothingConsulted = Warnings(accept: false, participates: false); + } + + [Fact] void should_report_the_route() => _notAccepting.Length.ShouldEqual(1); + [Fact] void should_name_the_route() => _notAccepting[0].Prefix.ShouldEqual("/mcp"); + [Fact] void should_name_every_service_whose_veto_is_not_applied() => _notAccepting[0].Subjects.ShouldContainOnly(["direct", "lobby"]); + [Fact] void should_not_report_a_route_accepting_callers_without_it() => _accepting.ShouldBeEmpty(); + [Fact] void should_not_report_a_route_when_no_service_is_consulted() => _nothingConsulted.ShouldBeEmpty(); + + static BearerRouteWarning[] Warnings(bool accept, bool participates) => + [.. BearerRouteWarnings.For(Configuration(accept, participates)) + .Where(_ => _.Kind == BearerRouteWarningKind.IdentityVerificationNotConsulted)]; + + static C.AuthProxy Configuration(bool accept, bool participates) => new() + { + Services = new Dictionary + { + ["direct"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://direct.example.test" }, + ResolveIdentityDetails = participates, + BearerRoutes = + [ + new() + { + PathPrefix = "/mcp", + Issuers = [new C.BearerIssuer { Issuer = "https://auth.example.test/" }], + Audiences = ["direct-api"], + AcceptWithoutIdentityVerification = accept, + }, + ], + }, + ["lobby"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://lobby.example.test" }, + ResolveIdentityDetails = participates, + }, + ["static"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://static.example.test" }, + ResolveIdentityDetails = false, + }, + }, + }; +} diff --git a/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_ignores_the_deployment_requirements.cs b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_ignores_the_deployment_requirements.cs new file mode 100644 index 00000000..fb19cfb4 --- /dev/null +++ b/Source/AuthProxy.Specs/BearerRoutes/for_BearerRouteWarnings/when_a_route_ignores_the_deployment_requirements.cs @@ -0,0 +1,61 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes.for_BearerRouteWarnings; + +/// +/// Leaving the deployment's claim requirements out on a bearer route admits callers a browser session would not be +/// admitted as, so it is reported at startup, naming every requirement left out. A route that leaves out nothing — +/// because nothing is declared, or because it does not ask to — is not reported. +/// +public class when_a_route_ignores_the_deployment_requirements : Specification +{ + BearerRouteWarning[] _ignoring; + BearerRouteWarning[] _ignoringNothingDeclared; + BearerRouteWarning[] _applying; + + void Because() + { + _ignoring = [.. BearerRouteWarnings.For(Configuration(ignore: true, declareRequirements: true)) + .Where(_ => _.Kind == BearerRouteWarningKind.DeploymentRequirementsIgnored)]; + _ignoringNothingDeclared = [.. BearerRouteWarnings.For(Configuration(ignore: true, declareRequirements: false)) + .Where(_ => _.Kind == BearerRouteWarningKind.DeploymentRequirementsIgnored)]; + _applying = [.. BearerRouteWarnings.For(Configuration(ignore: false, declareRequirements: true)) + .Where(_ => _.Kind == BearerRouteWarningKind.DeploymentRequirementsIgnored)]; + } + + [Fact] void should_report_the_route() => _ignoring.Length.ShouldEqual(1); + [Fact] void should_name_the_service() => _ignoring[0].ServiceName.ShouldEqual("direct"); + [Fact] void should_name_the_prefix() => _ignoring[0].Prefix.ShouldEqual("/mcp"); + [Fact] void should_name_the_root_and_service_requirements_left_out() => _ignoring[0].Subjects.ShouldContainOnly(["urn:github:team", "urn:github:organization"]); + [Fact] void should_not_report_a_route_when_nothing_is_required() => _ignoringNothingDeclared.ShouldBeEmpty(); + [Fact] void should_not_report_a_route_applying_the_requirements() => _applying.ShouldBeEmpty(); + + static C.AuthProxy Configuration(bool ignore, bool declareRequirements) => new() + { + Authorization = declareRequirements + ? new C.Authorization { RequiredClaims = [new C.ClaimRequirement { Claim = "urn:github:team", AnyOf = ["cratis/core"] }] } + : new C.Authorization(), + Services = new Dictionary + { + ["direct"] = new() + { + Backend = new C.ServiceEndpoint { BaseUrl = "https://direct.example.test" }, + ResolveIdentityDetails = false, + Authorization = declareRequirements + ? new C.Authorization { RequiredClaims = [new C.ClaimRequirement { Claim = "urn:github:organization", AnyOf = ["cratis"] }] } + : null, + BearerRoutes = + [ + new() + { + PathPrefix = "/mcp", + Issuers = [new C.BearerIssuer { Issuer = "https://auth.example.test/" }], + Audiences = ["direct-api"], + IgnoreDeploymentRequiredClaims = ignore, + }, + ], + }, + }, + }; +} diff --git a/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_a_session_carries_bearer_route_claims.cs b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_a_session_carries_bearer_route_claims.cs new file mode 100644 index 00000000..dd2fc0e3 --- /dev/null +++ b/Source/AuthProxy.Specs/Identity/for_ClientPrincipalExtensions/when_a_session_carries_bearer_route_claims.cs @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.BearerRoutes; + +namespace Cratis.AuthProxy.Identity.for_ClientPrincipalExtensions; + +/// +/// The urn:cratis:bearer: claims tell a backend a request was authenticated on a bearer route, with +/// the scopes and client the token was validated for. A browser session's identity provider does not get to say +/// that, so those claims never reach the forwarded principal of a session. +/// +public class when_a_session_carries_bearer_route_claims : Specification +{ + DefaultHttpContext _context; + ClientPrincipal? _result; + + void Establish() + { + _context = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim("sub", "subject-id"), + new Claim(BearerRouteClaims.Scope, "direct:admin"), + new Claim(BearerRouteClaims.ClientId, "trusted-client"), + ], + "cookie")), + }; + } + + void Because() => _result = _context.BuildClientPrincipal(); + + [Fact] void should_keep_the_session_claims() => Assert.Contains(_result!.Claims, _ => _.Type == "sub"); + [Fact] void should_not_forward_bearer_route_claims() => Assert.DoesNotContain(_result!.Claims, _ => BearerRouteClaims.IsReserved(_.Type)); +} diff --git a/Source/AuthProxy/Authorization/AccessPolicy.cs b/Source/AuthProxy/Authorization/AccessPolicy.cs index d09d8c55..ddfad7e9 100644 --- a/Source/AuthProxy/Authorization/AccessPolicy.cs +++ b/Source/AuthProxy/Authorization/AccessPolicy.cs @@ -39,9 +39,24 @@ public AccessDecision Evaluate(HttpContext context, C.AuthProxy config) } service ??= NamedService(context, config); - foreach (var requirement in RequirementsFor(service, config)) + return Evaluate(context.User, RequirementsFor(config, service)); + } + + /// + public AccessDecision Evaluate(ClaimsPrincipal user, C.AuthProxy config, string serviceName, C.BearerRoute route) + { + var requirements = route.IgnoreDeploymentRequiredClaims + ? route.RequiredClaims + : RequirementsFor(config, FindService(config, serviceName)).Concat(route.RequiredClaims); + + return Evaluate(user, requirements); + } + + static AccessDecision Evaluate(ClaimsPrincipal user, IEnumerable requirements) + { + foreach (var requirement in requirements) { - if (!IsSatisfied(requirement, context.User)) + if (!IsSatisfied(requirement, user)) { return AccessDecision.Denied(requirement.Claim); } @@ -53,10 +68,10 @@ public AccessDecision Evaluate(HttpContext context, C.AuthProxy config) /// /// Gets every requirement that applies to a request: the root's, then the target service's. /// - /// The targeted service, if any. /// The auth proxy configuration to read. + /// The targeted service, if any. /// The applicable requirements, root-first. - static IEnumerable RequirementsFor(C.Service? service, C.AuthProxy config) + static IEnumerable RequirementsFor(C.AuthProxy config, C.Service? service) { foreach (var requirement in config.Authorization.RequiredClaims) { diff --git a/Source/AuthProxy/Authorization/IAccessPolicy.cs b/Source/AuthProxy/Authorization/IAccessPolicy.cs index 0dbb8e84..a82fdd9d 100644 --- a/Source/AuthProxy/Authorization/IAccessPolicy.cs +++ b/Source/AuthProxy/Authorization/IAccessPolicy.cs @@ -1,6 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +using System.Security.Claims; using C = Cratis.AuthProxy.Configuration; namespace Cratis.AuthProxy.Authorization; @@ -29,4 +30,20 @@ public interface IAccessPolicy /// The auth proxy configuration to read. /// The for this caller and request. AccessDecision Evaluate(HttpContext context, C.AuthProxy config); + + /// + /// Evaluates the claim requirements of a bearer route against the principal of the token presented on it. + /// + /// The token's principal, after the route's claim mappings. + /// The auth proxy configuration to read. + /// The name of the service the route belongs to. + /// The bearer route the token was presented on. + /// The for this principal on this route. + /// + /// A bearer route belongs to exactly one service, whatever the request names, so its target is not worked out + /// from the request. The root requirements and the service's apply as they do to a browser session, unless the + /// route sets ; the route's own + /// always apply on top. + /// + AccessDecision Evaluate(ClaimsPrincipal user, C.AuthProxy config, string serviceName, C.BearerRoute route); } diff --git a/Source/AuthProxy/BearerRoutes/BearerChallenge.cs b/Source/AuthProxy/BearerRoutes/BearerChallenge.cs new file mode 100644 index 00000000..7a86f42b --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerChallenge.cs @@ -0,0 +1,145 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Net.Http.Headers; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Writes the API-style refusals of a bearer route: RFC 6750 challenges carrying the RFC 9728 +/// resource_metadata parameter an MCP client discovers its authorization server from. +/// +/// +/// Never a redirect and never a page: a bearer route is called by programs, and a program told to go and sign +/// in somewhere has nothing to do with the answer. Every refusal is marked no-store so no cache +/// can hand one caller's refusal to another. The response body is empty; why a token was refused is logged, +/// never told to the caller. +/// +public static class BearerChallenge +{ + /// + /// The seconds a client is asked to wait when the issuer's keys could not be retrieved. + /// + public const int IssuerUnavailableRetryAfterSeconds = 30; + + /// + /// Writes the refusal matching a failed validation. + /// + /// The current . + /// The failed validation. + /// The route the token was presented on. + public static void Write(HttpContext context, BearerTokenValidation validation, ResolvedBearerRoute route) + { + switch (validation.Status) + { + case BearerTokenValidationStatus.Missing: + // RFC 6750 §3.1: a request that carried no credentials gets no error code. + Unauthorized(context, route.ResourceMetadataUrl, error: null); + break; + + case BearerTokenValidationStatus.InsufficientScope: + Challenge( + context, + StatusCodes.Status403Forbidden, + route.ResourceMetadataUrl, + "insufficient_scope", + string.Join(' ', BearerTokenValidator.RequiredScopes(route))); + break; + + case BearerTokenValidationStatus.MissingTenant: + Forbidden(context); + break; + + case BearerTokenValidationStatus.IssuerUnavailable: + // The token could not be checked, which is not the same as the token being wrong: telling the + // client its token is invalid would send it to sign in again for an outage on the issuer's side. + NoStore(context); + context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable; + context.Response.Headers.RetryAfter = IssuerUnavailableRetryAfterSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture); + break; + + default: + Unauthorized(context, route.ResourceMetadataUrl, "invalid_token"); + break; + } + } + + /// + /// Writes a 401 challenge. + /// + /// The current . + /// The protected-resource metadata URL to name, if any. + /// The RFC 6750 error code, or for a request that carried no token. + public static void Unauthorized(HttpContext context, Uri? resourceMetadataUrl, string? error) => + Challenge(context, StatusCodes.Status401Unauthorized, resourceMetadataUrl, error, scope: null); + + /// + /// Writes a bare 403: the token is valid, and the request is still refused. + /// + /// The current . + public static void Forbidden(HttpContext context) + { + NoStore(context); + context.Response.StatusCode = StatusCodes.Status403Forbidden; + } + + /// + /// Writes a bare 400: the request path is not one AuthProxy will vouch for a principal on. + /// + /// The current . + public static void BadRequest(HttpContext context) + { + NoStore(context); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + } + + /// + /// Builds the value of a WWW-Authenticate bearer challenge. + /// + /// The protected-resource metadata URL to name, if any. + /// The RFC 6750 error code, if any. + /// The scopes the request needs, if any. + /// The header value. + /// + /// Every value placed in a quoted string here is either a constant, a URL that was parsed as an absolute URI + /// at startup, or a scope validated against the RFC 6749 scope-token grammar — none can carry a quote or a + /// backslash, so none can break out of its parameter. + /// + public static string ChallengeValue(Uri? resourceMetadataUrl, string? error, string? scope) + { + var parameters = new List(); + if (error is not null) + { + parameters.Add($"error=\"{error}\""); + } + + if (!string.IsNullOrEmpty(scope)) + { + parameters.Add($"scope=\"{scope}\""); + } + + if (resourceMetadataUrl is not null) + { + parameters.Add($"resource_metadata=\"{resourceMetadataUrl.AbsoluteUri}\""); + } + + return parameters.Count == 0 ? "Bearer" : $"Bearer {string.Join(", ", parameters)}"; + } + + /// + /// Prevents caching a bearer-route refusal. + /// + /// The current HTTP context. + internal static void NoStore(HttpContext context) + { + context.Response.Headers.CacheControl = "no-store"; + context.Response.Headers[HeaderNames.Pragma] = "no-cache"; + } + + static void Challenge(HttpContext context, int statusCode, Uri? resourceMetadataUrl, string? error, string? scope) + { + NoStore(context); + context.Response.StatusCode = statusCode; + context.Response.Headers.WWWAuthenticate = ChallengeValue(resourceMetadataUrl, error, scope); + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs b/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs new file mode 100644 index 00000000..08933470 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerForwardedIdentity.cs @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Identity; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents what AuthProxy vouches for when it forwards a request authenticated on a bearer route. +/// +/// The principal sent in the Microsoft Identity Platform headers. +/// The tenant from the token, sent as . +/// The granted scopes, sent as . +/// The client the token was issued to, sent as , when the token names one. +/// Whether the Authorization header travels to the backend too. +public sealed record BearerForwardedIdentity( + ClientPrincipal Principal, + string TenantId, + IReadOnlyList Scopes, + string? ClientId, + bool ForwardAuthorizationHeader) +{ + /// + /// Creates the forwarded identity for a successfully validated token. + /// + /// The successful validation. + /// The route the token was validated for. + /// The forwarded identity. + /// + /// The principal is built the way a browser session's is: the user id from sub and the user + /// details from preferred_username, then name — after the route's claim mappings + /// have applied, so a route can present the identity the backend already knows (for example the GitHub id + /// and login a GitHub browser session carries). The only roles are anonymous and + /// authenticated: a token never grants a role. + /// + public static BearerForwardedIdentity From(BearerTokenValidation validation, ResolvedBearerRoute route) + { + var user = validation.Principal!; + var subject = user.Claims.FirstOrDefault(_ => string.Equals(_.Type, "sub", StringComparison.Ordinal))?.Value ?? string.Empty; + var details = user.Claims.FirstOrDefault(_ => string.Equals(_.Type, "preferred_username", StringComparison.Ordinal))?.Value + ?? user.Claims.FirstOrDefault(_ => string.Equals(_.Type, "name", StringComparison.Ordinal))?.Value + ?? subject; + + var principal = new ClientPrincipal + { + IdentityProvider = route.IdentityProvider, + UserId = subject, + UserDetails = details, + UserRoles = ["anonymous", "authenticated"], + Claims = [.. user.Claims.Select(_ => new ClientPrincipalClaim { Type = _.Type, Value = _.Value })], + }; + + return new BearerForwardedIdentity( + principal, + validation.TenantId!, + [.. user.FindAll(BearerRouteClaims.Scope).Select(_ => _.Value)], + user.FindFirst(BearerRouteClaims.ClientId)?.Value, + route.Route.ForwardAuthorizationHeader); + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs new file mode 100644 index 00000000..151b371f --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadata.cs @@ -0,0 +1,150 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Concurrent; +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Reads and caches the signing keys bearer-token issuers publish. +/// +/// The factory for the client the metadata and JWKS are read with. +/// The logger. +/// The clock used for retrieval deadlines. +/// +/// Each issuer keeps its last good keys. Requested refreshes finish before keys are returned, so a token naming +/// a newly rotated key can be retried in the same request. Retrievals are serialized per issuer and failed +/// retrievals back off, including before the first success. Ordinary lookups use previously trusted keys without +/// waiting for an in-progress retrieval, and start due automatic refreshes in the background. An outage does not discard previously trusted keys. +/// Metadata naming another issuer is never cached or trusted. +/// +public sealed class BearerIssuerMetadata( + IHttpClientFactory httpClientFactory, + ILogger logger, + TimeProvider? timeProvider = null) : IBearerIssuerMetadata, IDisposable +{ + /// + /// The shortest interval between two requested refreshes of one issuer's metadata, and the failure backoff. + /// + public static readonly TimeSpan RefreshInterval = TimeSpan.FromSeconds(30); + + readonly ConcurrentDictionary<(string Issuer, string Address, bool RequireHttps), BearerIssuerMetadataCache> _caches = new(); + readonly CancellationTokenSource _shutdown = new(); + readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; + + /// + public async Task?> GetSigningKeys(ResolvedBearerIssuer issuer, CancellationToken cancellationToken, bool refresh = false) + { + var cache = CacheFor(issuer); + if (!refresh && cache.Configuration is { } available) + { + if (cache.Gate.Wait(0)) + { + if (ShouldRetrieve(cache, refresh: false)) + { + // The retrieval owns the gate until it finishes. No request, including this one, waits for it. + cache.BackgroundRefresh = Task.Run(() => RefreshInBackground(issuer, cache)); + } + else + { + cache.Gate.Release(); + } + } + + return [.. available.SigningKeys]; + } + + await cache.Gate.WaitAsync(cancellationToken); + try + { + if (ShouldRetrieve(cache, refresh)) + { + await Retrieve(issuer, cache, refresh, cancellationToken); + } + + return cache.Configuration is { } cached ? [.. cached.SigningKeys] : null; + } + finally + { + cache.Gate.Release(); + } + } + + /// + public void Dispose() + { + _shutdown.Cancel(); + foreach (var cache in _caches.Values) + { + cache.Dispose(); + } + _shutdown.Dispose(); + } + + bool ShouldRetrieve(BearerIssuerMetadataCache cache, bool refresh) + { + var now = _clock.GetUtcNow(); + return now >= cache.RetryAfter && + (cache.Configuration is null || now >= cache.AutomaticRefreshAfter || (refresh && now >= cache.RequestedRefreshAfter)); + } + + async Task RefreshInBackground(ResolvedBearerIssuer issuer, BearerIssuerMetadataCache cache) + { + try + { + await Retrieve(issuer, cache, refresh: false, _shutdown.Token); + } + catch (OperationCanceledException) when (_shutdown.IsCancellationRequested) + { + // Shutdown cancels and joins background retrievals before disposing their gates. + } + finally + { + cache.Gate.Release(); + } + } + + async Task Retrieve(ResolvedBearerIssuer issuer, BearerIssuerMetadataCache cache, bool refresh, CancellationToken cancellationToken) + { + if (refresh) + { + cache.RequestedRefreshAfter = _clock.GetUtcNow() + RefreshInterval; + } + + try + { + var configuration = await OpenIdConnectConfigurationRetriever.GetAsync(issuer.MetadataAddress, cache.Retriever, cancellationToken); + if (string.Equals(configuration.Issuer, issuer.Issuer, StringComparison.Ordinal)) + { + cache.Configuration = configuration; + cache.AutomaticRefreshAfter = _clock.GetUtcNow() + ConfigurationManager.DefaultAutomaticRefreshInterval; + } + else + { + logger.IssuerMetadataNamesAnotherIssuer(issuer.Issuer, issuer.MetadataAddress); + cache.RetryAfter = _clock.GetUtcNow() + RefreshInterval; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + cache.RetryAfter = _clock.GetUtcNow() + RefreshInterval; + logger.IssuerMetadataUnavailable(exception, issuer.Issuer, issuer.MetadataAddress); + } + } + + BearerIssuerMetadataCache CacheFor(ResolvedBearerIssuer issuer) => + _caches.GetOrAdd( + (issuer.Issuer, issuer.MetadataAddress, issuer.RequireHttps), + static (key, factory) => new BearerIssuerMetadataCache(new HttpDocumentRetriever(factory.CreateClient(BearerRouteDefaults.MetadataHttpClientName)) + { + RequireHttps = key.RequireHttps, + }), + httpClientFactory); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs new file mode 100644 index 00000000..a067b574 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerIssuerMetadataCache.cs @@ -0,0 +1,62 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.IdentityModel.Protocols; +using Microsoft.IdentityModel.Protocols.OpenIdConnect; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Holds one issuer's synchronized last-good metadata and retrieval deadlines. +/// +/// The issuer's document retriever. +internal sealed class BearerIssuerMetadataCache(IDocumentRetriever retriever) : IDisposable +{ + OpenIdConnectConfiguration? _configuration; + + /// + /// Gets the gate serializing retrievals and deadline access. + /// + internal SemaphoreSlim Gate { get; } = new(1, 1); + + /// + /// Gets the issuer's document retriever. + /// + internal IDocumentRetriever Retriever { get; } = retriever; + + /// + /// Gets or sets the last configuration naming the expected issuer. + /// + internal OpenIdConnectConfiguration? Configuration + { + get => Volatile.Read(ref _configuration); + set => Volatile.Write(ref _configuration, value); + } + + /// + /// Gets or sets the next periodic retrieval deadline. + /// + internal DateTimeOffset AutomaticRefreshAfter { get; set; } + + /// + /// Gets or sets the next allowed unknown-key refresh deadline. + /// + internal DateTimeOffset RequestedRefreshAfter { get; set; } + + /// + /// Gets or sets the next attempt after a failed retrieval. + /// + internal DateTimeOffset RetryAfter { get; set; } + + /// + /// Gets or sets the background retrieval to join before disposing its gate. + /// + internal Task? BackgroundRefresh { get; set; } + + /// + public void Dispose() + { + BackgroundRefresh?.GetAwaiter().GetResult(); + Gate.Dispose(); + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteClaims.cs b/Source/AuthProxy/BearerRoutes/BearerRouteClaims.cs new file mode 100644 index 00000000..3439daad --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteClaims.cs @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Defines the claim types AuthProxy writes into the forwarded principal of a request authenticated on a bearer +/// route. +/// +/// +/// The whole urn:cratis:bearer: namespace is AuthProxy's own. It is removed from every token before +/// AuthProxy writes its values, and removed from every principal that was not authenticated on a bearer route, so +/// a backend can rely on these claims meaning exactly what is documented here and on their presence meaning the +/// request came through a bearer route. +/// +public static class BearerRouteClaims +{ + /// + /// The claim type for the validated issuer of the access token. + /// + public const string Issuer = "urn:cratis:bearer:issuer"; + + /// + /// The claim type for the access token's own sub, kept even when a claim mapping rewrites + /// sub for the backend. + /// + public const string Subject = "urn:cratis:bearer:subject"; + + /// + /// The claim type for the client the token was issued to, from azp or, failing that, + /// client_id. This is the client's asserted identity: a public client cannot prove which program + /// is using it. + /// + public const string ClientId = "urn:cratis:bearer:client-id"; + + /// + /// The claim type for a granted scope. One claim per scope. + /// + public const string Scope = "urn:cratis:bearer:scope"; + + const string ReservedPrefix = "urn:cratis:bearer:"; + + /// + /// Determines whether a claim type belongs to the namespace AuthProxy reserves for bearer-route metadata. + /// + /// The claim type to inspect. + /// when the claim type is reserved; otherwise . + public static bool IsReserved(string claimType) => claimType.StartsWith(ReservedPrefix, StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs new file mode 100644 index 00000000..756a9411 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteConfigurationValidator.cs @@ -0,0 +1,232 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Buffers; +using Cratis.AuthProxy.Authentication; +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Refuses a configuration declaring a bearer route AuthProxy could not enforce as written. +/// +/// +/// A route that cannot be resolved is left out of , which leaves its path on the +/// browser-session model. That is closed, but invisibly so: the operator believes a token-authenticated API is +/// being served and every client gets a sign-in redirect. Every such mistake is named here, at startup, instead. +/// +public class BearerRouteConfigurationValidator : IValidateOptions +{ + /// + /// The RFC 6749 §3.3 scope-token characters: %x21 / %x23-5B / %x5D-7E. Excluding the quote and the backslash + /// also keeps every scope safe to quote in a WWW-Authenticate parameter. + /// + static readonly SearchValues _scopeCharacters = SearchValues.Create( + "!#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_`abcdefghijklmnopqrstuvwxyz{|}~"); + + /// + public ValidateOptionsResult Validate(string? name, C.AuthProxy options) + { + var failures = new List(); + var prefixes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var metadataPaths = new Dictionary(StringComparer.OrdinalIgnoreCase); + var anonymousPaths = options.Services.Values.SelectMany(AnonymousPaths.For).ToArray(); + + foreach (var (serviceName, service) in options.Services) + { + for (var index = 0; index < service.BearerRoutes.Count; index++) + { + var route = service.BearerRoutes[index]; + var at = $"{C.AuthProxy.SectionKey}:{nameof(C.AuthProxy.Services)}:{serviceName}:{nameof(C.Service.BearerRoutes)}:{index}"; + ValidateRoute(at, serviceName, service, route, prefixes, metadataPaths, anonymousPaths, failures); + + if (service.StripPathPrefix) + { + failures.Add( + $"{at}: bearer routes cannot be used with {nameof(C.Service.StripPathPrefix)}. " + + "A browser-session path with the service prefix removed could reach the same backend resource without the bearer policy. " + + $"Set {nameof(C.Service.StripPathPrefix)} to false and serve the full external path at the backend, or remove the bearer routes."); + } + + if (AnonymousPathPolicy.Evaluate(route.PathPrefix, out var prefix) == AnonymousPathRejection.None) + { + foreach (var (otherName, otherService) in options.Services.Where(_ => !string.Equals(_.Key, serviceName, StringComparison.OrdinalIgnoreCase))) + { + if (AnonymousPathPolicy.Evaluate(otherService.PathPrefix, out var otherPrefix) == AnonymousPathRejection.None + && Overlaps(prefix, otherPrefix)) + { + failures.Add( + $"{at}:{nameof(C.BearerRoute.PathPrefix)} '{prefix}' overlaps service '{otherName}' {nameof(C.Service.PathPrefix)} '{otherPrefix}'. " + + "Bearer routes claim their paths on every host; use non-overlapping prefixes."); + } + } + } + + if (options.RequiresIdentityVerification && !route.AcceptWithoutIdentityVerification) + { + failures.Add( + $"{at}: the deployment requires identity verification ({nameof(C.IdentityVerificationMode)}.{nameof(C.IdentityVerificationMode.Required)}), " + + "which a bearer route does not perform: it has no browser session to verify through /.cratis/me. Set " + + $"{nameof(C.BearerRoute.AcceptWithoutIdentityVerification)} on the route to accept its callers on the token and " + + "the claim requirements alone, or remove the route."); + } + } + } + + // Check after collecting every prefix so declaration order and service ownership cannot hide a conflict. + foreach (var metadataPath in metadataPaths.Keys) + { + var conflictingPrefix = prefixes.Keys.FirstOrDefault(_ => new PathString(metadataPath).StartsWithSegments(_, StringComparison.OrdinalIgnoreCase)); + if (conflictingPrefix is not null) + { + failures.Add($"{nameof(C.BearerRoute.ResourceMetadataUrl)} path '{metadataPath}' is under bearer route '{conflictingPrefix}'. Resource metadata is forwarded without authentication and must be outside every bearer route."); + } + } + + return failures.Count == 0 ? ValidateOptionsResult.Success : ValidateOptionsResult.Fail(failures); + } + + static void ValidateRoute( + string at, + string serviceName, + C.Service service, + C.BearerRoute route, + Dictionary prefixes, + Dictionary metadataPaths, + string[] anonymousPaths, + List failures) + { + if (service.Backend is null || string.IsNullOrWhiteSpace(service.Backend.BaseUrl)) + { + failures.Add($"{at}: the service declares no backend, so a bearer route has nothing to forward to."); + } + + var rejection = AnonymousPathPolicy.Evaluate(route.PathPrefix, out var prefix); + if (rejection != AnonymousPathRejection.None) + { + failures.Add($"{at}:{nameof(C.BearerRoute.PathPrefix)} '{route.PathPrefix}' is not usable ({rejection})."); + } + else + { + if (!prefixes.TryAdd(prefix, serviceName)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.PathPrefix)} '{prefix}' is already a bearer route of service '{prefixes[prefix]}'."); + } + + var overlapping = anonymousPaths.FirstOrDefault(_ => Overlaps(_, prefix)); + if (overlapping is not null) + { + failures.Add($"{at}:{nameof(C.BearerRoute.PathPrefix)} '{prefix}' overlaps the anonymous path '{overlapping}'. A path cannot be both."); + } + } + + if (route.Issuers.Count == 0) + { + failures.Add($"{at}:{nameof(C.BearerRoute.Issuers)} is empty. A bearer route must name the authorization server whose tokens it accepts."); + } + + for (var i = 0; i < route.Issuers.Count; i++) + { + if (!BearerRouteTable.TryResolveIssuer(route.Issuers[i], out _)) + { + failures.Add( + $"{at}:{nameof(C.BearerRoute.Issuers)}:{i}: the issuer and its metadata address must be absolute HTTPS URIs without query, " + + "fragment or user information (plain HTTP is accepted only on a loopback host, and then for both)."); + } + } + + if (route.Audiences.All(string.IsNullOrWhiteSpace)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.Audiences)} is empty. A bearer route must name the audience its tokens are issued for."); + } + + foreach (var scope in route.RequiredScopes.Where(_ => !string.IsNullOrWhiteSpace(_)).Select(_ => _.Trim())) + { + if (scope.AsSpan().IndexOfAnyExcept(_scopeCharacters) >= 0) + { + failures.Add($"{at}:{nameof(C.BearerRoute.RequiredScopes)} '{scope}' is not a valid OAuth scope."); + } + } + + ValidateResourceMetadata(at, serviceName, route, metadataPaths, failures); + + if (route.ClockSkew is { } skew && (skew < TimeSpan.Zero || skew > C.BearerRoute.MaximumClockSkew)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.ClockSkew)} must be between zero and {C.BearerRoute.MaximumClockSkew}."); + } + + for (var i = 0; i < route.RequiredClaims.Count; i++) + { + var claim = route.RequiredClaims[i].Claim?.Trim(); + if (string.IsNullOrEmpty(claim)) + { + failures.Add( + $"{at}:{nameof(C.BearerRoute.RequiredClaims)}:{i}:{nameof(C.ClaimRequirement.Claim)} must name a claim type. " + + "A requirement without one can never be satisfied and would refuse every token."); + } + else if (RoleClaims.Is(claim)) + { + failures.Add( + $"{at}:{nameof(C.BearerRoute.RequiredClaims)}:{i} requires the role claim '{claim}', which a bearer token never carries: " + + "AuthProxy drops role claims from every token, so the requirement would refuse every token."); + } + } + + var mappingTargets = new HashSet(StringComparer.OrdinalIgnoreCase); + var tenantClaim = string.IsNullOrWhiteSpace(route.TenantClaimType) ? C.BearerRoute.DefaultTenantClaimType : route.TenantClaimType.Trim(); + foreach (var (target, source) in route.ClaimMappings) + { + if (string.IsNullOrWhiteSpace(target) || string.IsNullOrWhiteSpace(source)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.ClaimMappings)} has a mapping with an empty claim type."); + } + else if (CanonicalIdentityClaims.IsReserved(target) || BearerRouteClaims.IsReserved(target) || RoleClaims.Is(target)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.ClaimMappings)} may not write '{target}': AuthProxy owns that claim type."); + } + else if (string.Equals(target, tenantClaim, StringComparison.OrdinalIgnoreCase)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.ClaimMappings)} may not write '{target}': the tenant must remain the token's original tenant claim."); + } + + if (!mappingTargets.Add(target)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.ClaimMappings)} has conflicting targets differing only by case: '{target}'."); + } + } + } + + static void ValidateResourceMetadata( + string at, + string serviceName, + C.BearerRoute route, + Dictionary metadataPaths, + List failures) + { + if (string.IsNullOrWhiteSpace(route.ResourceMetadataUrl)) + { + return; + } + + if (!BearerRouteTable.TryParseAuthorityUri(route.ResourceMetadataUrl, out var metadataUrl, out _) + || AnonymousPathPolicy.Evaluate(metadataUrl.AbsolutePath, out var metadataPath) != AnonymousPathRejection.None) + { + failures.Add( + $"{at}:{nameof(C.BearerRoute.ResourceMetadataUrl)} '{route.ResourceMetadataUrl}' must be an absolute HTTPS URL without query, " + + "fragment or user information, whose path AuthProxy may forward to the service."); + return; + } + + if (metadataPaths.TryGetValue(metadataPath, out var owner) && !string.Equals(owner, serviceName, StringComparison.OrdinalIgnoreCase)) + { + failures.Add($"{at}:{nameof(C.BearerRoute.ResourceMetadataUrl)} path '{metadataPath}' is already served by service '{owner}'."); + return; + } + + metadataPaths[metadataPath] = serviceName; + } + + static bool Overlaps(string first, string second) => + new PathString(first).StartsWithSegments(second) || new PathString(second).StartsWithSegments(first); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteDefaults.cs b/Source/AuthProxy/BearerRoutes/BearerRouteDefaults.cs new file mode 100644 index 00000000..2ff96509 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteDefaults.cs @@ -0,0 +1,27 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Defines constants used by bearer routes. +/// +public static class BearerRouteDefaults +{ + /// + /// The name of the HTTP client used to read issuer metadata and signing keys. + /// + public const string MetadataHttpClientName = "Cratis.AuthProxy.BearerRoutes.Metadata"; + + /// + /// The JWT signing algorithms accepted on a bearer route. Symmetric algorithms and none are + /// never accepted. + /// + public static readonly IReadOnlyList AllowedAlgorithms = ["RS256", "ES256"]; + + /// + /// The key holding the of a request + /// authenticated on a bearer route. Absent for everything else, including a protected-resource metadata request. + /// + internal const string ForwardedIdentityItemKey = "Cratis.AuthProxy.BearerRoutes.ForwardedIdentity"; +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteForwarder.cs b/Source/AuthProxy/BearerRoutes/BearerRouteForwarder.cs new file mode 100644 index 00000000..448fb8f8 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteForwarder.cs @@ -0,0 +1,80 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Options; +using Yarp.ReverseProxy.Configuration; +using Yarp.ReverseProxy.Forwarder; +using Yarp.ReverseProxy.Transforms.Builder; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Forwards bearer-route requests straight to the service backend. +/// +/// +/// A bearer route does not go through the reverse-proxy route table, because everything that table's routes are +/// guarded by — the cookie session, provider selection, tenant selection, identity verification — is exactly what +/// a bearer route must not use. It forwards with the same default transforms (X-Forwarded-*, header +/// copying), the same HTTP client configuration and the same timeouts as the table's routes, then replaces every identity header with what +/// vouches for. +/// +/// The YARP forwarder. +/// The YARP client factory, which applies the reverse proxy's own client configuration. +/// The YARP transform builder. +/// The current configuration, including the backend's activity timeout. +/// The logger. +public sealed class BearerRouteForwarder( + IHttpForwarder forwarder, + IForwarderHttpClientFactory clientFactory, + ITransformBuilder transformBuilder, + IOptionsMonitor config, + ILogger logger) : IBearerRouteForwarder, IDisposable +{ + readonly HttpTransformer _transformer = transformBuilder.Create(context => context.RequestTransforms.Add(new BearerRouteHeadersTransform())); + + readonly HttpMessageInvoker _invoker = clientFactory.CreateClient(new ForwarderHttpClientContext + { + ClusterId = "bearer-routes", + OldConfig = HttpClientConfig.Empty, + OldMetadata = null, + OldClient = null, + NewConfig = HttpClientConfig.Empty, + NewMetadata = null, + }); + + /// + public async Task Forward(HttpContext context, ResolvedBearerRoute route, BearerForwardedIdentity? identity) + { + if (identity is null) + { + context.Items.Remove(BearerRouteDefaults.ForwardedIdentityItemKey); + } + else + { + context.Items[BearerRouteDefaults.ForwardedIdentityItemKey] = identity; + } + + var current = config.CurrentValue; + current.Services.TryGetValue(route.ServiceName, out var service); + var requestConfig = new ForwarderRequestConfig + { + ActivityTimeout = service?.Backend?.ActivityTimeout + ?? service?.ActivityTimeout + ?? current.ActivityTimeout + ?? C.AuthProxy.DefaultActivityTimeout, + }; + var error = await forwarder.SendAsync(context, route.BackendBaseUrl, _invoker, requestConfig, _transformer); + if (error != ForwarderError.None) + { + logger.BearerRouteForwardingFailed( + context.Features.Get()?.Exception, + route.Prefix, + route.ServiceName, + error); + } + } + + /// + public void Dispose() => _invoker.Dispose(); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteHeadersTransform.cs b/Source/AuthProxy/BearerRoutes/BearerRouteHeadersTransform.cs new file mode 100644 index 00000000..19067506 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteHeadersTransform.cs @@ -0,0 +1,61 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Identity; +using Microsoft.Net.Http.Headers; +using Yarp.ReverseProxy.Transforms; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Replaces every identity header of a bearer-route request with what AuthProxy vouches for. +/// +/// +/// Every inbound copy of a header a backend trusts is removed first — the principal set, Tenant-ID, +/// and the token client and scope headers — so a caller cannot add to what the token says. The +/// Cookie header is removed because a bearer route does not use browser sessions, and a backend must +/// not be able to fall back to one. Authorization is removed unless the route asks for it: the +/// backend receives the principal AuthProxy vouches for, not the credential. +/// +public class BearerRouteHeadersTransform : RequestTransform +{ + /// + public override ValueTask ApplyAsync(RequestTransformContext context) + { + var headers = context.ProxyRequest.Headers; + SpoofableHeaders.Strip(headers); + headers.Remove(Headers.TokenClientId); + headers.Remove(Headers.TokenScope); + headers.Remove(HeaderNames.Cookie); + + var identity = context.HttpContext.Items.TryGetValue(BearerRouteDefaults.ForwardedIdentityItemKey, out var item) + ? item as BearerForwardedIdentity + : null; + + if (identity?.ForwardAuthorizationHeader != true) + { + headers.Authorization = null; + } + + if (identity is null) + { + return ValueTask.CompletedTask; + } + + context.ProxyRequest.SetMicrosoftIdentityHeaders(identity.Principal); + var tenantId = HeaderValue.ToTransportValue(identity.TenantId); + headers.TryAddWithoutValidation(Headers.TenantId, tenantId); + headers.TryAddWithoutValidation(Headers.LegacyTenantId, tenantId); + if (identity.Scopes.Count > 0) + { + headers.TryAddWithoutValidation(Headers.TokenScope, HeaderValue.ToTransportValue(string.Join(' ', identity.Scopes))); + } + + if (!string.IsNullOrWhiteSpace(identity.ClientId)) + { + headers.TryAddWithoutValidation(Headers.TokenClientId, HeaderValue.ToTransportValue(identity.ClientId)); + } + + return ValueTask.CompletedTask; + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs new file mode 100644 index 00000000..a0fb5061 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteMiddleware.cs @@ -0,0 +1,135 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.AuthProxy.Authorization; +using Cratis.AuthProxy.Tenancy; +using Microsoft.Extensions.Options; +using Microsoft.Net.Http.Headers; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// The gate for bearer routes: authenticates a request on a bearer route by its access token alone and forwards +/// it, and refuses a bearer-route token anywhere else. +/// +/// The next middleware in the pipeline. +/// The auth proxy configuration monitor. +/// The access-token validator. +/// The deployment's claim requirements, applied to the token's principal. +/// The tenant existence verifier, applied when tenant verification is configured. +/// The forwarder to the service backend. +/// The logger. +/// +/// Placed ahead of static files, authentication and everything after them, so a request on a bearer route never +/// reaches the cookie session, provider selection, tenant selection or the reverse-proxy route table: it is +/// answered here, by a forward or by an API-style refusal, and nothing else. The only other thing it does is +/// refuse a token from a bearer-route issuer on a path that is not one of the bearer routes, so such a token +/// cannot authenticate a browser-only surface through any other bearer support the deployment has configured. +/// +/// The deployment's claim requirements ( and the route's service's own) +/// apply here as they do to a browser session, to the principal after the route's claim mappings, unless the route +/// sets ; the route's own +/// apply on top. Identity +/// verification through /.cratis/me does not run here in either mode — not even the +/// 403 veto applies to a browser session. A +/// deployment that requires verification may declare a bearer route only when the route says it accepts callers +/// without it (), which startup validation enforces; +/// otherwise a route that does not say so is reported at startup (). +/// +/// +/// With no bearer route configured it hands every request on untouched. +/// +/// +public class BearerRouteMiddleware( + RequestDelegate next, + IOptionsMonitor config, + IBearerTokenValidator validator, + IAccessPolicy accessPolicy, + ITenantVerifier tenantVerifier, + IBearerRouteForwarder forwarder, + ILogger logger) +{ + /// + public async Task InvokeAsync(HttpContext context) + { + var current = config.CurrentValue; + if (BearerRouteTable.All(current).Count == 0) + { + await next(context); + return; + } + + // Reject aliases before selecting a route: a backend may normalize a path that would otherwise + // miss every bearer prefix and fall through to browser-session authentication. + if (!BearerRouteTable.IsUnambiguous(context.Request.Path)) + { + logger.BearerRoutePathAmbiguous(); + BearerChallenge.BadRequest(context); + return; + } + + if (BearerRouteTable.TryMatchResourceMetadata(context.Request.Path, current, out var metadataRoute)) + { + await ServeResourceMetadata(context, metadataRoute); + return; + } + + if (BearerRouteTable.TryMatch(context.Request.Path, current, out var route)) + { + await Authenticate(context, route, current); + return; + } + + if (BearerTokenValidator.TryFindPresentedIssuer(context.Request, _ => BearerRouteTable.IsBearerRouteIssuer(_, current), out var issuer)) + { + logger.BearerTokenOutsideItsRoutes(issuer, RequestPathRedaction.Redact(context.Request.Path)); + BearerChallenge.Unauthorized(context, resourceMetadataUrl: null, "invalid_token"); + return; + } + + await next(context); + } + + async Task ServeResourceMetadata(HttpContext context, ResolvedBearerRoute route) + { + if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method)) + { + BearerChallenge.NoStore(context); + context.Response.StatusCode = StatusCodes.Status405MethodNotAllowed; + context.Response.Headers[HeaderNames.Allow] = "GET, HEAD"; + return; + } + + await forwarder.Forward(context, route, identity: null); + } + + async Task Authenticate(HttpContext context, ResolvedBearerRoute route, C.AuthProxy current) + { + var validation = await validator.Validate(context.Request, route, context.RequestAborted); + if (!validation.Succeeded) + { + logger.BearerTokenRefused(route.Prefix, route.ServiceName, validation.Status, validation.Reason ?? string.Empty); + BearerChallenge.Write(context, validation, route); + return; + } + + var decision = accessPolicy.Evaluate(validation.Principal!, current, route.ServiceName, route.Route); + if (!decision.IsGranted) + { + logger.BearerAccessDenied(route.Prefix, route.ServiceName, decision.UnsatisfiedClaim); + BearerChallenge.Forbidden(context); + return; + } + + if (!await tenantVerifier.VerifyAsync(validation.TenantId!)) + { + logger.BearerTenantNotVerified(validation.TenantId!, RequestPathRedaction.Redact(context.Request.Path)); + BearerChallenge.Forbidden(context); + return; + } + + context.User = validation.Principal!; + await forwarder.Forward(context, route, BearerForwardedIdentity.From(validation, route)); + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs b/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs new file mode 100644 index 00000000..cd520bed --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Extension methods for registering bearer routes. +/// +public static class BearerRouteServiceCollectionExtensions +{ + /// + /// The timeout for reading an issuer's metadata document or JWKS. + /// + public static readonly TimeSpan MetadataTimeout = TimeSpan.FromSeconds(10); + + /// + /// Registers the services bearer routes need. Registering them changes nothing for a deployment that + /// declares no bearer route. + /// + /// The to configure. + /// The same for chaining. + /// + /// The bearer-route forwarder uses the reverse proxy's forwarder, client factory and transform builder, which + /// the reverse-proxy registration provides; they are resolved when the pipeline is built, not here. + /// + public static WebApplicationBuilder AddBearerRoutes(this WebApplicationBuilder builder) + { + builder.Services.AddHttpClient(BearerRouteDefaults.MetadataHttpClientName, client => client.Timeout = MetadataTimeout); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + builder.Services.AddSingleton, BearerRouteConfigurationValidator>(); + builder.Services.AddHostedService(); + + return builder; + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteStartupReport.cs b/Source/AuthProxy/BearerRoutes/BearerRouteStartupReport.cs new file mode 100644 index 00000000..8a67353e --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteStartupReport.cs @@ -0,0 +1,39 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Extensions.Options; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Logs, at startup, every the configuration gives rise to. +/// +/// The auth proxy configuration monitor. +/// The logger. +public sealed class BearerRouteStartupReport(IOptionsMonitor config, ILogger logger) : IHostedService +{ + /// + public Task StartAsync(CancellationToken cancellationToken) + { + foreach (var warning in BearerRouteWarnings.For(config.CurrentValue)) + { + var subjects = string.Join(", ", warning.Subjects); + switch (warning.Kind) + { + case BearerRouteWarningKind.DeploymentRequirementsIgnored: + logger.BearerRouteIgnoresDeploymentRequirements(warning.Prefix, warning.ServiceName, subjects); + break; + + case BearerRouteWarningKind.IdentityVerificationNotConsulted: + logger.BearerRouteDoesNotConsultIdentityVerification(warning.Prefix, warning.ServiceName, subjects); + break; + } + } + + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs new file mode 100644 index 00000000..eae0718f --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteTable.cs @@ -0,0 +1,292 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Resolves the bearer routes declared in . +/// +/// +/// The bearer gate and the configuration validator have to agree on which paths are bearer routes and which +/// issuers they accept, so both resolve through here. A route that cannot be resolved — an unusable prefix or +/// metadata URL, an issuer that is not an absolute HTTPS URI, a service without a backend — is left out, which +/// leaves its path on the browser-session model; refuses such a +/// configuration at startup. +/// +public static class BearerRouteTable +{ + /// + /// The path segment RFC 8414 inserts between an issuer's host and path to locate its metadata document. + /// + public const string AuthorizationServerMetadataPath = "/.well-known/oauth-authorization-server"; + + static readonly ConditionalWeakTable _resolved = []; + + /// + /// Gets every resolvable bearer route in the configuration. + /// + /// The auth proxy configuration to read. + /// The resolved routes. + public static IReadOnlyList All(C.AuthProxy config) => Resolve(config).Routes; + + /// + /// Gets the resolvable bearer routes a single service declares. + /// + /// The name of the service. + /// The service configuration. + /// The resolved routes. + public static IEnumerable For(string serviceName, C.Service service) + { + if (service.Backend is null) + { + yield break; + } + + foreach (var route in service.BearerRoutes) + { + if (TryResolve(serviceName, service.Backend.BaseUrl, route, out var resolved)) + { + yield return resolved; + } + } + } + + /// + /// Finds the bearer route covering a request path. + /// + /// The request path. + /// The auth proxy configuration to read. + /// The matching route. + /// when the path is on a bearer route; otherwise . + public static bool TryMatch(PathString path, C.AuthProxy config, out ResolvedBearerRoute route) + { + var routes = Resolve(config).Routes; + for (var i = 0; i < routes.Length; i++) + { + if (path.StartsWithSegments(routes[i].Prefix)) + { + route = routes[i]; + return true; + } + } + + route = default!; + return false; + } + + /// + /// Determines whether a request path means the same thing to AuthProxy and to any backend that reads it. + /// + /// The request path, as decoded by the server. + /// when the path carries nothing a backend could decode or normalize differently; otherwise . + /// + /// The server decodes a path before the gate sees it, except for an encoded /, and a double-encoded + /// character arrives still encoded. Either leaves a % in the path, as does anything else the + /// backend might decode once more. A backslash is a separator to some servers, and a dot segment is removed by + /// most. A semicolon starts a path parameter, which some servers strip before resolving dot segments — Tomcat, + /// Jetty and Spring read /mcp/..;/api as /api — and others keep, the same + /// rule applies to a declared prefix. A backend that decoded + /// /mcp/..%2Fapi into /api would receive a principal vouched for on a bearer route + /// at a path that is not one. Repeated separators are also refused because a backend may collapse them and + /// select a narrower route whose policy was not checked. A bearer route accepts none of these. + /// + public static bool IsUnambiguous(PathString path) + { + var value = path.Value ?? string.Empty; + if (value.Contains('%', StringComparison.Ordinal) + || value.Contains('\\', StringComparison.Ordinal) + || value.Contains(';', StringComparison.Ordinal) + || value.Contains("//", StringComparison.Ordinal)) + { + return false; + } + + foreach (var segment in value.Split('/')) + { + if (string.Equals(segment, ".", StringComparison.Ordinal) || string.Equals(segment, "..", StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + + /// + /// Finds the bearer route whose protected-resource metadata document a request path names. + /// + /// The request path. + /// The auth proxy configuration to read. + /// The route declaring that metadata path. + /// when the path is exactly a declared metadata path; otherwise . + /// + /// Matched exactly, not as a prefix: the metadata document is the one thing on a bearer route that is served + /// without a token, so nothing under it is. + /// + public static bool TryMatchResourceMetadata(PathString path, C.AuthProxy config, out ResolvedBearerRoute route) + { + var candidate = (path.Value ?? string.Empty).TrimEnd('/'); + var routes = Resolve(config).Routes; + for (var i = 0; i < routes.Length; i++) + { + if (routes[i].ResourceMetadataPath is { } metadataPath + && string.Equals(metadataPath, candidate, StringComparison.OrdinalIgnoreCase)) + { + route = routes[i]; + return true; + } + } + + route = default!; + return false; + } + + /// + /// Determines whether an issuer is accepted on any bearer route. + /// + /// The issuer identifier to look for. + /// The auth proxy configuration to read. + /// when some bearer route accepts the issuer; otherwise . + public static bool IsBearerRouteIssuer(string issuer, C.AuthProxy config) => Resolve(config).Issuers.Contains(issuer); + + /// + /// Resolves a configured issuer, applying its defaults. + /// + /// The configured issuer. + /// The resolved issuer. + /// when the issuer is usable; otherwise . + public static bool TryResolveIssuer(C.BearerIssuer issuer, out ResolvedBearerIssuer resolved) + { + resolved = default!; + + if (!TryParseAuthorityUri(issuer.Issuer, out var issuerUri, out var isLoopbackHttp)) + { + return false; + } + + string metadataAddress; + if (string.IsNullOrWhiteSpace(issuer.MetadataAddress)) + { + metadataAddress = $"{issuerUri.GetLeftPart(UriPartial.Authority)}{AuthorizationServerMetadataPath}{issuerUri.AbsolutePath.TrimEnd('/')}"; + } + else if (TryParseAuthorityUri(issuer.MetadataAddress, out var metadataUri, out var metadataIsLoopbackHttp) + && (!metadataIsLoopbackHttp || isLoopbackHttp)) + { + metadataAddress = metadataUri.AbsoluteUri; + } + else + { + return false; + } + + var tokenTypes = issuer.TokenTypes.Where(_ => !string.IsNullOrWhiteSpace(_)).Select(_ => _.Trim()).ToArray(); + resolved = new ResolvedBearerIssuer( + issuer.Issuer.Trim(), + metadataAddress, + tokenTypes.Length > 0 ? tokenTypes : C.BearerIssuer.DefaultTokenTypes, + RequireHttps: !isLoopbackHttp); + + return true; + } + + /// + /// Parses an absolute URI an authorization server or a resource is identified by. + /// + /// The configured value. + /// The parsed URI. + /// Whether the URI is a plain-HTTP loopback development address. + /// when the value is usable; otherwise . + /// + /// HTTPS is required, except on a loopback host, so neither a token's trust anchor nor the address a client is + /// told to fetch metadata from can be downgraded outside a developer's own machine. Query, fragment and user + /// information are refused because none of them belongs in an identifier. + /// + public static bool TryParseAuthorityUri(string? candidate, out Uri uri, out bool isLoopbackHttp) + { + isLoopbackHttp = false; + if (string.IsNullOrWhiteSpace(candidate) + || !Uri.TryCreate(candidate.Trim(), UriKind.Absolute, out uri!) + || !string.IsNullOrEmpty(uri.Query) + || !string.IsNullOrEmpty(uri.Fragment) + || !string.IsNullOrEmpty(uri.UserInfo)) + { + uri = default!; + return false; + } + + if (string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)) + { + return true; + } + + if (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) && uri.IsLoopback) + { + isLoopbackHttp = true; + return true; + } + + uri = default!; + return false; + } + + static bool TryResolve(string serviceName, string backendBaseUrl, C.BearerRoute route, out ResolvedBearerRoute resolved) + { + resolved = default!; + + if (AnonymousPathPolicy.Evaluate(route.PathPrefix, out var prefix) != AnonymousPathRejection.None + || route.Issuers.Count == 0 + || route.Audiences.All(string.IsNullOrWhiteSpace)) + { + return false; + } + + var issuers = new List(); + foreach (var issuer in route.Issuers) + { + if (!TryResolveIssuer(issuer, out var resolvedIssuer)) + { + return false; + } + + issuers.Add(resolvedIssuer); + } + + Uri? metadataUrl = null; + string? metadataPath = null; + if (!string.IsNullOrWhiteSpace(route.ResourceMetadataUrl)) + { + if (!TryParseAuthorityUri(route.ResourceMetadataUrl, out metadataUrl, out _) + || AnonymousPathPolicy.Evaluate(metadataUrl.AbsolutePath, out var normalizedMetadataPath) != AnonymousPathRejection.None) + { + return false; + } + + metadataPath = normalizedMetadataPath; + } + + resolved = new ResolvedBearerRoute(serviceName, backendBaseUrl, prefix, issuers, route, metadataUrl, metadataPath); + return true; + } + + static Resolution Resolve(C.AuthProxy config) => _resolved.GetValue(config, Create); + + static Resolution Create(C.AuthProxy config) + { + // Longest prefix first, so a narrower route declared under a wider one is the one that applies to it. + var routes = config.Services + .SelectMany(_ => For(_.Key, _.Value)) + .OrderByDescending(_ => _.Prefix.Length) + .ToArray(); + var issuers = routes + .SelectMany(_ => _.Issuers) + .Select(_ => _.Issuer) + .ToHashSet(StringComparer.Ordinal); + + return new Resolution(routes, issuers); + } + + sealed record Resolution(ResolvedBearerRoute[] Routes, HashSet Issuers); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteWarning.cs b/Source/AuthProxy/BearerRoutes/BearerRouteWarning.cs new file mode 100644 index 00000000..c2217bd6 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteWarning.cs @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents something a bearer route does not check that the rest of the deployment does. +/// +/// What is not checked. +/// The service the route belongs to. +/// The route's path prefix. +/// What the warning is about: the claim types left out, or the services not consulted. +public sealed record BearerRouteWarning( + BearerRouteWarningKind Kind, + string ServiceName, + string Prefix, + IReadOnlyList Subjects); diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteWarningKind.cs b/Source/AuthProxy/BearerRoutes/BearerRouteWarningKind.cs new file mode 100644 index 00000000..3ff94e4b --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteWarningKind.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents a way a bearer route admits callers a browser session in the same deployment would not be admitted +/// on, which the operator is told about at startup. +/// +public enum BearerRouteWarningKind +{ + /// + /// The route sets , leaving out claim + /// requirements the deployment declares. + /// + DeploymentRequirementsIgnored = 0, + + /// + /// Services answer /.cratis/me for browser sessions, and an HTTP 403 from one + /// refuses a browser session even under . A + /// bearer route never calls it, so that refusal never happens there, and the route does not say it accepts + /// that through . + /// + IdentityVerificationNotConsulted = 1, +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs b/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs new file mode 100644 index 00000000..afcbe55b --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRouteWarnings.cs @@ -0,0 +1,73 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Works out what each bearer route leaves unchecked that the deployment otherwise checks. +/// +/// +/// Each of these is a deliberate configuration, or the default one, which is why none of them refuses to start. +/// Each also means a caller can reach the service through the route who would be refused through a browser +/// session, which is why none of them is allowed to be silent either. +/// +/// A bearer route does not call /.cratis/me — not even for the 403 veto +/// applies to a browser session. The endpoint is written for +/// browser sessions, and a product cannot be assumed to answer it correctly for a principal authenticated by a +/// token: one that answered 403 to every principal it did not recognize would lock out every token, +/// and one that answered 200 would give the veto no meaning. The product's backend decides tenant +/// membership for a token instead, and the route says it accepts that through +/// , or is reported here. +/// +/// +public static class BearerRouteWarnings +{ + /// + /// Gets the warnings for every resolvable bearer route in the configuration. + /// + /// The auth proxy configuration to read. + /// The warnings, one per route and kind. + public static IEnumerable For(C.AuthProxy config) + { + // Every participating service is asked for a browser session, whichever service the request targets, so + // every one of them is a service whose refusal a bearer route does not get. + var consulted = config.Services + .Where(_ => _.Value.ParticipatesInIdentityResolution) + .Select(_ => _.Key) + .ToList(); + + foreach (var route in BearerRouteTable.All(config)) + { + if (consulted.Count > 0 && !route.Route.AcceptWithoutIdentityVerification) + { + yield return new BearerRouteWarning(BearerRouteWarningKind.IdentityVerificationNotConsulted, route.ServiceName, route.Prefix, consulted); + } + + if (route.Route.IgnoreDeploymentRequiredClaims) + { + var ignored = DeploymentRequirementClaims(config, route.ServiceName); + if (ignored.Count > 0) + { + yield return new BearerRouteWarning(BearerRouteWarningKind.DeploymentRequirementsIgnored, route.ServiceName, route.Prefix, ignored); + } + } + } + } + + static List DeploymentRequirementClaims(C.AuthProxy config, string serviceName) + { + var service = config.Services + .Where(_ => string.Equals(_.Key, serviceName, StringComparison.OrdinalIgnoreCase)) + .Select(_ => _.Value) + .FirstOrDefault(); + + return config.Authorization.RequiredClaims + .Concat(service?.Authorization?.RequiredClaims ?? []) + .Select(_ => _.Claim?.Trim() ?? string.Empty) + .Where(_ => _.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToList(); + } +} diff --git a/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs new file mode 100644 index 00000000..48f4e0f9 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerRoutesLogging.cs @@ -0,0 +1,37 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +internal static partial class BearerRoutesLogging +{ + [LoggerMessage(LogLevel.Warning, "The metadata at '{MetadataAddress}' does not name the configured issuer '{Issuer}'. Tokens from that issuer are refused until it does.")] + internal static partial void IssuerMetadataNamesAnotherIssuer(this ILogger logger, string issuer, string metadataAddress); + + [LoggerMessage(LogLevel.Warning, "The metadata or signing keys of issuer '{Issuer}' could not be retrieved from '{MetadataAddress}'.")] + internal static partial void IssuerMetadataUnavailable(this ILogger logger, Exception exception, string issuer, string metadataAddress); + + [LoggerMessage(LogLevel.Information, "Bearer token refused on route '{Route}' of service '{Service}' ({Status}): {Reason}")] + internal static partial void BearerTokenRefused(this ILogger logger, string route, string service, BearerTokenValidationStatus status, string reason); + + [LoggerMessage(LogLevel.Information, "A token from bearer-route issuer '{Issuer}' was presented on {Path}, which is not one of its bearer routes. Refused.")] + internal static partial void BearerTokenOutsideItsRoutes(this ILogger logger, string issuer, string path); + + [LoggerMessage(LogLevel.Information, "A request has an encoded character, a backslash, a semicolon, repeated separators or a dot segment in its path. Refused before route selection because bearer routes are configured.")] + internal static partial void BearerRoutePathAmbiguous(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "A valid bearer token on route '{Route}' of service '{Service}' does not satisfy the required claim '{Claim}'. Refused.")] + internal static partial void BearerAccessDenied(this ILogger logger, string route, string service, string claim); + + [LoggerMessage(LogLevel.Warning, "Tenant '{TenantId}' named by a bearer token on {Path} could not be verified. Refused.")] + internal static partial void BearerTenantNotVerified(this ILogger logger, string tenantId, string path); + + [LoggerMessage(LogLevel.Warning, "Bearer route '{Route}' of service '{Service}' sets IgnoreDeploymentRequiredClaims, so the deployment's claim requirements on {Claims} are not applied to its tokens. Only the route's own RequiredClaims are.")] + internal static partial void BearerRouteIgnoresDeploymentRequirements(this ILogger logger, string route, string service, string claims); + + [LoggerMessage(LogLevel.Warning, "Bearer route '{Route}' of service '{Service}' does not call /.cratis/me, so a 403 from {Services} that would refuse a browser session does not refuse its tokens; the backend must decide membership. Set AcceptWithoutIdentityVerification on the route to state that this is intended.")] + internal static partial void BearerRouteDoesNotConsultIdentityVerification(this ILogger logger, string route, string service, string services); + + [LoggerMessage(LogLevel.Warning, "Forwarding bearer route '{Route}' of service '{Service}' failed ({Error}).")] + internal static partial void BearerRouteForwardingFailed(this ILogger logger, Exception? exception, string route, string service, Yarp.ReverseProxy.Forwarder.ForwarderError error); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerTokenValidation.cs b/Source/AuthProxy/BearerRoutes/BearerTokenValidation.cs new file mode 100644 index 00000000..79d7c337 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerTokenValidation.cs @@ -0,0 +1,43 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Claims; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents the result of validating the access token on a bearer route. +/// +/// The outcome. +/// The principal to forward, only when is . +/// The tenant from the token, only when is . +/// A short, log-safe explanation of a refusal. Never sent to the caller. +public sealed record BearerTokenValidation( + BearerTokenValidationStatus Status, + ClaimsPrincipal? Principal = null, + string? TenantId = null, + string? Reason = null) +{ + /// + /// Gets a value indicating whether the token was accepted. + /// + public bool Succeeded => Status == BearerTokenValidationStatus.Succeeded; + + /// + /// Creates a successful result. + /// + /// The principal to forward. + /// The tenant from the token. + /// The result. + public static BearerTokenValidation Success(ClaimsPrincipal principal, string tenantId) => + new(BearerTokenValidationStatus.Succeeded, principal, tenantId); + + /// + /// Creates a refusal. + /// + /// The refusal outcome. + /// A short, log-safe explanation. + /// The result. + public static BearerTokenValidation Refused(BearerTokenValidationStatus status, string reason) => + new(status, Reason: reason); +} diff --git a/Source/AuthProxy/BearerRoutes/BearerTokenValidationStatus.cs b/Source/AuthProxy/BearerRoutes/BearerTokenValidationStatus.cs new file mode 100644 index 00000000..51e072ce --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerTokenValidationStatus.cs @@ -0,0 +1,28 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents the outcome of validating the access token on a bearer route. +/// +public enum BearerTokenValidationStatus +{ + /// The token is valid for the route and carries a tenant. + Succeeded = 0, + + /// The request carries no bearer token. + Missing = 1, + + /// The token is malformed, expired, wrongly signed, from another issuer or for another audience. + Invalid = 2, + + /// The token is valid but lacks a scope the route requires. + InsufficientScope = 3, + + /// The token is valid but carries no single usable tenant. + MissingTenant = 4, + + /// The issuer's metadata or signing keys could not be retrieved, so the token could not be checked. + IssuerUnavailable = 5, +} diff --git a/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs new file mode 100644 index 00000000..e85ebca0 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/BearerTokenValidator.cs @@ -0,0 +1,347 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Buffers; +using System.Security.Claims; +using Cratis.AuthProxy.Authentication; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Validates the access token presented on a bearer route and builds the principal AuthProxy forwards for it. +/// +/// The source of the issuers' signing keys. +/// +/// The token is checked strictly: the issuer must be one the route accepts and must match exactly; the signature +/// must verify against a key the issuer publishes, with an asymmetric algorithm from +/// (so neither none nor a symmetric algorithm keyed +/// with a public key is accepted); the typ header must name an access token; the audience must be one +/// the route accepts; and the token must carry an expiry and be within its lifetime, allowing only the route's small +/// clock skew. Encrypted tokens are refused. Only then are scopes and tenant looked at. +/// +public sealed class BearerTokenValidator(IBearerIssuerMetadata metadata) : IBearerTokenValidator +{ + /// + /// The longest tenant identifier accepted from a token. + /// + public const int MaximumTenantIdLength = 256; + + const string BearerPrefix = "Bearer "; + const string ScopeClaimType = "scope"; + const string SubjectClaimType = "sub"; + + static readonly SearchValues _tokenCharacters = SearchValues.Create( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~+/="); + + static readonly SearchValues _tenantCharacters = SearchValues.Create( + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"); + + static readonly JsonWebTokenHandler _handler = new() { MapInboundClaims = false }; + + enum TokenPresence + { + Absent = 0, + Present = 1, + Malformed = 2, + } + + /// + /// Gets the scopes a route requires, trimmed and without blanks. + /// + /// The route. + /// The required scopes. + public static IEnumerable RequiredScopes(ResolvedBearerRoute route) => + route.Route.RequiredScopes + .Where(_ => !string.IsNullOrWhiteSpace(_)) + .Select(_ => _.Trim()) + .Distinct(StringComparer.Ordinal); + + /// + /// Finds a bearer token in a request whose claimed issuer matches, without validating anything. + /// + /// The request. + /// Decides whether a claimed issuer is one being looked for. + /// The first matching claimed issuer. + /// when some bearer token in the request names a matching issuer; otherwise . + /// + /// This is the reading used to refuse a token, so it is deliberately more lenient than any scheme + /// that might accept one: every Authorization value is looked at, and every comma-separated + /// part of each, since the JWT Bearer handler reads the values joined by commas; the scheme is matched + /// case-insensitively and may be followed by any run of whitespace; and the token is trimmed. A header the + /// strict reading of would call malformed must not escape a refusal that a more + /// forgiving handler downstream would then not make. + /// + /// Only good for deciding which rules apply to a token, never for trusting it: nothing about the value has + /// been checked. + /// + /// + public static bool TryFindPresentedIssuer(HttpRequest request, Func matches, out string issuer) + { + issuer = string.Empty; + foreach (var value in request.Headers.Authorization) + { + if (string.IsNullOrEmpty(value)) + { + continue; + } + + foreach (var part in value.Split(',')) + { + if (TryReadLenientBearerToken(part, out var token) + && TryReadUnvalidatedIssuer(token, out var candidate) + && matches(candidate)) + { + issuer = candidate; + return true; + } + } + } + + return false; + } + + /// + public async Task Validate(HttpRequest request, ResolvedBearerRoute route, CancellationToken cancellationToken) + { + var header = ReadBearerToken(request, out var token); + if (header == TokenPresence.Absent) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.Missing, "No bearer token was presented."); + } + + if (header == TokenPresence.Malformed) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.Invalid, "The authorization header is malformed."); + } + + if (!TryReadUnvalidatedIssuer(token, out var presentedIssuer)) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.Invalid, "The token is not a signed JWT."); + } + + var issuer = route.Issuers.FirstOrDefault(_ => string.Equals(_.Issuer, presentedIssuer, StringComparison.Ordinal)); + if (issuer is null) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.Invalid, "The token's issuer is not accepted on this route."); + } + + var keys = await metadata.GetSigningKeys(issuer, cancellationToken); + if (keys is null) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.IssuerUnavailable, "The issuer's signing keys are unavailable."); + } + + var result = await _handler.ValidateTokenAsync(token, CreateParameters(route, issuer, keys)); + if (!result.IsValid && result.Exception is SecurityTokenSignatureKeyNotFoundException) + { + // The token names a key this proxy has not seen — the issuer may have rotated. Ask for fresh keys once; + // refreshes are rate limited, so this cannot be turned into a flood of requests to the issuer. + keys = await metadata.GetSigningKeys(issuer, cancellationToken, refresh: true); + if (keys is null) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.IssuerUnavailable, "The issuer's signing keys are unavailable."); + } + + result = await _handler.ValidateTokenAsync(token, CreateParameters(route, issuer, keys)); + } + + if (!result.IsValid || result.ClaimsIdentity is null) + { + return BearerTokenValidation.Refused( + BearerTokenValidationStatus.Invalid, + $"The token failed validation ({result.Exception?.GetType().Name ?? "unknown"})."); + } + + var claims = result.ClaimsIdentity.Claims.ToArray(); + var scopes = claims + .Where(_ => string.Equals(_.Type, ScopeClaimType, StringComparison.Ordinal)) + .SelectMany(_ => _.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + var missingScopes = RequiredScopes(route).Where(_ => !scopes.Contains(_, StringComparer.Ordinal)).ToArray(); + if (missingScopes.Length > 0) + { + return BearerTokenValidation.Refused( + BearerTokenValidationStatus.InsufficientScope, + $"The token lacks the required scope(s) {string.Join(' ', missingScopes)}."); + } + + if (!TryGetSingleValue(claims, route.TenantClaimType, out var tenantId) || !IsUsableTenantId(tenantId)) + { + return BearerTokenValidation.Refused( + BearerTokenValidationStatus.MissingTenant, + $"The token carries no single usable '{route.TenantClaimType}' claim."); + } + + if (!TryGetSingleValue(claims, SubjectClaimType, out var subject)) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.Invalid, "The token carries no single subject."); + } + + if (!TryBuildForwardedClaims(route, claims, out var forwarded, out var missingSource)) + { + return BearerTokenValidation.Refused( + BearerTokenValidationStatus.Invalid, + $"The token carries no '{missingSource}' claim for a configured claim mapping."); + } + + if (!TryGetSingleValue(forwarded, SubjectClaimType, out _)) + { + return BearerTokenValidation.Refused(BearerTokenValidationStatus.Invalid, "The token carries no single subject."); + } + + forwarded.Add(new Claim(BearerRouteClaims.Issuer, issuer.Issuer)); + forwarded.Add(new Claim(BearerRouteClaims.Subject, subject)); + if (TryGetSingleValue(claims, "azp", out var clientId) || TryGetSingleValue(claims, "client_id", out clientId)) + { + forwarded.Add(new Claim(BearerRouteClaims.ClientId, clientId)); + } + + forwarded.AddRange(scopes.Select(_ => new Claim(BearerRouteClaims.Scope, _))); + + var identity = new ClaimsIdentity(forwarded, route.IdentityProvider, "name", ClaimTypes.Role); + return BearerTokenValidation.Success(new ClaimsPrincipal(identity), tenantId); + } + + static TokenValidationParameters CreateParameters(ResolvedBearerRoute route, ResolvedBearerIssuer issuer, IReadOnlyCollection keys) => new() + { + ValidateIssuer = true, + ValidIssuer = issuer.Issuer, + ValidateAudience = true, + RequireAudience = true, + IgnoreTrailingSlashWhenValidatingAudience = false, + ValidAudiences = route.Route.Audiences.Where(_ => !string.IsNullOrWhiteSpace(_)).Select(_ => _.Trim()).ToArray(), + ValidateLifetime = true, + RequireExpirationTime = true, + ClockSkew = route.ClockSkew, + RequireSignedTokens = true, + ValidateIssuerSigningKey = true, + IssuerSigningKeys = keys, + ValidAlgorithms = BearerRouteDefaults.AllowedAlgorithms, + ValidTypes = issuer.TokenTypes, + SaveSigninToken = false, + }; + + static TokenPresence ReadBearerToken(HttpRequest request, out string token) + { + token = string.Empty; + + var values = request.Headers.Authorization; + if (values.Count == 0) + { + return TokenPresence.Absent; + } + + if (values.Count != 1 || values[0] is not { } value) + { + return TokenPresence.Malformed; + } + + // Another scheme is not a bearer token at all, which RFC 6750 answers with a bare challenge. + if (!value.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)) + { + return TokenPresence.Absent; + } + + token = value[BearerPrefix.Length..]; + return token.Length > 0 && token.AsSpan().IndexOfAnyExcept(_tokenCharacters) < 0 + ? TokenPresence.Present + : TokenPresence.Malformed; + } + + static bool TryReadLenientBearerToken(string value, out string token) + { + const string scheme = "Bearer"; + + token = string.Empty; + var trimmed = value.AsSpan().Trim(); + if (trimmed.Length <= scheme.Length + || !trimmed.StartsWith(scheme, StringComparison.OrdinalIgnoreCase) + || !char.IsWhiteSpace(trimmed[scheme.Length])) + { + return false; + } + + token = trimmed[scheme.Length..].Trim().ToString(); + return token.Length > 0; + } + + static bool TryReadUnvalidatedIssuer(string token, out string issuer) + { + issuer = string.Empty; + if (!_handler.CanReadToken(token)) + { + return false; + } + + try + { + var jwt = _handler.ReadJsonWebToken(token); + if (jwt.IsEncrypted || string.IsNullOrEmpty(jwt.Issuer)) + { + return false; + } + + issuer = jwt.Issuer; + return true; + } + catch (Exception exception) when (exception is ArgumentException or SecurityTokenException or JsonException) + { + return false; + } + } + + static bool TryGetSingleValue(IEnumerable claims, string claimType, out string value) + { + var values = claims.Where(_ => string.Equals(_.Type, claimType, StringComparison.Ordinal)).Take(2).ToArray(); + value = values.Length == 1 ? values[0].Value.Trim() : string.Empty; + return value.Length > 0; + } + + static bool IsUsableTenantId(string tenantId) => + tenantId is not "." and not ".." + && tenantId.Length <= MaximumTenantIdLength + && tenantId.AsSpan().IndexOfAnyExcept(_tenantCharacters) < 0; + + static bool IsSingleValueIdentityClaim(string type) => + string.Equals(type, SubjectClaimType, StringComparison.OrdinalIgnoreCase) + || string.Equals(type, "preferred_username", StringComparison.OrdinalIgnoreCase) + || string.Equals(type, "name", StringComparison.OrdinalIgnoreCase); + + static bool TryBuildForwardedClaims(ResolvedBearerRoute route, Claim[] tokenClaims, out List forwarded, out string missingSource) + { + missingSource = string.Empty; + + // AuthProxy's own namespaces are written by AuthProxy alone. A token that carries them does not get to + // speak for the proxy: canonical identity claims would make the forwarded principal unbuildable, and + // bearer-route claims would claim a client or scope the token was not validated for. + // Role claims are dropped too: the issuer vouches for who the caller is and what the client may attempt, + // and the forwarded principal carries no role the backend did not grant itself. + forwarded = [.. tokenClaims + .Where(_ => !CanonicalIdentityClaims.IsReserved(_.Type) && !BearerRouteClaims.IsReserved(_.Type) && !RoleClaims.Is(_.Type)) + .Select(_ => new Claim(_.Type, _.Value, _.ValueType))]; + + foreach (var (target, source) in route.Route.ClaimMappings) + { + var values = tokenClaims + .Where(_ => string.Equals(_.Type, source, StringComparison.Ordinal)) + .Select(_ => _.Value) + .Where(_ => !string.IsNullOrWhiteSpace(_)) + .ToArray(); + if (values.Length == 0 || (IsSingleValueIdentityClaim(target) && values.Length != 1)) + { + missingSource = source; + return false; + } + + // ClaimsPrincipal authorization lookups ignore case; no variant of an overwritten target may survive. + forwarded.RemoveAll(_ => string.Equals(_.Type, target, StringComparison.OrdinalIgnoreCase)); + forwarded.AddRange(values.Select(_ => new Claim(target, _))); + } + + return true; + } +} diff --git a/Source/AuthProxy/BearerRoutes/IBearerIssuerMetadata.cs b/Source/AuthProxy/BearerRoutes/IBearerIssuerMetadata.cs new file mode 100644 index 00000000..e2fe690e --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/IBearerIssuerMetadata.cs @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.IdentityModel.Tokens; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Defines a source of the signing keys a bearer-token issuer publishes. +/// +public interface IBearerIssuerMetadata +{ + /// + /// Gets the issuer's current signing keys, from its metadata document and JWKS. + /// + /// The issuer. + /// The request's cancellation token. + /// Whether this caller needs to await a rate-limited unknown-key refresh before receiving keys. + /// The signing keys, or when they could not be retrieved or the metadata names another issuer. + Task?> GetSigningKeys(ResolvedBearerIssuer issuer, CancellationToken cancellationToken, bool refresh = false); +} diff --git a/Source/AuthProxy/BearerRoutes/IBearerRouteForwarder.cs b/Source/AuthProxy/BearerRoutes/IBearerRouteForwarder.cs new file mode 100644 index 00000000..8cf6fabc --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/IBearerRouteForwarder.cs @@ -0,0 +1,22 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Defines the forwarding of a bearer-route request to the backend of the service declaring the route. +/// +public interface IBearerRouteForwarder +{ + /// + /// Forwards the request to the route's service backend. + /// + /// The current . + /// The route the request is on. + /// + /// What AuthProxy vouches for, or to forward without any identity — the + /// protected-resource metadata request. + /// + /// A that completes when the response has been relayed. + Task Forward(HttpContext context, ResolvedBearerRoute route, BearerForwardedIdentity? identity); +} diff --git a/Source/AuthProxy/BearerRoutes/IBearerTokenValidator.cs b/Source/AuthProxy/BearerRoutes/IBearerTokenValidator.cs new file mode 100644 index 00000000..99e89427 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/IBearerTokenValidator.cs @@ -0,0 +1,19 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Defines the validation of the access token presented on a bearer route. +/// +public interface IBearerTokenValidator +{ + /// + /// Validates the access token the request carries against the route it targets. + /// + /// The request. + /// The bearer route the request targets. + /// The request's cancellation token. + /// The validation outcome. + Task Validate(HttpRequest request, ResolvedBearerRoute route, CancellationToken cancellationToken); +} diff --git a/Source/AuthProxy/BearerRoutes/ResolvedBearerIssuer.cs b/Source/AuthProxy/BearerRoutes/ResolvedBearerIssuer.cs new file mode 100644 index 00000000..dece4448 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/ResolvedBearerIssuer.cs @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents a configured bearer-token issuer with its defaults applied. +/// +/// The exact issuer identifier a token and the issuer metadata must both name. +/// The address of the issuer's metadata document. +/// The accepted JWT typ header values. +/// Whether the metadata and JWKS documents must be retrieved over HTTPS. +public sealed record ResolvedBearerIssuer(string Issuer, string MetadataAddress, IReadOnlyList TokenTypes, bool RequireHttps); diff --git a/Source/AuthProxy/BearerRoutes/ResolvedBearerRoute.cs b/Source/AuthProxy/BearerRoutes/ResolvedBearerRoute.cs new file mode 100644 index 00000000..a670dc81 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/ResolvedBearerRoute.cs @@ -0,0 +1,52 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using C = Cratis.AuthProxy.Configuration; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Represents a configured bearer route with its path prefix normalized and its defaults applied. +/// +/// The name of the service the route belongs to. +/// The base URL of the service backend the route is forwarded to. +/// The normalized path prefix. +/// The issuers whose tokens the route accepts. +/// The route configuration. +/// The protected-resource metadata URL named in challenges, when configured. +/// The normalized path of , when configured. +public sealed record ResolvedBearerRoute( + string ServiceName, + string BackendBaseUrl, + string Prefix, + IReadOnlyList Issuers, + C.BearerRoute Route, + Uri? ResourceMetadataUrl, + string? ResourceMetadataPath) +{ + /// + /// Gets the clock skew allowed when validating a token lifetime on this route, never more than + /// and never negative. + /// + public TimeSpan ClockSkew => Route.ClockSkew switch + { + null => C.BearerRoute.DefaultClockSkew, + { } skew when skew < TimeSpan.Zero => TimeSpan.Zero, + { } skew when skew > C.BearerRoute.MaximumClockSkew => C.BearerRoute.MaximumClockSkew, + { } skew => skew, + }; + + /// + /// Gets the token claim the tenant is read from. + /// + public string TenantClaimType => string.IsNullOrWhiteSpace(Route.TenantClaimType) + ? C.BearerRoute.DefaultTenantClaimType + : Route.TenantClaimType.Trim(); + + /// + /// Gets the identity provider label of the forwarded principal. + /// + public string IdentityProvider => string.IsNullOrWhiteSpace(Route.IdentityProvider) + ? C.BearerRoute.DefaultIdentityProvider + : Route.IdentityProvider; +} diff --git a/Source/AuthProxy/BearerRoutes/RoleClaims.cs b/Source/AuthProxy/BearerRoutes/RoleClaims.cs new file mode 100644 index 00000000..0914e311 --- /dev/null +++ b/Source/AuthProxy/BearerRoutes/RoleClaims.cs @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Security.Claims; + +namespace Cratis.AuthProxy.BearerRoutes; + +/// +/// Recognizes claim types that grant a role, so a bearer token can never carry one to a backend. +/// +/// +/// ClaimsPrincipal.IsInRole compares claim types case-insensitively, so this does too. +/// +static class RoleClaims +{ + /// + /// Determines whether a claim type grants a role. + /// + /// The claim type. + /// True when the claim type is a role claim in any casing. + internal static bool Is(string claimType) => + string.Equals(claimType, ClaimTypes.Role, StringComparison.OrdinalIgnoreCase) + || string.Equals(claimType, "role", StringComparison.OrdinalIgnoreCase) + || string.Equals(claimType, "roles", StringComparison.OrdinalIgnoreCase); +} diff --git a/Source/AuthProxy/Configuration/BearerIssuer.cs b/Source/AuthProxy/Configuration/BearerIssuer.cs new file mode 100644 index 00000000..cea49989 --- /dev/null +++ b/Source/AuthProxy/Configuration/BearerIssuer.cs @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Configuration; + +/// +/// Represents an authorization server whose access tokens a accepts. +/// +/// +/// AuthProxy only relies on the issuer here — it never issues these tokens. The signing keys are read from the +/// issuer's published metadata (RFC 8414, or OpenID Connect discovery) and its JWKS, cached, and refreshed when a +/// token names a key the cache does not know, so key rotation at the issuer is honored without a restart. +/// +public class BearerIssuer +{ + /// + /// The access-token media types accepted when is left unset: the RFC 9068 JWT access + /// token profile's at+jwt, in its short and its full form. + /// + public static readonly IReadOnlyList DefaultTokenTypes = ["at+jwt", "application/at+jwt"]; + + /// + /// Gets or sets the issuer identifier. A token is accepted only when its iss claim is exactly + /// this value, and the issuer's published metadata must name exactly this value too. + /// + /// + /// An absolute HTTPS URI without query, fragment or user information. Plain HTTP is accepted only for a + /// loopback development issuer. + /// + public string Issuer { get; set; } = string.Empty; + + /// + /// Gets or sets the metadata document address. Leave unset to use the RFC 8414 address derived from + /// : /.well-known/oauth-authorization-server inserted between the issuer's + /// host and path. + /// + public string? MetadataAddress { get; set; } + + /// + /// Gets or sets the JWT typ header values accepted from this issuer. Leave unset to accept the + /// RFC 9068 access-token types in only, which keeps an ID token or any other + /// JWT the issuer signs from being presented as an access token. + /// + public IList TokenTypes { get; set; } = []; +} diff --git a/Source/AuthProxy/Configuration/BearerRoute.cs b/Source/AuthProxy/Configuration/BearerRoute.cs new file mode 100644 index 00000000..a9bc5826 --- /dev/null +++ b/Source/AuthProxy/Configuration/BearerRoute.cs @@ -0,0 +1,143 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Cratis.AuthProxy.Configuration; + +/// +/// Represents a path prefix on a service that is authenticated by an access token from an external authorization +/// server instead of by a browser session. +/// +/// +/// On a bearer route a valid access token is the only credential: the session cookie is never read, no provider +/// selection or tenant selection is ever served, and every refusal is an API-style 401 or +/// 403 with an RFC 6750 challenge. Tokens from the configured issuers are refused on every path +/// that is not one of their bearer routes, so a token cannot reach a browser-only surface. +/// +public class BearerRoute +{ + /// + /// The identity provider label the forwarded principal carries when is unset. + /// + public const string DefaultIdentityProvider = "bearer"; + + /// + /// The token claim the tenant is read from when is unset. + /// + public const string DefaultTenantClaimType = "tid"; + + /// + /// The clock skew allowed when is unset. + /// + public static readonly TimeSpan DefaultClockSkew = TimeSpan.FromSeconds(30); + + /// + /// The largest clock skew a route may allow. + /// + public static readonly TimeSpan MaximumClockSkew = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets the path prefix this route covers, for example /mcp or /v1. Matched + /// case-insensitively on segment boundaries, with the same rules as . + /// + public string PathPrefix { get; set; } = string.Empty; + + /// + /// Gets or sets the authorization servers whose tokens this route accepts. + /// + public IList Issuers { get; set; } = []; + + /// + /// Gets or sets the audiences this route accepts. A token is accepted only when its aud claim names + /// at least one of them. + /// + public IList Audiences { get; set; } = []; + + /// + /// Gets or sets the scopes a token must carry, every one of them, to be accepted on this route. Leave empty to + /// require none. + /// + public IList RequiredScopes { get; set; } = []; + + /// + /// Gets or sets the absolute URL of this route's RFC 9728 protected-resource metadata document. It is named in + /// the resource_metadata parameter of every challenge on this route, and its path is forwarded to + /// the service's backend without authentication. + /// + public string? ResourceMetadataUrl { get; set; } + + /// + /// Gets or sets the token claim that carries the tenant. Defaults to . + /// + public string TenantClaimType { get; set; } = DefaultTenantClaimType; + + /// + /// Gets or sets the claims to rewrite before the principal is forwarded: forwarded claim type to the token claim + /// it is read from, replacing all case variants of the target. A missing source refuses the token, as do + /// multiple usable source values for mapped sub, preferred_username or name. Targets may not differ only + /// by case or overwrite the tenant claim. + /// + public IDictionary ClaimMappings { get; set; } = new Dictionary(); + + /// + /// Gets or sets the identity provider label of the forwarded principal. Defaults to + /// . + /// + public string IdentityProvider { get; set; } = DefaultIdentityProvider; + + /// + /// Gets or sets a value indicating whether the Authorization header is forwarded to the backend. + /// Defaults to : the backend receives the principal AuthProxy vouches for, not the token. + /// + public bool ForwardAuthorizationHeader { get; set; } + + /// + /// Gets or sets the clock skew allowed when checking the token lifetime. Defaults to + /// ; at most . + /// + public TimeSpan? ClockSkew { get; set; } + + /// + /// Gets or sets claim requirements of this route's own, which the token's principal must satisfy after + /// — every one of them, in addition to whatever deployment requirements apply. + /// + /// + /// Composed exactly like : the list is an and, each + /// requirement's an or. A token never carries a role, so a + /// requirement on a role claim is refused at startup. + /// + public IList RequiredClaims { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the deployment's claim requirements — the proxy-wide + /// and the route's service's own — are left out on this route. + /// Defaults to : they apply to a bearer token as they do to a browser session. + /// + /// + /// For a deployment whose requirements name a claim only its browser sign-in produces — a GitHub team read + /// from the GitHub API, say — and which the token issuer does not mint. Without this, every token on the + /// route would be refused. Setting it widens who reaches the service through this route, so AuthProxy logs a + /// warning at startup naming the requirements it leaves out; state what the route requires instead in + /// . + /// + public bool IgnoreDeploymentRequiredClaims { get; set; } + + /// + /// Gets or sets a value indicating whether this route accepts callers without asking any service's + /// /.cratis/me about them. Defaults to . + /// + /// + /// A bearer route never calls /.cratis/me, in either : the + /// endpoint answers for browser sessions, and a product cannot be assumed to answer it correctly for a principal + /// authenticated by a token. Setting this states that for this route the validated token, its scopes and the + /// claim requirements are the whole decision at the edge, and that the backend decides tenant membership and + /// everything else. + /// + /// When any service declares , every forwarded request is meant to + /// carry a positive verdict, so a bearer route in that deployment is refused at startup unless it sets this. + /// Under , where an HTTP 403 from + /// /.cratis/me refuses a browser session, a route that does not set this is started with a + /// warning that the refusal does not apply to its tokens. + /// + /// + public bool AcceptWithoutIdentityVerification { get; set; } +} diff --git a/Source/AuthProxy/Configuration/Service.cs b/Source/AuthProxy/Configuration/Service.cs index 73fe8b20..283a64be 100644 --- a/Source/AuthProxy/Configuration/Service.cs +++ b/Source/AuthProxy/Configuration/Service.cs @@ -235,4 +235,14 @@ public class Service /// When configured, AuthProxy can verify client credentials against the service and mint scoped bearer tokens. /// public ServiceClientCredentials? ClientCredentials { get; set; } + + /// + /// Gets or sets the path prefixes on this service that are authenticated by an access token from an external + /// authorization server rather than by a browser session. + /// + /// + /// Each route is forwarded to the service's . Leave empty to keep every path on the + /// browser-session model. + /// + public IList BearerRoutes { get; set; } = []; } diff --git a/Source/AuthProxy/Headers.cs b/Source/AuthProxy/Headers.cs index 5a8e0b12..cf2d6480 100644 --- a/Source/AuthProxy/Headers.cs +++ b/Source/AuthProxy/Headers.cs @@ -64,6 +64,21 @@ public static class Headers /// public const string LegacyTenantId = "Tenant-ID"; + /// + /// The client an access token was issued to — its azp, or failing that its + /// client_id — sent only for a request authenticated on a bearer route. + /// + /// + /// This is the client's asserted identity: a public client cannot prove which program is using it. + /// + public const string TokenClientId = "x-cratis-token-client-id"; + + /// + /// The space-separated scopes granted to an access token, sent only for a request authenticated on a bearer + /// route. + /// + public const string TokenScope = "x-cratis-token-scope"; + /// /// Service identifier used to route requests to the appropriate service. /// diff --git a/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs b/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs index 0d7716b6..3527d356 100644 --- a/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs +++ b/Source/AuthProxy/Identity/ClientPrincipalExtensions.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using Cratis.AuthProxy.Authentication; +using Cratis.AuthProxy.BearerRoutes; using Microsoft.AspNetCore.Authentication; namespace Cratis.AuthProxy.Identity; @@ -76,8 +77,10 @@ public static class ClientPrincipalExtensions .Concat(["anonymous", "authenticated"]) .Distinct(); + // The bearer-route namespace means "authenticated on a bearer route". A browser session's provider does not + // get to say that, so its claims in that namespace are never forwarded. var claims = user.Claims - .Where(c => !IsRoleClaim(c.Type, isCanonical)) + .Where(c => !IsRoleClaim(c.Type, isCanonical) && !BearerRouteClaims.IsReserved(c.Type)) .Select(c => new ClientPrincipalClaim { Type = c.Type, Value = c.Value }); return new ClientPrincipal diff --git a/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs b/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs index 587223ca..4633cc16 100644 --- a/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs +++ b/Source/AuthProxy/Identity/InjectIdentityHeadersTransform.cs @@ -28,6 +28,10 @@ public override ValueTask ApplyAsync(RequestTransformContext context) // the tenancy middleware having run, so nothing a caller sent survives whatever path led here. SpoofableHeaders.Strip(context.ProxyRequest.Headers); + // Only a bearer route vouches for these, and bearer routes are never forwarded through here. + context.ProxyRequest.Headers.Remove(Headers.TokenClientId); + context.ProxyRequest.Headers.Remove(Headers.TokenScope); + var principal = httpContext.BuildClientPrincipal(); if (principal is not null) { diff --git a/Source/AuthProxy/IngressExtensions.cs b/Source/AuthProxy/IngressExtensions.cs index 6f033fbc..efffa96a 100644 --- a/Source/AuthProxy/IngressExtensions.cs +++ b/Source/AuthProxy/IngressExtensions.cs @@ -4,6 +4,7 @@ using Cratis.AuthProxy.Admission; using Cratis.AuthProxy.Authentication; using Cratis.AuthProxy.Authorization; +using Cratis.AuthProxy.BearerRoutes; using Cratis.AuthProxy.ErrorPages; using Cratis.AuthProxy.Identity; using Cratis.AuthProxy.Ingress; @@ -105,6 +106,10 @@ public static WebApplicationBuilder AddIngressConfiguration(this WebApplicationB // that never opts in. builder.AddAdmission(); + // The same holds for the bearer-route gate: UseIngress always places it, and it hands every request on + // untouched for a deployment that declares no bearer route. + builder.AddBearerRoutes(); + builder.Services.AddSingleton, KeyRingConfigurationValidator>(); builder.Services .AddDataProtection() @@ -151,6 +156,11 @@ public static WebApplication UseIngress(this WebApplication app) // circuits on its first line for every deployment that has not opted in. app.UseMiddleware(); + // Ahead of the pages, the static files and authentication, because a bearer route must reach none of + // them: a request on one is answered here, by a forward or an API-style refusal. It hands every request on + // untouched when no bearer route is configured. + app.UseMiddleware(); + // Routes match one service header, so a client still sending the legacy one is given the current one // before any endpoint is selected. app.UseMiddleware(); diff --git a/Source/AuthProxy/TenancyMiddleware.cs b/Source/AuthProxy/TenancyMiddleware.cs index 95fd8ec8..3385f8a5 100644 --- a/Source/AuthProxy/TenancyMiddleware.cs +++ b/Source/AuthProxy/TenancyMiddleware.cs @@ -50,6 +50,8 @@ public async Task InvokeAsync(HttpContext context) // Every x-ms-client-principal* header and the tenant under both of its names; only the tenant this // middleware resolves below is ever forwarded. SpoofableHeaders.Strip(context.Request.Headers); + context.Request.Headers.Remove(Headers.TokenClientId); + context.Request.Headers.Remove(Headers.TokenScope); // 2. Resolve tenant. if (!tenantResolver.TryResolve(context, out string tenantId))