mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-06-05 22:44:17 +00:00
* docs: bearer-token auth design spec * docs: harden bearer-auth spec with security review findings * feat(bearer): opt-in M2M bearer-token authentication Adds an opt-in Authorization: Bearer <jwt> path for machine-to-machine clients. Replaces and supersedes the broken approach in PR #93 (synthetic-session that omitted user_identifier and skipped ID-token rejection / replay-protection-semantics / kid-pinning / etc.). Design Two auth entrypoints feed one shared post-auth pipeline: cookie path ─┐ ├── forwardAuthorized(rw, req, *principal) bearer path ─┘ (roles/groups, header injection, security headers, cookie strip, forward) buildPrincipalFromSession and buildPrincipalFromBearerToken produce the same `principal` value type. forwardAuthorized is session-agnostic and runs the existing post-auth work; processAuthorizedRequest now wraps it with the session-specific concerns (backchannel-logout, dirty/Save). The cookie path's behaviour is byte-identical to before this PR; the existing test suite passes unmodified. Security hardening baked into the bearer path - Audience MANDATORY. Startup fails when EnableBearerAuth=true and Audience is empty. - BearerIdentifierClaim defaults to "sub"; "email" is rejected at startup to avoid the unverified-email spoofing footgun. Cookie path's UserIdentifierClaim is unaffected and still defaults to "email". - ID tokens explicitly rejected via the existing detectTokenType helper (nonce, typ=at+jwt, token_use, scope, aud-vs-clientID heuristics); belt-and-braces nonce/token_use=id rejection on top. - alg pinned to asymmetric allowlist (RS/PS/ES 256/384/512) BEFORE JWKS fetch, blocking alg=none and alg=HS* probes from amplifying into upstream calls. - kid length capped at 256 bytes and charset-restricted before JWKS fetch, blocking pathological-kid JWKS amplification. - Multi-audience tokens require azp == clientID. - iat upper-age bound (MaxTokenAgeSeconds, default 24h) bounds clock- manipulation and forever-token abuse. - Identifier sanitization: length cap, control-char + bidi-override + delimiter (, ; =) rejection. - Per-IP failure throttle: configurable threshold/window/penalty; returns 429 + Retry-After. Limits offline-guessing-style attacks and protects the shared rate-limiter / JWKS endpoint. - JTI replay marking suppressed via new internal verifyOpts {skipReplayMarking} so the same bearer can be reused until exp; the blacklist Get stays active so RevokeToken still terminates a bearer token immediately. The existing exported VerifyToken interface is unchanged so all mocks continue to work. - Cookie wins by default when both bearer and cookie are present (safer against browser/extension/proxy bearer injection). Operator can flip via BearerOverridesCookie. - Authorization header stripped on forward by default; also stripped on excluded URLs so the token can't leak into health/metrics downstream logs. - Optional RFC 7662 introspection via existing requireTokenIntrospection. Introspection-endpoint failure returns 503 (distinguishes infra from token rejection). - 401s use RFC 6750 WWW-Authenticate hints (toggleable). Failure reason is logged at debug; raw tokens are never logged. Implementation - principal.go: pure-data principal type and buildPrincipalFromSession. - bearer_auth.go: alg/kid pin, classifier, identifier sanitization, multi-aud azp gate, iat age check, per-IP failure tracker, handleBearerRequest, buildPrincipalFromBearerToken. - token_manager.go: VerifyToken now wraps a new verifyTokenWithOpts that accepts internal-only verifyOpts. Existing callers, the TokenVerifier interface, and all mocks unchanged. - middleware.go: extracted forwardAuthorized from processAuthorizedRequest; wired bearer detection after init wait + after bypass; excluded-URL Authorization strip when bearer enabled. - settings.go: ten new config fields with defaults applied in CreateConfig. - main.go: startup validation for audience + identifier-claim guard; bearer failure tracker init. Tests - bearer_auth_test.go: table-driven helper tests for every new component (parseBearerJOSEHeader, sanitizeBearerIdentifier, resolveBearerIdentifier, enforceMultiAudienceAzp, enforceIatAge, bearerFailureTracker, detectBearerToken). Integration tests through ServeHTTP covering happy path, ID-token rejection, alg=none rejection, oversized kid, multi-aud with/without azp, iat-too-old, bidi identifier, replay (100x reuse), 429 throttle trip, excluded-URL strip, roles gate, cookie-wins precedence, BearerOverridesCookie, oversized token, malformed JWT, feature-off pass-through. Startup validation for audience- required and email-identifier-rejected. - All existing tests pass unmodified (cookie-path regression). - go vet clean. golangci-lint clean (0 issues). Race detector clean on bearer tests. Documentation - README.md: bearer auth section with security highlights and config snippet; doc link in the index. - .traefik.yml: commented config block exposing every bearer knob. - docs/CONFIGURATION.md: new subsection with full parameter table. - docs/BEARER_AUTH.md: threat model, hardening matrix, failure response table, operational guidance, known follow-ups. - docs/superpowers/specs/2026-05-18-bearer-token-auth-design.md: design spec + security-review hardening history. * fix(cache): redact raw cache keys in debug logs (CodeQL go/clear-text-logging) CodeQL flagged 9 high-severity alerts (go/clear-text-logging) where the in-memory cache and the hybrid L1+L2 backend printed `key=%s` at debug. Cache callers (token cache, blacklist, introspection cache) pass raw access / refresh / id tokens as cache keys, so any debug-enabled deployment would write them to log streams. Pre-existing issue. CodeQL started flagging it on this PR because the new bearer-auth path adds a data-flow source (req.Header.Get("Authorization")) that reaches the existing logging sinks via the same cache. The cookie path had the same risk but wasn't tracked as taint by CodeQL. Fix: hash the key (SHA-256[:8] hex) before printing. Same approach the bearer-auth logger uses for principal identifiers (spec §13). Doesn't change cache semantics — same key still produces the same hash, so debug correlation across log lines is preserved without exposing the raw value. Touches both affected packages: - internal/cache/cache.go (2 sites: Set + LRU eviction) - internal/cache/backends/hybrid.go (12 sites: L1/L2 read/write/fallback) New helper `redactKey` colocated with each package (unexported, package-local) keeps the change blast radius narrow. Tests green; lint clean. * docs(bearer): how to obtain bearer tokens from the OIDC provider Adds a section walking operators through the OAuth 2.0 client_credentials flow (RFC 6749 §4.4) and the JWT bearer assertion alternative (RFC 7523), with a worked Auth0-shape curl example, a per-provider quick reference (Auth0, Okta, Keycloak, Entra v2, Cognito, GitLab, Google), operational notes (token TTL, caching, JWKS rotation, revocation, scope vs audience, secret hygiene), and a three-line validation loop. Most common operator confusion: "I enabled the feature but tokens get 401'd" — almost always missing or wrong audience. The new section makes the audience-matching requirement loud, with per-provider parameter names so people don't have to dig through IdP docs. Locations: - docs/BEARER_AUTH.md — full section under "Quick start" - README.md — short snippet + deep link
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
# Bearer Token (M2M) Authentication
|
||||
|
||||
Opt-in path that lets API clients present `Authorization: Bearer <jwt>` to
|
||||
authenticate without going through the cookie-based OIDC redirect flow.
|
||||
Designed for machine-to-machine (M2M) traffic — services calling other
|
||||
services with tokens minted by your OIDC provider.
|
||||
|
||||
The bearer path lives next to the cookie path: both go through the same
|
||||
post-auth pipeline (`forwardAuthorized`) that injects identity headers,
|
||||
checks `allowedRolesAndGroups`, applies security headers, and forwards to
|
||||
the backend. The only thing that differs is how the principal is established
|
||||
for that single request.
|
||||
|
||||
## Quick start
|
||||
|
||||
```yaml
|
||||
enableBearerAuth: true
|
||||
audience: https://api.example.com # REQUIRED when bearer is enabled
|
||||
clientID: my-api-client-id
|
||||
providerURL: https://issuer.example.com
|
||||
sessionEncryptionKey: <32+-byte secret>
|
||||
callbackURL: /oauth2/callback
|
||||
```
|
||||
|
||||
That is the minimum. Everything else has a secure default.
|
||||
|
||||
## Obtaining bearer tokens from your OIDC provider
|
||||
|
||||
The middleware only **validates** bearer tokens — minting them is the IdP's job. For M2M traffic the canonical mint flow is OAuth 2.0 **`client_credentials`** (RFC 6749 §4.4); some providers require **JWT bearer assertion** (RFC 7523) instead.
|
||||
|
||||
```
|
||||
┌────────────┐ POST /token ┌──────────┐
|
||||
│ client │ ───────────────────────────────►│ IdP │
|
||||
│ (service) │ grant_type=client_credentials │ /token │
|
||||
│ │ client_id=… │ │
|
||||
│ │ client_secret=… (or JWT) │ │
|
||||
│ │ audience=https://api.… ←── critical │
|
||||
│ │ scope=api:read … │
|
||||
│ │ ◄───────────────────────────────│ │
|
||||
│ │ access_token (JWT) │ │
|
||||
└────────────┘ └──────────┘
|
||||
│
|
||||
│ GET /protected
|
||||
│ Authorization: Bearer <access_token>
|
||||
▼
|
||||
Your service (behind Traefik + this plugin)
|
||||
```
|
||||
|
||||
The IdP returns a JWT signed by the same JWKs the middleware already trusts (it discovers them from `providerURL`/.well-known). On the first protected request, the middleware verifies signature + issuer + **audience** + `exp` + identifier claim, then forwards downstream with `X-Forwarded-User` set.
|
||||
|
||||
### Minimal worked example (Auth0-shape)
|
||||
|
||||
```bash
|
||||
# 1. Mint a token
|
||||
curl -s -X POST https://issuer.example.com/oauth/token \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": "your-m2m-client-id",
|
||||
"client_secret": "your-m2m-client-secret",
|
||||
"audience": "https://api.example.com",
|
||||
"scope": "api:read api:write"
|
||||
}'
|
||||
# → {"access_token":"eyJhbGciOiJSUzI1NiIs…","token_type":"Bearer","expires_in":86400,…}
|
||||
|
||||
# 2. Use it
|
||||
curl -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIs…' https://api.example.com/protected
|
||||
```
|
||||
|
||||
The `audience` field in the token request **must match** the `audience` you configured on the middleware. Mismatch → 401 with `Bearer error="invalid_token"`.
|
||||
|
||||
### Per-provider quick reference
|
||||
|
||||
| Provider | Grant | Token endpoint | Audience parameter | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **Auth0** | `client_credentials` | `https://TENANT.auth0.com/oauth/token` | `audience=<your API identifier>` | Register an "API" + "Machine to Machine Application" authorised against that API. Without `audience` you get an opaque /userinfo token, which the bearer path rejects. See `docs/AUTH0_AUDIENCE_GUIDE.md`. |
|
||||
| **Okta** | `client_credentials` | `https://TENANT.okta.com/oauth2/default/v1/token` | Configured in the authorization server; default `aud` is the auth-server URL | Service app must enable the `client_credentials` flow and be granted the requested scopes. |
|
||||
| **Keycloak** | `client_credentials` | `https://kc/realms/REALM/protocol/openid-connect/token` | Configure an "Audience" mapper on a client scope, or use `client_id` as the audience | Client must have `serviceAccountsEnabled: true` plus role mappings. |
|
||||
| **Entra ID / Azure AD** | `client_credentials` (v2.0 endpoint) | `https://login.microsoftonline.com/TENANT/oauth2/v2.0/token` | Pass `scope=<App ID URI>/.default`; `aud` ends up being the API's App ID URI | Requires an App Registration + API permissions + admin consent. **Use the v2.0 endpoint** — v1 issues Microsoft-proprietary access tokens that are opaque to non-Microsoft clients. |
|
||||
| **AWS Cognito** | `client_credentials` | `https://YOUR_DOMAIN.auth.REGION.amazoncognito.com/oauth2/token` | Scopes from a "Resource Server" attached to your User Pool | App client must have `client_credentials` flow enabled. Use HTTP **Basic** auth header for `client_id:client_secret`. |
|
||||
| **GitLab** | `client_credentials` | `https://gitlab.com/oauth/token` | Audience matches the GitLab issuer | Rarely used for protecting external APIs; better suited for GitLab's own resources. |
|
||||
| **Google** | **JWT bearer (RFC 7523)** — *not* `client_credentials` | `https://oauth2.googleapis.com/token` | Signed assertion JWT carries `aud=https://oauth2.googleapis.com/token`; resulting access token is **opaque** unless you specifically request a Google-issued JWT for your API | Google service-account flow is not the best fit for this middleware (opaque tokens are rejected on the bearer path). Run Auth0 / Okta / Keycloak in front, or use ID-token-based flows on the cookie path. |
|
||||
|
||||
### RFC 7523 (JWT bearer assertion) — secretless alternative
|
||||
|
||||
When shared secrets are forbidden (FAPI, internal compliance), swap `client_secret` for a signed JWT assertion:
|
||||
|
||||
```
|
||||
POST /token
|
||||
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
|
||||
assertion=<JWT signed by the client's private key>
|
||||
```
|
||||
|
||||
The assertion JWT carries `iss=<client_id>`, `sub=<client_id>`, `aud=<token endpoint>`, `exp`. The IdP verifies the signature against a public key you've pre-registered and returns an access token.
|
||||
|
||||
This middleware already supports JWT assertions on the *middleware → IdP* hop via `clientAuthMethod: private_key_jwt` (see `docs/CONFIGURATION.md`). For the *client → IdP* hop, the same pattern applies — the client signs its own assertion.
|
||||
|
||||
### Operational notes
|
||||
|
||||
- **Token TTL is typically 1–24 hours.** Clients should refresh on `401`, not on a polling timer — saves the IdP.
|
||||
- **Cache and reuse tokens.** The middleware caches verified tokens too, so repeated presentations are cheap. Clients SHOULD reuse a token until ~80 % of `expires_in`.
|
||||
- **JWKS rotation is transparent.** The middleware auto-refreshes its JWKS cache when the IdP rotates keys. Clients don't need to do anything.
|
||||
- **Revocation is generally not per-token** with `client_credentials`. If you need real-time revocation, set `requireTokenIntrospection: true` on the middleware and the IdP is consulted on every cache miss.
|
||||
- **`scope` vs `audience`.** Scope says *what the client may do*; audience says *which service the token is for*. The middleware enforces audience; the backend service should enforce scope.
|
||||
- **Secret hygiene.** Store `client_secret` in a secrets manager (Vault, AWS Secrets Manager, Kubernetes `Secret`). For higher assurance, switch the client to `private_key_jwt` (no shared secret at all).
|
||||
|
||||
### Quickest validation loop
|
||||
|
||||
```bash
|
||||
# 1. Mint
|
||||
TOKEN=$(curl -s -X POST https://issuer.example.com/oauth/token \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"grant_type":"client_credentials","client_id":"…","client_secret":"…","audience":"https://api.example.com"}' \
|
||||
| jq -r .access_token)
|
||||
|
||||
# 2. Inspect claims to confirm aud/iss/exp match the middleware config
|
||||
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq
|
||||
|
||||
# 3. Hit the protected route
|
||||
curl -i -H "Authorization: Bearer $TOKEN" https://api.example.com/protected
|
||||
```
|
||||
|
||||
`HTTP/1.1 200` with `X-Forwarded-User` on the backend confirms the loop works end-to-end. `401` with `WWW-Authenticate: Bearer error="invalid_token"` plus a middleware debug log explaining the rejection (audience mismatch, ID token presented, `iat` outside the 24h window, etc.) confirms the hardening is firing as designed.
|
||||
|
||||
## Threat model and design rules
|
||||
|
||||
Bearer authentication has materially different security properties from
|
||||
cookie sessions: no `HttpOnly`/`Secure`/`SameSite` shielding, the token is
|
||||
visible in headers and logs, and it's easier to exfiltrate. The bearer path
|
||||
treats every one of these as a first-class concern.
|
||||
|
||||
| Property | Behaviour | Why |
|
||||
|---|---|---|
|
||||
| Default state | `enableBearerAuth=false` | Bearer is opt-in; existing deployments observe no change. |
|
||||
| Audience | **Mandatory.** Startup fails if `audience` is empty when bearer is enabled. | Eliminates the "token issued for service B accepted by service A" confusion attack. |
|
||||
| Token format | JWT only (3 segments, JOSE-encoded). Opaque tokens are not accepted on the bearer path. | Matches the validation pipeline; opaque tokens require introspection only and bypass JWT-specific defences. |
|
||||
| `alg` allowlist | Hard-pinned asymmetric: `RS256/384/512`, `PS256/384/512`, `ES256/384/512`. Checked **before** any JWKS fetch. | Denies `alg=none` and `alg=HS*` probes; prevents attacker noise from amplifying into JWKS round-trips. |
|
||||
| `kid` hardening | Max 256 bytes; charset `[A-Za-z0-9._\-=]`. Checked **before** JWKS fetch. | Prevents cache-key explosion / pathological-`kid` JWKS amplification. |
|
||||
| Token type | ID tokens are explicitly rejected (`nonce` claim, `typ: at+jwt`, `token_use=id`, scope/aud heuristics — reuses the existing `detectTokenType` helper). | ID tokens are not API credentials; treating them as such is classic token confusion. |
|
||||
| Multi-audience | When `aud` is an array of length > 1, the token must carry `azp == clientID`. | OIDC §2 hardening against tokens minted for one client being replayed by another. |
|
||||
| `iat` upper-age | Rejects tokens older than `maxTokenAgeSeconds` (default 24h). | Bounds clock-manipulation / forever-token abuse, even if `exp` is far in the future. |
|
||||
| Identifier claim | `bearerIdentifierClaim` (default `"sub"`). Resolved value drives `X-Forwarded-User`. | Decoupled from the cookie path's `UserIdentifierClaim` (default `email`) so the M2M flow can never accidentally trust an unverified email. |
|
||||
| Identifier sanitisation | Length cap (`maxIdentifierLength`, default 256). Rejects control chars, Unicode bidi-overrides (U+202A–U+202E, U+2066–U+2069), and the delimiters `, ; =`. | Defence in depth against downstream header injection / log injection / admin-UI spoofing. |
|
||||
| JTI replay marking | Bearer path skips the JTI **Set** (so the same token can be reused until `exp`) but the **Get** stays active. | Allows legitimate bearer reuse without false-positive replay detection; revoked tokens (added to the blacklist by `RevokeToken`) still fail immediately. |
|
||||
| Mixed bearer + cookie | **Cookie wins by default.** Flip to bearer-wins with `bearerOverridesCookie=true`. | Safer against browser/extension/proxy bearer injection scenarios. The cookie is the authoritative authenticator when present. |
|
||||
| `Authorization` strip | `stripAuthorizationHeader=true` by default. | Keeps the raw token out of downstream services and their logs. |
|
||||
| Excluded URLs | `Authorization` is stripped on excluded paths when `enableBearerAuth=true`. | Prevents bearer leakage into public health/metrics endpoint logs and prevents recon via excluded paths. |
|
||||
| Per-IP throttle | After `bearerFailureThreshold` consecutive 401s from one source IP within `bearerFailureWindowSeconds`, further bearer requests from that IP return `429 Too Many Requests` + `Retry-After` for `bearerFailurePenaltySeconds`. | Limits offline-guessing-style attacks and protects the shared rate-limiter / JWKS endpoint. |
|
||||
| Optional introspection | `requireTokenIntrospection=true` calls RFC 7662 introspection on every cache miss. Introspection result is cached briefly. Endpoint failure returns `503` (distinguishes infra outage from credential rejection). | Real-time revocation for high-assurance environments. Adds per-request IdP latency. |
|
||||
| Response shape | `401 Unauthorized` with generic body. `WWW-Authenticate: Bearer error="invalid_token"` per RFC 6750 §3 (toggleable via `bearerEmitWWWAuthenticate`). `403` for roles/groups denial. `429` for throttle. `503` for introspection-endpoint outage. | Auditable from spec to code; reason categories never leak into the response body. |
|
||||
| Logging | Failure reason + identifier hash (SHA-256 truncated to 8 hex chars) logged at debug. Raw tokens are never logged. | Audit trail without secrets-in-logs. |
|
||||
|
||||
## Configuration reference
|
||||
|
||||
| Field | Default | Description |
|
||||
|---|---|---|
|
||||
| `enableBearerAuth` | `false` | Master switch for the bearer path. |
|
||||
| `audience` | (unset) | **Required** when `enableBearerAuth=true`. Reuses the existing global `audience` field. |
|
||||
| `bearerIdentifierClaim` | `"sub"` | JWT claim used as the principal identifier. `"email"` is rejected at startup. |
|
||||
| `stripAuthorizationHeader` | `true` | Remove the `Authorization` header before forwarding to the backend. Disable only when a downstream needs to re-verify the bearer. |
|
||||
| `bearerEmitWWWAuthenticate` | `true` | Include `WWW-Authenticate: Bearer error="..."` on 401 responses (RFC 6750 §3). Disable to reduce recon signal. |
|
||||
| `bearerOverridesCookie` | `false` | Cookie wins when both are present (default). Set `true` for the AWS/GCP/Kubernetes bearer-wins convention. |
|
||||
| `maxTokenAgeSeconds` | `86400` | Upper bound on `iat` claim age (24h). Set `0` to disable the check (not recommended). |
|
||||
| `maxIdentifierLength` | `256` | Length cap for the post-sanitisation identifier. |
|
||||
| `bearerFailureThreshold` | `20` | Consecutive 401s from one IP that trip the throttle. |
|
||||
| `bearerFailureWindowSeconds` | `60` | Rolling window over which 401s are counted. |
|
||||
| `bearerFailurePenaltySeconds` | `60` | Duration of the 429 penalty box after the threshold trips. |
|
||||
| `requireTokenIntrospection` | `false` | Call RFC 7662 introspection on every cache miss. Adds per-request IdP latency. |
|
||||
|
||||
## What the bearer path does NOT do
|
||||
|
||||
- **Human-user / browser flows.** The bearer path is M2M-only in this
|
||||
iteration. Browser SPAs that want to attach a bearer to fetch calls work
|
||||
if your backend treats them as machine clients, but the spec defaults are
|
||||
tuned for service-to-service traffic.
|
||||
- **Opaque access tokens.** Tokens must be JWTs. Introspection is a
|
||||
revocation overlay on top of JWT verification, not a substitute for it.
|
||||
- **`email_verified` enforcement.** The bearer path rejects `email` as the
|
||||
identifier claim at startup precisely because `email_verified` is not
|
||||
enforced in this iteration. Adding human-user bearer support is a
|
||||
follow-up that must include this check.
|
||||
- **mTLS / API keys.** Out of scope. The `principal` abstraction enables
|
||||
adding these later as additional auth methods that produce a principal
|
||||
for the shared `forwardAuthorized` pipeline.
|
||||
- **SSE / WebSocket bypass with bearer.** Bypass paths keep their existing
|
||||
cookie-only behaviour; bearer headers are ignored on those endpoints.
|
||||
Documented limitation; widen by removing the bypass if you need bearer on
|
||||
streaming endpoints.
|
||||
|
||||
## Operational guidance
|
||||
|
||||
- **Always set `strictAudienceValidation: true` when bearer is enabled.**
|
||||
Startup logs a recommendation if you don't.
|
||||
- **Set a tight `maxTokenAgeSeconds`** for environments where tokens are
|
||||
expected to be minted frequently — the default 24h is conservative.
|
||||
- **Enable `requireTokenIntrospection`** if your IdP supports it and
|
||||
revocation latency matters. Bearer-path introspection caches results for
|
||||
a short window per token.
|
||||
- **Monitor 429s.** Sustained 429 traffic indicates either a buggy client
|
||||
loop or an active credential-stuffing attempt. The throttle is your
|
||||
primary signal for both.
|
||||
- **`stripAuthorizationHeader=false` extends the token's blast radius** to
|
||||
every downstream service that sees the request. Treat those services'
|
||||
logs as token stores.
|
||||
- **Bearer reuse is normal.** Don't enable per-token rate limiting; that's
|
||||
what `bearerFailureThreshold` is for (per-IP, not per-token).
|
||||
- **Cookie-wins is the safer default.** Only flip `bearerOverridesCookie`
|
||||
if you control all clients and have audited that none of them present a
|
||||
cookie alongside a bearer they don't intend to authenticate with.
|
||||
|
||||
## Failure response matrix
|
||||
|
||||
| Trigger | Status | Body | `WWW-Authenticate` |
|
||||
|---|---|---|---|
|
||||
| Empty bearer after prefix | 401 | `Unauthorized` | `Bearer error="invalid_request"` |
|
||||
| Token over `MaxLength` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Not a 3-segment JWT | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Disallowed `alg` (e.g. none, HS*) | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Missing / oversized / bad-charset `kid` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Signature / issuer / audience / `exp` failure | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| `iat` older than `maxTokenAgeSeconds` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Multi-audience token without matching `azp` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Detected as ID token | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| JTI blacklisted (revoked) | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Introspection reports `active=false` | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Introspection endpoint failure | 503 | `Service Unavailable` | (none) |
|
||||
| Identifier claim missing / empty | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Identifier fails sanitisation | 401 | `Unauthorized` | `Bearer error="invalid_token"` |
|
||||
| Per-IP failure threshold tripped | 429 | `Too Many Requests` | (none); `Retry-After: <bearerFailurePenaltySeconds>` |
|
||||
| Roles / groups not allowed | 403 | `Access denied` | (none) |
|
||||
|
||||
## Known follow-ups (deferred)
|
||||
|
||||
These are documented as future work, not blockers:
|
||||
|
||||
- **Human-user bearer with `email_verified` enforcement.** Requires
|
||||
decoupling the email-claim guard from the startup rejection and adding a
|
||||
per-request `email_verified=true` check.
|
||||
- **Introspection respects `client_assertion`.** The existing introspection
|
||||
helper uses `client_secret_basic` only; operators on `private_key_jwt`
|
||||
will see introspection silently use basic auth.
|
||||
- **Per-route bearer configuration.** Single middleware-wide setting in this
|
||||
iteration.
|
||||
|
||||
## References
|
||||
|
||||
- [PR design spec](superpowers/specs/2026-05-18-bearer-token-auth-design.md) — full design rationale, alternatives considered, and per-section sign-off history.
|
||||
- [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750) — Bearer Token Usage.
|
||||
- [RFC 7662](https://www.rfc-editor.org/rfc/rfc7662) — OAuth 2.0 Token Introspection.
|
||||
- [RFC 9068](https://www.rfc-editor.org/rfc/rfc9068) — JWT Profile for OAuth 2.0 Access Tokens.
|
||||
@@ -261,6 +261,26 @@ strictAudienceValidation: true
|
||||
| `disableReplayDetection` | bool | `false` | Disable JTI-based replay attack detection |
|
||||
| `allowPrivateIPAddresses` | bool | `false` | Allow private IPs in provider URLs |
|
||||
|
||||
### Bearer-token (M2M) authentication
|
||||
|
||||
Opt-in path that accepts `Authorization: Bearer <jwt>` instead of the cookie
|
||||
session flow. M2M-only, default off, audience-mandatory. See
|
||||
[docs/BEARER_AUTH.md](BEARER_AUTH.md) for the threat model and operational
|
||||
guidance.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `enableBearerAuth` | bool | `false` | Master switch. Startup fails if true with empty `audience` or with `bearerIdentifierClaim=email`. |
|
||||
| `bearerIdentifierClaim` | string | `"sub"` | JWT claim used as the principal identifier. `"email"` is rejected at startup. |
|
||||
| `stripAuthorizationHeader` | bool | `true` | Strip `Authorization` from forwarded requests after successful bearer auth. |
|
||||
| `bearerEmitWWWAuthenticate` | bool | `true` | Emit RFC 6750 `WWW-Authenticate: Bearer error="..."` hints on 401. |
|
||||
| `bearerOverridesCookie` | bool | `false` | Cookie wins when both bearer and cookie are present (default). Set true for bearer-wins. |
|
||||
| `maxTokenAgeSeconds` | int64 | `86400` | Upper bound on `iat` claim age (24h). 0 disables the check. |
|
||||
| `maxIdentifierLength` | int | `256` | Length cap on the sanitised principal identifier. |
|
||||
| `bearerFailureThreshold` | int | `20` | Consecutive 401s from one source IP that trip the throttle. |
|
||||
| `bearerFailureWindowSeconds` | int | `60` | Rolling window for counting 401s. |
|
||||
| `bearerFailurePenaltySeconds` | int | `60` | 429 + `Retry-After` duration after the threshold trips. |
|
||||
|
||||
---
|
||||
|
||||
## Session Management
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
# Bearer Token Authentication — Design Spec
|
||||
|
||||
- **Date**: 2026-05-18
|
||||
- **Status**: Design — pending implementation plan
|
||||
- **Supersedes**: PR #93 (broken implementation; recommended to close in favour of this design)
|
||||
|
||||
## 1. Summary
|
||||
|
||||
Add an opt-in path that lets API clients (machine-to-machine) authenticate by presenting a signed access token in the `Authorization: Bearer <token>` header, bypassing the cookie-based OIDC redirect flow. Identity, roles, and authorization checks remain consistent with the existing cookie path; the only thing that changes is how the principal is established for that single request.
|
||||
|
||||
The feature is implemented by extracting a shared `forwardAuthorized` pipeline from the existing `processAuthorizedRequest`, introducing a `principal` value type, and adding a small bearer-specific entrypoint that builds a principal directly from a verified JWT — without synthesising a fake `SessionData`.
|
||||
|
||||
## 2. Motivation
|
||||
|
||||
PR #93 attempted this feature by building an in-memory `SessionData` from JWT claims and reusing `processAuthorizedRequest`. The approach has three latent defects:
|
||||
|
||||
1. The synthetic session omits `mainSession.Values["user_identifier"]`. `processAuthorizedRequest` reads it via `GetUserIdentifier()`; when empty it bails to `defaultInitiateAuthentication` and issues an OIDC redirect. The feature is non-functional in practice despite the unit test passing.
|
||||
2. `verifyToken` accepts both ID tokens (audience match against `clientID`) and access tokens. ID tokens are not API credentials; treating them as such is a classic token-confusion vector.
|
||||
3. `verifyToken` adds JTI to the replay blacklist on first verify. Once the verified-token cache evicts, subsequent reuse of the same bearer token triggers a false-positive replay rejection.
|
||||
|
||||
Rather than patch a synthetic-session approach that will keep generating bugs as `SessionData` evolves, this spec replaces it with a cleaner abstraction where session lifecycle and post-auth header injection live in separate units.
|
||||
|
||||
## 3. Goals
|
||||
|
||||
- Accept `Authorization: Bearer <jwt>` from M2M clients, validate the token, and forward the request downstream with identity headers populated.
|
||||
- Enforce the same `allowedRolesAndGroups` policy as the cookie path.
|
||||
- Default-off; safe defaults when enabled (audience required, ID tokens rejected, identifier sanitised).
|
||||
- No behavioural change to the cookie path. Existing tests must continue to pass without modification.
|
||||
|
||||
## 4. Non-Goals
|
||||
|
||||
- Human-user / browser flows. Bearer is M2M-only in this iteration.
|
||||
- Pure opaque access tokens on the bearer path. Tokens must be JWTs; introspection (RFC 7662) is supported *on top of* JWT verification for revocation state, not as a substitute for it.
|
||||
- mTLS, API keys, or any other auth method. The `principal` abstraction enables them later, but they are not delivered here.
|
||||
- Per-route bearer configuration. Single middleware-wide setting.
|
||||
|
||||
## 5. Decided Requirements
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Consumer type | Machine-to-machine (M2M) only |
|
||||
| Token format | JWT only (signature, issuer, audience, exp) |
|
||||
| Audience | Mandatory when feature enabled; startup fails if `Audience == ""` |
|
||||
| Token type | Access tokens only; ID tokens explicitly rejected |
|
||||
| Revocation | JWT-only verification by default; introspection (RFC 7662) opt-in via existing `RequireTokenIntrospection` |
|
||||
| Identity claim | New `BearerIdentifierClaim` config (string, default `"sub"`). Bearer path reads this claim exclusively; does NOT use `UserIdentifierClaim` (which defaults to `"email"` and drives the cookie path). Resolved value must be a non-empty string. `sub` is mandatory per `jwt.go:416` regardless, so even with a different `BearerIdentifierClaim` the token must still carry a valid `sub`. Decoupling avoids the M2M-vs-human-user identity-claim conflict and the email-spoofing footgun. |
|
||||
| Identifier sanitisation | Reject value containing any `unicode.IsControl` char, any Unicode bidi-override (U+202A–U+202E, U+2066–U+2069), leading/trailing whitespace, commas, semicolons, equals signs. Max length 256 bytes. |
|
||||
| Token classifier | **Reuse existing `detectTokenType(jwt, token)` at `token_manager.go:187-303`** which already handles `nonce`, `typ: at+jwt`, `token_use`, `scope`, and aud-vs-clientID priority. Bearer path rejects any token where `detectTokenType == true` (ID token). Do not invent a parallel classifier. |
|
||||
| Algorithm pinning | Hard-pin `alg ∈ {RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512}`, enforced **before** JWKS lookup on the bearer path. Prevents wasted JWKS fetches for `alg=none`/HS attacker probes. |
|
||||
| `kid` hardening | `kid` ≤ 256 bytes, charset `[A-Za-z0-9._\-=]`. Reject before JWKS lookup. |
|
||||
| Token age | Bearer path enforces `now - iat <= MaxTokenAgeSeconds` (default 86400 / 24h, configurable). Cookie path unchanged. |
|
||||
| Multi-audience policy | If `aud` is an array (length > 1), require `azp` claim to be present and equal to `clientID`. Single-string `aud` unaffected. |
|
||||
| Mixed bearer + cookie precedence | **Cookie wins by default** when both are presented (safer for browser scenarios). Operator opt-in: `BearerOverridesCookie=true` to flip. Either way, a warning is logged on the request. |
|
||||
| Bearer + excluded URL | `Authorization` header is **stripped** before forwarding when the request hits an excluded URL. Prevents bearer leaking into public endpoints' downstream logs and prevents recon via excluded paths. |
|
||||
| Per-source bearer 401 throttle | New sharded cache `failedBearerAttempts` keyed by client IP. After N (default 20) consecutive 401s from one IP within 1 minute, reject further bearer requests from that IP with 429 for 60s. Applied BEFORE `verifyToken` to deny JWKS amplification. |
|
||||
| `Authorization` header passthrough | New `StripAuthorizationHeader` config, default `true` |
|
||||
| Roles/groups gating | Same `allowedRolesAndGroups` rules as cookie path |
|
||||
| Default state | `EnableBearerAuth` = `false` |
|
||||
| JTI replay marking | Suppressed on bearer path; cookie path unchanged |
|
||||
| Failure response shape | 401 with generic body; `WWW-Authenticate: Bearer error="invalid_token"` per RFC 6750 |
|
||||
| Introspection endpoint outage | 503 (distinguishes infra outage from token rejection) |
|
||||
| Mixed bearer + cookie | Bearer wins; cookie ignored on that request |
|
||||
| SSE/WS bypass + bearer | Bypass paths keep cookie-only check; bearer header ignored on SSE/WS |
|
||||
|
||||
## 6. Architecture
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
HTTP req ──► │ ServeHTTP │ (existing entry; adds bearer detection)
|
||||
└─────────┬────────┘
|
||||
┌───────────┴────────────┐
|
||||
▼ ▼
|
||||
cookie / session bearer (Authorization: Bearer …)
|
||||
│ │
|
||||
▼ ▼
|
||||
┌────────────────┐ ┌────────────────────┐
|
||||
│ buildPrincipal │ │ buildPrincipal │
|
||||
│ FromSession() │ │ FromBearerToken() │
|
||||
└────────┬───────┘ └─────────┬──────────┘
|
||||
│ produces *principal │
|
||||
└──────────────┬───────────┘
|
||||
▼
|
||||
┌────────────────────────────┐
|
||||
│ forwardAuthorized(rw,req,p)│ (shared pipeline)
|
||||
│ • roles/groups gate │
|
||||
│ • header injection │
|
||||
│ • header templates │
|
||||
│ • security headers │
|
||||
│ • cookie stripping │
|
||||
│ • next.ServeHTTP │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
**Invariant**: `forwardAuthorized` never touches session storage. Session-specific concerns (Save, IsDirty, backchannel-logout invalidation) stay inside `processAuthorizedRequest` around the call to `forwardAuthorized`.
|
||||
|
||||
**Feature gate**: when `EnableBearerAuth == false`, the bearer-detection check in `ServeHTTP` is a no-op. Existing deployments observe byte-identical behaviour.
|
||||
|
||||
## 7. Components
|
||||
|
||||
### 7.1 `principal` type (new file `principal.go`)
|
||||
|
||||
```go
|
||||
type principalSource int
|
||||
|
||||
const (
|
||||
sourceSession principalSource = iota
|
||||
sourceBearer
|
||||
)
|
||||
|
||||
type principal struct {
|
||||
Identifier string // drives X-Forwarded-User
|
||||
Email string // optional, "" for M2M
|
||||
Subject string // sub claim
|
||||
ClientID string // azp / client_id, M2M caller
|
||||
Claims map[string]interface{} // raw claims for templates / groups
|
||||
AccessToken string // for X-Auth-Request-Token (gated by minimalHeaders)
|
||||
IDToken string // "" on bearer path
|
||||
RefreshToken string // "" on bearer path
|
||||
Source principalSource
|
||||
}
|
||||
```
|
||||
|
||||
Pure data. No methods that mutate it. No I/O. No manager pointer.
|
||||
|
||||
### 7.2 `buildPrincipalFromSession(*SessionData) *principal` (new in `principal.go`)
|
||||
|
||||
Read-only adapter over existing `SessionData` getters: `GetUserIdentifier`, `GetEmail`, `GetAccessToken`, `GetIDToken`, `GetRefreshToken`, cached claims via `GetIDTokenClaims`. Does not write back to the session. This is the only function that still knows about `SessionData`.
|
||||
|
||||
### 7.3 `buildPrincipalFromBearerToken(token string) (*principal, error)` (new in `bearer_auth.go`)
|
||||
|
||||
1. **Length / format guards**: `len(token) <= AccessTokenConfig.MaxLength`, exactly two dots, non-empty after trim.
|
||||
2. **Parse header for early alg/kid pinning** (without trusting payload): decode JOSE header; reject if `alg` ∉ asymmetric allowlist; reject if `kid` missing, > 256 bytes, or contains chars outside `[A-Za-z0-9._\-=]`. This happens **before** JWKS lookup so attacker noise doesn't amplify into JWKS fetches.
|
||||
3. **Per-IP 401 throttle check**: if this IP is in the `failedBearerAttempts` penalty box, return 429 immediately.
|
||||
4. `t.verifyToken(token, verifyOpts{skipReplayMarking: true})` — reuses signature, issuer, audience, expiration, JTI Get (replay detection). The `skipReplayMarking` flag gates ONLY the JTI Set at `token_manager.go:108-143`; the JTI Get at `token_manager.go:44-47, 80-89` remains active so revoked tokens (via `RevokeToken` adding to blacklist) are still rejected.
|
||||
5. **Re-parse claims** (`parseJWT(token)` is cheap and already done internally; reuse via a single decode if practical).
|
||||
6. **Token-type guard**: call existing `detectTokenType(jwt, token)` (`token_manager.go:187-303`). Reject when it returns `true` (ID token). Belt-and-braces: also reject if `claims["nonce"]` is a non-empty string or `claims["token_use"] == "id"`.
|
||||
7. **Multi-audience hardening**: if `claims["aud"]` is a `[]interface{}` with length > 1, require `claims["azp"]` to be a non-empty string equal to `t.clientID`; reject otherwise.
|
||||
8. **`iat` upper-age bound**: reject when `time.Now().Unix() - int64(claims["iat"].(float64)) > MaxTokenAgeSeconds` (default 86400).
|
||||
9. **Optional introspection**: if `requireTokenIntrospection` is set, call `introspectToken`; reject if `active == false` (401); surface 503 on transport failure. Bearer-path introspection cache TTL is capped at 60s (not 5min) to keep the "real-time revocation" promise close to true.
|
||||
10. **Identifier resolution**: read `t.bearerIdentifierClaim` (defaults to `"sub"`); do NOT use `t.userIdentifierClaim` (cookie path's setting, default `email`). The bearer path does NOT fall back to other claims because `jwt.Verify` already enforces non-empty `sub` (`jwt.go:416-419`). Empty/missing identifier → 401.
|
||||
11. **Identifier sanitisation**: trim, then reject if length > 256 OR contains any of: `unicode.IsControl`, bidi-override (U+202A–U+202E, U+2066–U+2069), `,`, `;`, `=`.
|
||||
12. Return `&principal{ Source: sourceBearer, … }`.
|
||||
|
||||
On any failure path: increment the per-IP `failedBearerAttempts` counter; return the appropriate HTTP status (401 / 403 / 429 / 503) without revealing the failure reason in the response body. Reason is logged at debug only, with the identifier (if resolved) hashed via SHA-256 truncated to 8 hex chars.
|
||||
|
||||
### 7.4 `forwardAuthorized(rw, req, *principal)` (new in `middleware.go`, extracted)
|
||||
|
||||
The shared post-auth pipeline. Lifted verbatim from the existing `processAuthorizedRequest`:
|
||||
|
||||
1. Roles/groups extraction via existing `extractGroupsAndRolesFromClaims`.
|
||||
2. `allowedRolesAndGroups` gate (existing logic).
|
||||
3. Inject `X-Forwarded-User`, `X-User-Groups`, `X-User-Roles`.
|
||||
4. Inject `X-Auth-Request-*` (gated by `minimalHeaders`).
|
||||
5. Header templates.
|
||||
6. Security headers.
|
||||
7. Cookie strip when `stripAuthCookies`.
|
||||
8. **New**: `Authorization` header strip when `stripAuthorizationHeader` AND `principal.Source == sourceBearer`.
|
||||
9. `t.next.ServeHTTP(rw, req)`.
|
||||
|
||||
Does not call `Save`, does not check `IsDirty`. Session persistence stays with the cookie-path caller.
|
||||
|
||||
### 7.5 `handleBearerRequest(rw, req)` (new in `bearer_auth.go`)
|
||||
|
||||
```
|
||||
1. Detect "Authorization: Bearer <token>" (case-insensitive prefix).
|
||||
2. token = TrimSpace(authHeader[7:]); reject empty.
|
||||
3. p, err := buildPrincipalFromBearerToken(token).
|
||||
On err → 401 with WWW-Authenticate, log reason at debug.
|
||||
4. forwardAuthorized(rw, req, p).
|
||||
```
|
||||
|
||||
Target: ~40 lines.
|
||||
|
||||
### 7.6 Refactor of `processAuthorizedRequest` (modify `middleware.go`)
|
||||
|
||||
Splits along the principal boundary:
|
||||
- Session-specific part (backchannel-logout invalidation, `IsDirty` / `Save`) stays in `processAuthorizedRequest`.
|
||||
- Everything else moves to `forwardAuthorized`.
|
||||
- `processAuthorizedRequest` ends with `forwardAuthorized(rw, req, buildPrincipalFromSession(session))`.
|
||||
|
||||
### 7.7 `verifyOpts` extension to `verifyToken` (modify `token_manager.go`)
|
||||
|
||||
Add a parameter struct:
|
||||
```go
|
||||
type verifyOpts struct {
|
||||
skipReplayMarking bool // suppress JTI Set (token_manager.go:108-143); blacklist Get stays active
|
||||
}
|
||||
```
|
||||
|
||||
Both the type and field are unexported (internal-only knob). Signature change: `verifyToken(token string)` becomes `verifyToken(token string, opts verifyOpts)`. Existing callers pass `verifyOpts{}` (zero value = current behaviour). Bearer path passes `verifyOpts{skipReplayMarking: true}`.
|
||||
|
||||
**Critical semantics — must be reflected in implementation and tests:**
|
||||
- `skipReplayMarking` only gates the **Set** at `token_manager.go:108-143` (the call adding the JTI to the blacklist and replay cache).
|
||||
- The blacklist **Get** at `token_manager.go:44-47, 80-89` stays unconditionally active on the bearer path. Tokens revoked via `RevokeToken` (which adds the JTI to the blacklist) MUST still be rejected on the bearer path.
|
||||
- Must NOT be implemented by mutating `t.disableReplayDetection` (struct field) — that would create a cross-request race that disables replay protection globally.
|
||||
|
||||
A targeted regression test exercises: bearer token verified once → admin calls `RevokeToken` adding the JTI to the blacklist → same token replayed → 401.
|
||||
|
||||
### 7.8 Config additions (modify `settings.go`)
|
||||
|
||||
```go
|
||||
EnableBearerAuth bool `json:"enableBearerAuth,omitempty"`
|
||||
BearerIdentifierClaim string `json:"bearerIdentifierClaim,omitempty"`
|
||||
StripAuthorizationHeader bool `json:"stripAuthorizationHeader,omitempty"`
|
||||
BearerEmitWWWAuthenticate bool `json:"bearerEmitWWWAuthenticate,omitempty"`
|
||||
BearerOverridesCookie bool `json:"bearerOverridesCookie,omitempty"`
|
||||
MaxTokenAgeSeconds int64 `json:"maxTokenAgeSeconds,omitempty"`
|
||||
MaxIdentifierLength int `json:"maxIdentifierLength,omitempty"`
|
||||
BearerFailureThreshold int `json:"bearerFailureThreshold,omitempty"`
|
||||
BearerFailureWindowSeconds int `json:"bearerFailureWindowSeconds,omitempty"`
|
||||
BearerFailurePenaltySeconds int `json:"bearerFailurePenaltySeconds,omitempty"`
|
||||
```
|
||||
|
||||
Defaults (applied in `CreateConfig` for the bearer-related fields; values >0 only honoured when `EnableBearerAuth=true`):
|
||||
- `EnableBearerAuth`: `false`.
|
||||
- `BearerIdentifierClaim`: `"sub"`.
|
||||
- `StripAuthorizationHeader`: `true`.
|
||||
- `BearerEmitWWWAuthenticate`: `true` (RFC 6750 hint enabled by default; flip to false if recon-exposure is a concern).
|
||||
- `BearerOverridesCookie`: `false` (cookie wins when both present; flip to `true` for the legacy/industry-default behaviour).
|
||||
- `MaxTokenAgeSeconds`: `86400` (24h upper bound on `iat`).
|
||||
- `MaxIdentifierLength`: `256`.
|
||||
- `BearerFailureThreshold`: `20` (consecutive 401s per IP before throttle).
|
||||
- `BearerFailureWindowSeconds`: `60`.
|
||||
- `BearerFailurePenaltySeconds`: `60` (429 reply for this long after threshold tripped).
|
||||
|
||||
### 7.9 Startup validation (modify `main.go` `New()`)
|
||||
|
||||
- `EnableBearerAuth && Audience == ""` → fatal error.
|
||||
- `EnableBearerAuth && !StrictAudienceValidation` → warning log (recommended hardening).
|
||||
- `EnableBearerAuth && BearerIdentifierClaim == "email"` → fatal error (the bearer path is M2M and an `email` identifier without `email_verified` enforcement is a spoofing vector; default `BearerIdentifierClaim=sub` avoids this; explicit override to `email` is rejected).
|
||||
- `EnableBearerAuth && MaxTokenAgeSeconds <= 0` → reset to default 86400 with info log.
|
||||
- `EnableBearerAuth && BearerFailureThreshold <= 0` → reset to default 20 with info log.
|
||||
|
||||
## 8. Data Flow
|
||||
|
||||
### 8.1 Bearer path
|
||||
|
||||
```
|
||||
ServeHTTP entry (pre-init paths unchanged: logout, backchannel, frontchannel, excluded URLs, SSE/WS bypass)
|
||||
│
|
||||
├─ enableBearerAuth == false? → fall through to cookie path
|
||||
│
|
||||
└─ enableBearerAuth == true AND Authorization starts with "Bearer "
|
||||
│
|
||||
▼
|
||||
handleBearerRequest
|
||||
│
|
||||
├─ format guards (empty, length, segment count)
|
||||
│
|
||||
▼
|
||||
verifyToken(token, verifyOpts{SkipReplayMarking: true})
|
||||
│ signature, issuer, audience (strict), exp
|
||||
│
|
||||
▼
|
||||
classifyToken(claims) → reject ID tokens
|
||||
│
|
||||
▼
|
||||
if requireTokenIntrospection: introspectToken → active check
|
||||
│
|
||||
▼
|
||||
resolveIdentifier(claims) → sanitiseIdentifier
|
||||
│
|
||||
▼
|
||||
principal{Source: sourceBearer, …}
|
||||
│
|
||||
▼
|
||||
forwardAuthorized(rw, req, principal)
|
||||
│
|
||||
├─ roles/groups gate (403 on deny)
|
||||
├─ header injection
|
||||
├─ header templates
|
||||
├─ security headers
|
||||
├─ strip OIDC cookies (existing)
|
||||
├─ strip Authorization header (new, when configured)
|
||||
└─ next.ServeHTTP(rw, req)
|
||||
```
|
||||
|
||||
### 8.2 Cookie path (refactored, semantically unchanged)
|
||||
|
||||
```
|
||||
processAuthorizedRequest
|
||||
1. Session validity / backchannel-logout invalidation (unchanged).
|
||||
2. principal := buildPrincipalFromSession(session).
|
||||
3. forwardAuthorized(rw, req, principal).
|
||||
4. if session.IsDirty(): session.Save().
|
||||
```
|
||||
|
||||
## 9. Error Handling
|
||||
|
||||
| Trigger | Status | Body | WWW-Authenticate | Debug log reason |
|
||||
|---|---|---|---|---|
|
||||
| Empty bearer after prefix | 401 | `Unauthorized` | `Bearer error="invalid_request"` | empty bearer token |
|
||||
| Token over MaxLength | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token exceeds max length |
|
||||
| Not a 3-segment JWT | 401 | `Unauthorized` | `Bearer error="invalid_token"` | malformed JWT |
|
||||
| Disallowed `alg` (e.g. none, HS*) | 401 | `Unauthorized` | `Bearer error="invalid_token"` | unsupported alg |
|
||||
| Missing/oversized/bad-charset `kid` | 401 | `Unauthorized` | `Bearer error="invalid_token"` | invalid kid |
|
||||
| Signature / issuer / aud / exp fail | 401 | `Unauthorized` | `Bearer error="invalid_token"` | reason from verifyToken (category only) |
|
||||
| `iat` older than MaxTokenAgeSeconds | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token too old (iat outside age bound) |
|
||||
| Multi-aud without matching `azp` | 401 | `Unauthorized` | `Bearer error="invalid_token"` | multi-aud token without azp match |
|
||||
| Detected as ID token | 401 | `Unauthorized` | `Bearer error="invalid_token"` | ID tokens not accepted on bearer path |
|
||||
| JTI blacklisted (revoked) | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token JTI in blacklist |
|
||||
| Introspection `active=false` | 401 | `Unauthorized` | `Bearer error="invalid_token"` | token inactive at IdP |
|
||||
| Introspection endpoint failure | 503 | `Service Unavailable` | (none) | introspection unavailable |
|
||||
| Identifier claim missing/empty | 401 | `Unauthorized` | `Bearer error="invalid_token"` | no identifier claim |
|
||||
| Identifier fails sanitisation | 401 | `Unauthorized` | `Bearer error="invalid_token"` | invalid identifier characters |
|
||||
| Per-IP failure threshold tripped | 429 | `Too Many Requests` | (none); `Retry-After: <BearerFailurePenaltySeconds>` | source IP in penalty box |
|
||||
| Roles/groups not allowed | 403 | `Access denied` | (none) | user not in allowedRolesAndGroups |
|
||||
|
||||
Responses never include token contents, never include the raw failure reason, and never set `Location` headers (API clients cannot follow redirects).
|
||||
|
||||
## 10. Edge Cases
|
||||
|
||||
1. **Both bearer header and cookie session present.** Cookie wins by default (safer against browser/extension/proxy bearer injection). `BearerOverridesCookie=true` flips to bearer-wins. Either way: WARN log includes both source markers so operators can audit.
|
||||
2. **`Authorization: Basic …`.** Not bearer; cookie path runs as today.
|
||||
3. **`Authorization: Bearer ` (trailing space, no value).** Empty after trim → 401.
|
||||
4. **Mixed-case prefix (`bearer`, `BEARER`, `BeArEr`).** Case-insensitive prefix check; token value preserved verbatim.
|
||||
5. **Multiple `Authorization` headers.** Use only the first (Go `http.Header.Get` default). Documented.
|
||||
6. **Bearer during OIDC init wait.** Bearer requests also block on init: we need `issuerURL`, `audience`, JWKs ready. If init fails, bearer requests return 503 just like cookie requests.
|
||||
7. **SSE / WebSocket bypass with bearer.** Bypass paths keep cookie-only behaviour. Operators who want bearer on streaming endpoints must remove SSE/WS bypass. Documented.
|
||||
8. **Logout endpoint with bearer.** Logout runs before bearer detection. Treated as cookie-session logout; bearer token revocation requires IdP-side action.
|
||||
9. **Excluded URLs with bearer.** Bypass excluded URLs as today; bearer not validated on excluded paths. ADDITIONALLY: `Authorization: Bearer` is stripped from the request before forwarding so the token can't leak into the excluded endpoint's downstream logs / metrics scrapers / health checks.
|
||||
10. **Concurrent identical bearer requests.** Existing `tokenCache` is concurrency-safe; no new locking.
|
||||
11. **Client rotates token between requests.** Independent verification per token; independent cache entries.
|
||||
12. **Clock skew.** Use existing `jwt.Verify` leeway. (If absent, add ±30s as a separate change; out of scope here.)
|
||||
|
||||
## 11. Testing Strategy
|
||||
|
||||
### 11.1 Integration tests (new `bearer_auth_test.go`)
|
||||
|
||||
Table-driven test against a real `httptest.Server` and the full `ServeHTTP` flow. Coverage matrix:
|
||||
|
||||
- Valid access token + allowed roles → 200, `next` ran, `X-Forwarded-User` set.
|
||||
- Valid token without configured roles → 200.
|
||||
- Wrong audience, expired, tampered signature → 401, `next` did not run.
|
||||
- ID token presented → 401 (`ID tokens not accepted`).
|
||||
- Malformed JWT (2 segments) → 401.
|
||||
- Oversized token (> MaxLength) → 401.
|
||||
- Empty bearer → 401.
|
||||
- Missing identifier claim → 401.
|
||||
- Identifier containing `\r\n` → 401.
|
||||
- `allowedRolesAndGroups` mismatch → 403.
|
||||
- `allowedRolesAndGroups` match → 200.
|
||||
- `EnableBearerAuth=false` + bearer header → cookie path runs (302 to `/authorize`).
|
||||
- Bearer + valid cookie session → bearer wins, 200.
|
||||
- `StripAuthorizationHeader=true` → downstream sees no `Authorization`.
|
||||
- `StripAuthorizationHeader=false` → downstream sees `Authorization`.
|
||||
- Case variants (`bearer`, `BEARER`) → 200.
|
||||
- SSE bypass + bearer → cookie-only check applies (bearer ignored).
|
||||
- **Replay regression**: same token 1000 times in a row → all 200.
|
||||
- **Cache-evict regression**: same token, force-evict `tokenCache` between iterations (call `tokenCache.Delete` directly), replay → still 200 (verifies `skipReplayMarking` doesn't poison the blacklist).
|
||||
- **Revocation-while-bearer regression**: bearer token verified once → admin calls `RevokeToken` adding JTI to blacklist → same token presented → 401 (verifies blacklist Get stays active on bearer path even with `skipReplayMarking` set).
|
||||
- **Alg-pin: token signed with `alg=none`** → 401, no JWKS fetch happens (verify with a counting mock).
|
||||
- **`kid` injection: 50KB random kid** → 401 immediately, no JWKS fetch.
|
||||
- **Per-IP throttle**: 21 bad bearer requests from same IP within 1 minute → 22nd returns 429 + Retry-After.
|
||||
- **`iat` upper-age**: token with `iat = now - 25h` → 401 (older than 24h default).
|
||||
- **Multi-aud without azp**: aud = `["a", "b"]`, no azp → 401.
|
||||
- **Multi-aud with matching azp**: aud = `["api-aud", "other"]`, azp = clientID → 200.
|
||||
- **Identifier with bidi-override**: sub contains U+202E → 401.
|
||||
- **Identifier with comma**: sub = `"alice,bob"` → 401.
|
||||
- **Identifier over 256 bytes** → 401.
|
||||
- **`UserIdentifierClaim=email` at startup with EnableBearerAuth=true** → startup fails.
|
||||
- **Excluded URL + bearer**: bearer header presented on excluded URL → request forwarded, downstream sees no `Authorization` header (stripped).
|
||||
|
||||
### 11.2 Unit tests (in `bearer_auth_test.go`)
|
||||
|
||||
- `classifyToken`: ID-token detection, access-token detection by `scope`/`scp`/`token_use`, ambiguous → reject.
|
||||
- `resolveIdentifier`: precedence (`userIdentifierClaim` → `sub` → `client_id`/`azp`); missing → error; empty string → error.
|
||||
- `sanitizeIdentifier`: rejects all `unicode.IsControl`; accepts email/sub-style values.
|
||||
|
||||
### 11.3 Introspection tests (`bearer_auth_introspection_test.go`)
|
||||
|
||||
- Token valid + introspection `active=true` → 200.
|
||||
- Token valid + introspection `active=false` → 401.
|
||||
- Introspection endpoint 500 → 503.
|
||||
- Second request hits introspection cache (no second HTTP call).
|
||||
|
||||
### 11.4 Startup validation tests (extend `settings_test.go` / `main_test.go`)
|
||||
|
||||
- `EnableBearerAuth=true, Audience=""` → `New()` errors.
|
||||
- `EnableBearerAuth=true, StrictAudienceValidation=false` → succeeds with warning.
|
||||
- `EnableBearerAuth=false` → no validation; existing tests untouched.
|
||||
|
||||
### 11.5 Cookie-path regression suite
|
||||
|
||||
- All existing `TestServeHTTP_*` tests in `main_servehttp_test.go` pass unmodified.
|
||||
- Add: cookie session, `EnableBearerAuth=true`, no bearer header → identical behaviour to baseline.
|
||||
- Add: dirty session still triggers `Save()` after refactor.
|
||||
|
||||
### 11.6 Principal invariants
|
||||
|
||||
- `buildPrincipalFromSession`: `Source == sourceSession`; `IDToken` / `RefreshToken` populated when present in session.
|
||||
- `buildPrincipalFromBearerToken`: `Source == sourceBearer`; `IDToken == ""`, `RefreshToken == ""`.
|
||||
- `forwardAuthorized` produces identical headers for equivalent principals regardless of source.
|
||||
|
||||
### 11.7 Coverage gate
|
||||
|
||||
- New code in `bearer_auth.go` and `principal.go`: ≥ 90% line coverage.
|
||||
- `forwardAuthorized` coverage ≥ existing `processAuthorizedRequest` coverage baseline.
|
||||
|
||||
### 11.8 Out of scope (follow-ups)
|
||||
|
||||
- Load test of bearer vs cookie hot path.
|
||||
- Fuzzing the JWT parser.
|
||||
- Additional auth methods (mTLS, API keys) — design enables them, but they are separate work.
|
||||
|
||||
## 12. Migration / Rollout
|
||||
|
||||
Default-off. Existing deployments observe no behavioural change. Operators opt in by setting:
|
||||
|
||||
```yaml
|
||||
enableBearerAuth: true
|
||||
audience: https://api.example.com # required when bearer enabled
|
||||
# optional:
|
||||
stripAuthorizationHeader: true # default
|
||||
requireTokenIntrospection: false # default; set true for real-time revocation
|
||||
userIdentifierClaim: client_id # optional override; defaults to sub fallback chain
|
||||
```
|
||||
|
||||
Documentation: update `docs/CONFIGURATION.md` with a bearer-auth section, and add a new `docs/BEARER_AUTH.md` covering the security model, threat assumptions (token issuer is trusted; audience must be set; bearer means trust the issuer's revocation policy unless introspection enabled), and recommended configurations for common IdPs.
|
||||
|
||||
## 13. Security Considerations
|
||||
|
||||
| Concern | Mitigation |
|
||||
|---|---|
|
||||
| Token confusion (ID token used as bearer) | Reuse `detectTokenType` (`token_manager.go:187-303`) which checks `nonce`, `typ: at+jwt`, `token_use`, `scope`, aud-vs-clientID. Belt-and-braces: explicit `nonce` + `token_use == "id"` rejection on top. |
|
||||
| Audience confusion (token for service B accepted by A) | `Audience` mandatory at startup; verified via existing `VerifyJWTSignatureAndClaims`; multi-aud tokens require matching `azp == clientID`. |
|
||||
| Replay-via-blacklist false positive | `verifyOpts{skipReplayMarking: true}` on bearer path. Gates ONLY the Set; the Get stays so revoked tokens still fail. |
|
||||
| Revocation lag | Optional RFC 7662 introspection. Bearer-path introspection cache TTL capped at 60s. Set `RequireTokenIntrospection=true` for real-time revocation. |
|
||||
| `alg`-confusion / `alg=none` attacks | Hard-pin asymmetric allowlist at bearer entry, **before** JWKS fetch. Prevents wasted upstream calls and locks out HS/none probes. |
|
||||
| `kid` injection / JWKS amplification | `kid` length cap (256 bytes) + charset allowlist enforced at bearer entry. |
|
||||
| Bearer 401 brute-force / oracle | Per-IP `failedBearerAttempts` cache; configurable threshold + penalty box returning 429 + `Retry-After`. |
|
||||
| `iat` clock-manipulation / forever-tokens | `MaxTokenAgeSeconds` upper bound (default 24h); cookie path unchanged. |
|
||||
| Identifier-driven header injection | `sanitizeIdentifier`: length cap, control-char + bidi-override + `,;=` rejection. `net/http` rejects CRLF on the wire too (defence in depth). |
|
||||
| Token leakage downstream | `StripAuthorizationHeader=true` by default. Also: `Authorization` stripped on excluded-URL requests so bearer can't leak into health/metrics downstream logs. |
|
||||
| Token-in-logs | All log paths log reason categories, not raw tokens. Identifier hashed via SHA-256 truncated to 8 hex chars before any info/warn-level emission (full identifier only at debug). New `safeLogAuthEvent(category, hashedIdentifier, reasonCode)` helper makes this hard to misuse. |
|
||||
| `email` claim spoofing | Startup fails if `EnableBearerAuth && UserIdentifierClaim == "email"`. Future human-user bearer iteration must add `email_verified` enforcement. |
|
||||
| Bypass on SSE / WS endpoints | SSE/WS bypass keeps cookie-only behaviour; bearer ignored. Operators choose to widen if needed. |
|
||||
| Mixed bearer + cookie precedence | Cookie wins by default (safer for browser scenarios); `BearerOverridesCookie=true` flips. WARN log on both-present requests. |
|
||||
| Configuration drift (operator forgets audience) | Startup fails when `EnableBearerAuth=true && Audience==""`. |
|
||||
| Downstream blast radius when `StripAuthorizationHeader=false` | Documented: forwarded bearer extends token's blast radius to all downstream services. Logs at those services become token stores. Operators must treat downstream log policy accordingly. |
|
||||
| Introspection auth method (pre-existing gap, called out) | `token_introspection.go:80` uses `client_secret_basic` only; does not honour `private_key_jwt`. Out of scope for this PR but documented as a follow-up; operators using `ClientAuthMethod=private_key_jwt` + `RequireTokenIntrospection=true` should be aware introspection will use basic auth. |
|
||||
|
||||
## 14. Open Questions
|
||||
|
||||
None — all design decisions resolved during brainstorming + security review. Implementation may surface incidental questions (e.g. exact clock-skew leeway in `jwt.Verify`); those are out of scope for this spec and handled in the implementation plan.
|
||||
|
||||
## 14a. Security Review Reference
|
||||
|
||||
This design was reviewed by the `security-reviewer` subagent on 2026-05-18. Findings incorporated:
|
||||
|
||||
- **Critical**: C1 (classifier reuses `detectTokenType`), C2 (sub fallback dropped — unreachable due to `jwt.go:416`), C3 (replay-marking gates only Set, not Get; revocation regression test added).
|
||||
- **High**: H1 (alg pinned at bearer entry), H2 (kid length + charset), H3 (cookie wins by default, configurable), H4 (per-IP 401 throttle), H5 (multi-aud requires azp).
|
||||
- **Medium**: M1 (identifier max-length + bidi reject + delimiter chars), M2 (introspection cache TTL capped at 60s on bearer path), M4 (log-hashing via SHA-256[:8]), M5 (StripAuth blast-radius documented), M6 (iat upper-age bound), M7 (Authorization stripped on excluded URLs).
|
||||
- **Low/Nit**: L2 (renamed to `BearerEmitWWWAuthenticate`), N3 (startup rejects `UserIdentifierClaim=email`).
|
||||
- **Documented as pre-existing gaps (follow-up PRs)**: M3 (introspection auth method doesn't honour `private_key_jwt`).
|
||||
|
||||
## 15. Implementation Plan Reference
|
||||
|
||||
To be produced by the `writing-plans` skill in a follow-up document at `docs/superpowers/plans/2026-05-18-bearer-token-auth-plan.md`. The plan decomposes this design into ordered, independently-testable PRs.
|
||||
Reference in New Issue
Block a user