Repository navigation
Accept external access tokens on configured bearer routes - #147
Conversation
Per-service bearer routes authenticate by an access token from a configured authorization server (discovery + JWKS, cached, refreshed on an unknown key) with strict validation: exact issuer, audience per route, lifetime with small skew, RS256/ES256 only, at+jwt type. A request on a bearer route never reaches the cookie session, provider or tenant selection: it is refused with an RFC 6750 challenge naming the RFC 9728 resource metadata, or forwarded straight to the backend with the Microsoft Identity Platform headers, Tenant-ID from tid, and the token's scopes and client id. The resource metadata path passes through anonymously, and a bearer-route token is refused on every other path. Refs #143
…s off sessions UseIngress always places the bearer-route gate, so its services are registered with AddIngressConfiguration like the admission gate's, and hosts composing the pipeline themselves keep starting. A browser session's claims in the urn:cratis:bearer: namespace are no longer forwarded, so only a bearer route can say a request was authenticated by an access token. Refs #143
…header is written The off-route refusal read the header strictly, so 'Bearer <jwt>' or a second Authorization header passed it as malformed while the JWT Bearer handler trims and accepts the token. The refusal now reads every value leniently. Refs #143
…ule to bearer routes Authorization.RequiredClaims (proxy-wide and the route's service's) are evaluated against the token's principal after claim mappings and refused with 403. A bearer route never calls /.cratis/me, so a deployment requiring identity verification now refuses to start with one unless the route sets AcceptWithoutIdentityVerification. Refs #143
A path on a bearer route that still carries percent-encoding after the server decoded it, a backslash, or a dot segment is refused with 400 before the token is read, so a principal is never vouched for at a path a backend would normalize into one that is not a bearer route. Specify prefix matching on case and segment boundaries alongside it.
Let the stub issuer reshape a token and sign one with its public key as an HMAC secret, and specify end to end that tokens of an unacceptable shape, missing a mapped claim or naming an unusable tenant are refused.
Spoofed token headers are stripped on browser routes, role and AuthProxy-namespace claims in a token are dropped, and a route that forwards the Authorization header still keeps cookies at the edge.
The protected-resource metadata path serves only GET and HEAD, exactly; an unreachable issuer yields 503 with Retry-After and no challenge; and a misconfigured bearer route stops the host from starting.
A ';' starts a path parameter that Tomcat, Jetty and Spring strip before resolving dot segments, so /mcp/..;/api/items matched /mcp here and reached the backend as /api/items with a principal vouched for on the bearer route. Any ';' on a bearer-route path is now refused with 400, the same rule a declared prefix is held to. Refs #143
Claim requirements apply to bearer principals, so a deployment requiring a claim only its browser sign-in produces - Direct requires urn:github:team, read from the GitHub API - would refuse every token its issuer mints. A bearer route can now declare RequiredClaims of its own, always applied on top, and set IgnoreDeploymentRequiredClaims to leave the proxy-wide and service requirements out. The default still applies all of them. Leaving them out is logged as a startup warning naming the requirements left out, and a route requirement naming no claim or a role is refused at startup. The Direct example documents the flag and how to replace it once Cratis Identity mints a team or membership claim. Refs #143
A bearer route never calls /.cratis/me, so the 403 veto the default BestEffort mode applies to a browser session did not apply to tokens, and nothing said so. Calling it for tokens is not the safer option: the endpoint answers for browser sessions, and a product that cannot recognise a token principal would either lock out every token or give the veto no meaning. The behavior stays, and is now stated: every bearer route in a deployment where some service answers /.cratis/me is logged as a startup warning naming those services, unless the route sets AcceptWithoutIdentityVerification to say its backend decides membership. Required still refuses to start without it. The docs say that the backend must enforce tenant membership, and the Direct example sets the flag. Refs #143
- Keys: a failed refresh keeps the last retrieved keys in use; tokens are refused, with 503, only when none were ever retrieved or the metadata names another issuer. Unknown-key refreshes are limited to one per 30s. - The typ default is at+jwt or application/at+jwt. - A token without a single sub, or without a claim a mapping reads, is a 401 invalid_token; a tenant must be a single usable one. - The metadata path strips Cookie and Authorization too, and answers 405 to other methods. - userDetails falls back to sub. - The forward adds no Service-ID but passes a caller's through; the sentence saying it was forwarded 'without a Service-ID header' sat in the identity-verification bullet and was wrong on both counts. Refs #143
ClaimsPrincipal.IsInRole matches claim types case-insensitively, so a case-variant role claim in a token would still grant a backend role. One helper now drops and refuses role claims in any casing.
|
Moved from the description, which is published verbatim as the release notes (see the release-note contract in Original descriptionCloses #143. Refs Cratis/Direct#1354 and Cratis/Identity. A service can now declare bearer routes (for example Behaviour
Checks (local, mirroring dotnet-build.yml): Debug and Release builds with 0 warnings; Aspire 74, Security 390 and AuthProxy 2,213 specs pass. Review: four rounds of the full review workflow. Every confirmed major (whitespace bypass off the bearer routes, skipped claim requirements, |
|
Addressed the confirmed review findings: synchronous rate-limited signing-key refresh, synchronized initial-fetch failure backoff, single-valued mapped identity fields, tenant-mapping protection, case-consistent mapped-claim replacement and ordinal JWT identity lookups. Added key-rotation/outage, concurrent backoff, mapped-claim casing/cardinality, configuration and metadata-refusal cache-control regressions. Local checks passed: whole-solution Debug and Release builds (zero warnings); AuthProxy.Specs Debug (2219), Aspire.Specs Debug (74), security specs Debug and Release (401 each); changed-area whitespace verification; immutable Yarn install with scripts disabled; high-severity recursive Yarn audit; transitive NuGet vulnerability report (no vulnerable packages). Whole-solution whitespace verification fails on unchanged files already present on origin/main; these unrelated files were left untouched. The advisory-only moderate Yarn audit reports the existing eslint deprecation. Release publish did not start within two bounded 120-second pi-phase queue waits; the dependent Docker image build therefore remains unrun. No publishing or image-check pass is claimed. This is not a merge-ready handoff until the remaining checks and independent review are completed. Merged origin/main before the fixes. No merge or label change was performed. The main checkout remains clean on main. |
…bearer-routes # Conflicts: # Source/AuthProxy/Authorization/AccessPolicy.cs # Source/AuthProxy/IngressExtensions.cs # Source/AuthProxy/TenancyMiddleware.cs
|
Pushed the batched review fixes at All confirmed findings are addressed: caller-specific requested refresh, nonblocking automatic refresh and retry, rejection of resource-metadata paths beneath bearer routes, rejection of repeated path separators, and the browser-session bearer-namespace release-note disclosure. Regression specs accompany the behavioral fixes. Local checks used pi-phase, with execution limits of 120–300 seconds:
Per the owner's targeted-only local-check decision, no whole-solution/full-suite, Docker, or dependency-audit gate was run locally. The PR's CI is the full gate; it has not been watched or claimed green. The release note was checked against the current diff and updated through REST. No merge or label changes were made. |
|
Addressed all three CONFIRMED findings: ambiguous aliases are refused before route selection, bearer forwarding honors backend → service → root → default activity timeouts, and the release note explicitly covers browser-session token-header stripping even without bearer routes. Added the sole Local checks (via pi-phase):
Per the owner's targeted-only instruction, whole-solution Debug/Release builds, remaining specs, Docker, dependency audits and documentation gates are left to the PR's CI. CI has not been watched or claimed green. No merge or label change was performed. |
|
All four confirmed findings are addressed: exact audience matching, startup rejection of stripped-prefix aliases and cross-service prefix overlaps, dot-segment tenant rejection, and the deployment-claim release note. The code fixes, services documentation and signed-token/startup regressions are committed locally as Local checks:
The mandatory local Security.Specs pass is therefore unmet, so no push was made. The PR body was corrected via REST for the deployment RequiredClaims default and IgnoreDeploymentRequiredClaims opt-out; it does not claim the unpushed code changes. CI was not watched, no labels were changed, and the PR was not merged. The requested worktree cleanup preserves the local branch and a recovery bundle at |
|
Pushed the review fixes and a tenant-verification spec fixture correction at a8c504d. Security.Specs passes: 467/467 in Debug (requested command) and Release. The fixture now uses a non-file-like verification endpoint; dot-segment rejection assertions remain unchanged. Whole-solution Debug/Release builds and AuthProxy.Specs (2563/2563) and Aspire.Specs (77/77) also pass. No merge performed. |
|
Merged Conflicts and resolution
Local checksAll phases used
Rechecked the PR body against |
Summary
Services can declare bearer routes such as
/mcpand/v1that accept access tokens from an external authorization server and forward the same trusted identity headers as browser sessions. AuthProxy remains an edge and relying party; it does not issue these tokens.Added
Services:<name>:BearerRoutes[], with path prefixes, issuers, audiences, required scopes, protected-resource metadata URLs, tenant claims, identity providers and claim mappings. The deployment's proxy-wide and serviceAuthorization.RequiredClaimsapply to bearer-token principals after claim mappings; a route can add its ownRequiredClaimsand setIgnoreDeploymentRequiredClaimsto leave the deployment's requirements out, which is logged at startup. Invalid route configuration stops AuthProxy at startup, including resource-metadata paths at or below any bearer-route prefix, bearer routes on a service withStripPathPrefix, and bearer prefixes that overlap another service'sPathPrefixon any host. Surrounding whitespace in configured issuer identifiers and tenant claim names is trimmed. (Accept Cratis Identity access tokens on bearer routes and keep AuthProxy an edge, not an authorization server #143)typ(at+jwtandapplication/at+jwtby default), exact route audiences (a trailing slash names a different audience), required expiry and a single subject. Encrypted, unsigned and HMAC tokens are refused. (Accept Cratis Identity access tokens on bearer routes and keep AuthProxy an edge, not an authorization server #143)tidby default); a missing usable tenant gives 403. Claim mappings cannot overwrite the tenant claim or declare targets differing only by case, and mapped identity fields must be single-valued. (Accept Cratis Identity access tokens on bearer routes and keep AuthProxy an edge, not an authorization server #143)resource_metadataorinvalid_token, 403 for insufficient scope or denied access, 400 for ambiguous paths (%,\,;, repeated/separators or dot segments), and 503 when trusted issuer keys are unavailable. Refusals, including unsupported methods on resource-metadata paths, areno-store. (Accept Cratis Identity access tokens on bearer routes and keep AuthProxy an edge, not an authorization server #143)x-cratis-token-scopeandx-cratis-token-client-idheaders, plus bearer-route configuration and authentication guides with a Direct example. (Accept Cratis Identity access tokens on bearer routes and keep AuthProxy an edge, not an authorization server #143)ActivityTimeoutsettings in that order, falling back to five minutes and applying configuration reloads to subsequent requests. (Accept Cratis Identity access tokens on bearer routes and keep AuthProxy an edge, not an authorization server #143)Changed
Authorizationcasing or whitespace. (Accept Cratis Identity access tokens on bearer routes and keep AuthProxy an edge, not an authorization server #143)Authorizationonly when configured. Role claims in any casing are dropped; mappings replace all case variants of their targets, and forwarded JWT identity fields use exact claim names. (Accept Cratis Identity access tokens on bearer routes and keep AuthProxy an edge, not an authorization server #143)/.cratis/me; backends must enforce tenant membership. A startup warning explains this, and deployments requiring identity verification must setAcceptWithoutIdentityVerification: trueon each bearer route to accept its callers without that verification. Identity verification is required by default for services with backends that participate in identity resolution. (Accept Cratis Identity access tokens on bearer routes and keep AuthProxy an edge, not an authorization server #143)urn:cratis:bearer:namespace or inboundx-cratis-token-scopeandx-cratis-token-client-idheaders, which only bearer routes set, even in deployments without bearer routes. (Accept Cratis Identity access tokens on bearer routes and keep AuthProxy an edge, not an authorization server #143)/api//mcp/toolsfor/api/mcp. Clients must send unambiguous paths; see the bearer-route path rules. (Accept Cratis Identity access tokens on bearer routes and keep AuthProxy an edge, not an authorization server #143)