Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
125ae09
Accept external access tokens on configured bearer routes
woksin Sep 29, 2026
6a53ba6
Specify bearer routes end to end against a stub issuer
woksin Sep 29, 2026
19777ec
Document bearer routes for external access tokens
woksin Sep 29, 2026
eb1dd55
Register bearer routes with the ingress pipeline and keep their claim…
woksin Sep 29, 2026
babfc3f
Refuse a bearer-route token off its routes however the Authorization …
woksin Sep 29, 2026
3325cce
Apply the deployment's claim requirements and identity-verification r…
woksin Sep 29, 2026
ae38965
Refuse bearer-route paths a backend could decode into another path
woksin Sep 29, 2026
f9bad99
Specify that ID, HMAC, expiry-less and encrypted tokens are refused
woksin Sep 29, 2026
0ab7adc
Specify what bearer and browser routes forward to the backend
woksin Sep 29, 2026
f344ae4
Specify metadata methods, unreachable issuers and startup refusals
woksin Sep 29, 2026
1528b81
Refuse bearer-route paths carrying a path parameter
woksin Sep 29, 2026
bcb7c0c
Let a bearer route state which claim requirements apply to its tokens
woksin Sep 29, 2026
96d819a
Make the skipped /.cratis/me veto on bearer routes explicit
woksin Sep 29, 2026
0adb52a
Correct the bearer-route docs against what the gate does
woksin Sep 29, 2026
7ccfa91
Recognize role claim types case-insensitively on bearer routes
woksin Sep 29, 2026
1a34d00
Merge remote-tracking branch 'origin/main' into feature/143-identity-…
woksin Oct 1, 2026
67e57b0
Fix bearer issuer refresh and mapped identity consistency
woksin Oct 1, 2026
554511c
Serve trusted bearer keys while issuer metadata refreshes
woksin Oct 1, 2026
3b10a8f
Normalize configured bearer issuers and tenant claim names
woksin Oct 1, 2026
75082de
Merge remote-tracking branch 'origin/main' into feature/143-identity-…
woksin Oct 1, 2026
c30eec7
Keep bearer refresh intent caller-specific and reject unsafe paths
woksin Oct 1, 2026
f8e6895
Merge remote-tracking branch 'origin/main' into feature/143-identity-…
woksin Oct 1, 2026
39d0372
Remove trailing blank lines from bearer route guides
woksin Oct 1, 2026
fc2c17b
Merge origin/main and retain bearer-route policies
woksin Oct 1, 2026
bd2a11d
Align bearer-route specs with required identity verification
woksin Oct 1, 2026
a374c4c
Merge branch 'main' into feature/143-identity-bearer-routes
woksin Oct 2, 2026
8ba1239
Reject ambiguous bearer-route aliases and honor activity timeouts
woksin Oct 2, 2026
6b7d9ae
Merge remote-tracking branch 'origin/main' into feature/143-identity-…
woksin Oct 2, 2026
68d8b36
Close bearer audience, tenant and prefix boundaries
woksin Oct 2, 2026
a8c504d
Use a routable endpoint for dotted tenant verification specs
woksin Oct 2, 2026
a779f9e
Merge origin/main into feature/143-identity-bearer-routes
woksin Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions Documentation/configuration/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<ResourceMetadataUrl>"` |
| 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="<required scopes>", 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
Expand Down
7 changes: 7 additions & 0 deletions Documentation/configuration/authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Documentation/configuration/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Loading