Skip to content

Accept external access tokens on configured bearer routes - #147

Merged
woksin merged 31 commits into
mainfrom
feature/143-identity-bearer-routes
Oct 2, 2026
Merged

woksin merged 31 commits into
mainfrom
feature/143-identity-bearer-routes

Conversation

@woksin

@woksin woksin commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Services can declare bearer routes such as /mcp and /v1 that 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

Changed

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.
@woksin

woksin commented Sep 29, 2026

Copy link
Copy Markdown
Contributor Author

Moved from the description, which is published verbatim as the release notes (see the release-note contract in .cratis/ai/rules/pull-requests.md).

Original description

Closes #143. Refs Cratis/Direct#1354 and Cratis/Identity.

A service can now declare bearer routes (for example /mcp and /v1). On those routes, AuthProxy accepts access tokens from a configured external authorization server such as Cratis Identity, and forwards the same trusted identity headers it forwards for browser sessions. AuthProxy stays an edge and relying party. It does not issue these tokens.

Behaviour

  • Configuration: Services:<name>:BearerRoutes[] with a path prefix, issuers, audiences, required scopes, a resource-metadata URL, the tenant claim (tid), claim mappings, the identity provider, and a per-route RequiredClaims / IgnoreDeploymentRequiredClaims. A misconfigured route stops AuthProxy at startup.

  • Isolation: the gate runs before authentication, provider selection and tenant selection. On a bearer route, browser sessions are never used and there are no interactive redirects. Tokens from a bearer-route issuer are refused on every other route, whatever whitespace or casing the Authorization header uses.

  • Validation:

    • exact issuer, and a matching issuer in its metadata;
    • signature checked against JWKS (cached, refreshed at most every 30 s for unknown keys);
    • RS256 and ES256 only, and no encrypted or HMAC tokens;
    • typ must be at+jwt;
    • audience checked per route;
    • exp required, and exactly one sub.
  • Tenant: taken only from tid. A missing tenant gives 403.

  • Refusals:

    • 401 with resource_metadata (RFC 9728), or invalid_token;
    • 403 insufficient_scope;
    • 400 for ambiguous paths (%, \\, ;, dot segments);
    • 503 when the issuer's keys were never retrieved.

    All refusals are no-store.

  • Forwarding: inbound identity, tenant and token headers are stripped, cookies are never forwarded, and Authorization only when the route opts in. Role claims in any casing are dropped, so a token never grants a role. New headers: x-cratis-token-scope and x-cratis-token-client-id.

  • Membership: bearer routes never call /.cratis/me. A startup warning says so, and the backend must enforce tenant membership. Under IdentityVerification: Required, AuthProxy refuses to start unless the route opts in explicitly.

  • Docs: authentication.md, tenancy.md and services.md, with a Direct example.

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, ..; traversal) and the last minor (role-claim casing) are fixed. ⚠️ All rounds were Anthropic-only (two independent Opus reviewers), because of the OpenAI usage limit. This is a security-critical edge change, so please don't merge until a cross-provider review has run.

@woksin

woksin commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

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.

@woksin

woksin commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Pushed the batched review fixes at bd2a11db1f975d122fb22456a19c34304f1cd05e, including a merge of current main (#164, #168 and #169). The merge preserves main's service-routing authorization and key-ring registration alongside the bearer-route policies. Bearer fixtures now explicitly select BestEffort where intended, and new validator specs cover the Required default with and without bearer-route acknowledgement.

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:

  • Release builds of Source/AuthProxy/AuthProxy.csproj, Source/AuthProxy.Specs/AuthProxy.Specs.csproj, and Source/AuthProxy.Security.Specs/AuthProxy.Security.Specs.csproj, all with -p:TreatWarningsAsErrors=true and repository analyzers enabled: passed, zero warnings/errors.
  • dotnet test Source/AuthProxy.Security.Specs/AuthProxy.Security.Specs.csproj --configuration Release --no-build --filter 'FullyQualifiedName~for_BearerRoutes': passed, 163 specs. An initial run exposed stale fixture assumptions after merging the Required default; those fixtures were corrected before the passing run.
  • dotnet test Source/AuthProxy.Specs/AuthProxy.Specs.csproj --configuration Release --no-build --filter 'FullyQualifiedName~BearerRoutes|FullyQualifiedName~for_AccessPolicy|FullyQualifiedName~when_a_session_carries_bearer_route_claims': skipped because pi-phase did not admit the command within its 120-second queue budget. The updated-state phase also was not admitted; no further retries.
  • git diff --check: passed.

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.

@woksin

woksin commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

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 /api/mcp prefix regression with browser credentials and a slash-normalizing origin, plus timeout precedence/reload specs. The branch already contained current main when pulled; no additional merge was needed. No REJECTED finding was acted on.

Local checks (via pi-phase):

  • dotnet build Source/AuthProxy.Security.Specs/AuthProxy.Security.Specs.csproj --configuration Release -warnaserror: passed, including AuthProxy; zero warnings/errors.
  • dotnet build Source/AuthProxy.Specs/AuthProxy.Specs.csproj --configuration Release -warnaserror: passed; zero warnings/errors.
  • The first security-spec run passed the new regression but failed one existing assertion expecting 401 for a backslash before a matching prefix. Updated that assertion to the new pre-selection 400 contract and rebuilt successfully. No identity-verification assertion was weakened; the browser fixture explicitly inherits BestEffort.
  • Final dotnet test Source/AuthProxy.Security.Specs/AuthProxy.Security.Specs.csproj --configuration Release --no-build: skipped after pi-phase's 120-second admission timeout (exit 75), without retry.
  • dotnet test Source/AuthProxy.Specs/AuthProxy.Specs.csproj --configuration Release --no-build: skipped after pi-phase's 120-second admission timeout (exit 75), without retry.
  • git diff --check: passed.

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.

@woksin

woksin commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

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 68d8b36a7fc4a7f500be39ec600a671963c460b3 on feature/143-identity-bearer-routes; current origin/main was merged first. They have not been pushed.

Local checks:

  • dotnet build Source/AuthProxy.Security.Specs/AuthProxy.Security.Specs.csproj --configuration Release -warnaserror passed through pi-phase, including the referenced AuthProxy project, with zero warnings/errors.
  • dotnet test Source/AuthProxy.Security.Specs/AuthProxy.Security.Specs.csproj --configuration Debug --logger 'console;verbosity=minimal' was skipped: pi-phase did not admit it within the instructed 120-second queue budget (exit 75); no tests ran. No retry or whole-solution gate was run under the targeted-only instruction. The security workflow's required Release test run also remains outstanding.

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 /Volumes/sourcecode/repos/cratis/.ai-work/cleanup-2026-09-30/keep/branch-bundles/AuthProxy-147-review-fixes.bundle. Before pushing, run the mandatory Security.Specs project in Release and extend the release note to mention exact audiences and the documented prefix restrictions.

@woksin

woksin commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

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.

@woksin

woksin commented Oct 2, 2026

Copy link
Copy Markdown
Contributor Author

Merged origin/main into feature/143-identity-bearer-routes without rebasing. Merge commit: a779f9e24214e05890c870c37431441f2f17d57d (main parent: 3955a1079c16ac79fe8b3fea89aaad9a2eb18963). The PR has not been merged.

Conflicts and resolution

  • Documentation/configuration/services.md — the service-properties table and appended guides conflicted. Kept both BearerRoutes and AccessToken properties, and both complete guides with their original examples and requirements. JSON examples parse.

Source/AuthProxy/Configuration/Service.cs merged automatically and retains both properties. No runtime conflict required a product decision. #171's access-token implementation and reverse-proxy wiring are unchanged from main, including route-bound token policies, destination-version metadata, 503 on mismatched snapshots, refresh-rejection backoff, and sliding retention. #147's bearer-route implementation and ingress placement are unchanged from the pre-merge branch, including token-only authentication, header stripping, exact audiences, startup refusals, and ambiguous-path rejection. Bearer requests terminate in the pre-authentication bearer gate/direct forwarder and never enter browser-session token forwarding; browser routes reject tokens from bearer-route issuers. The forwarding middleware acts only on cookie-authenticated backend requests.

Local checks

All phases used pi-phase, with a 600-second queue budget and a 300-second execution limit (120 seconds for Aspire specs). All passed on the first attempt; no retries were necessary.

  • dotnet build AuthProxy.slnx --configuration Release -warnaserror — passed, zero warnings/errors. Includes AuthProxy, AuthProxy.Security.Specs, and AuthProxy.Specs.
  • dotnet build AuthProxy.slnx --configuration Debug -warnaserror — passed, zero warnings/errors.
  • dotnet test Source/AuthProxy.Security.Specs/AuthProxy.Security.Specs.csproj --configuration Release --no-build — 481 passed, 0 failed/skipped.
  • dotnet test Source/AuthProxy.Specs/AuthProxy.Specs.csproj --configuration Release --no-build — 2701 passed, 0 failed/skipped.
  • dotnet test Source/Aspire.Specs/Aspire.Specs.csproj --configuration Release --no-build — 77 passed, 0 failed/skipped.
  • The same three test-project commands with --configuration Debug — respectively 481, 2701, and 77 passed, 0 failed/skipped.
  • git diff --check origin/main...HEAD — passed.

Rechecked the PR body against git diff --stat origin/main...HEAD: it still describes the bearer-route changes; #171's already-merged forwarding work is not included in the release notes. No body changes were needed. Docker packaging and dependency-audit CI checks were not run locally; no remote CI pass is claimed.

@woksin
woksin merged commit 5c431fe into main Oct 2, 2026
@woksin
woksin deleted the feature/143-identity-bearer-routes branch October 2, 2026 07:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant