feat(auth): generic delegated access-token verifier (at+jwt) with external principal mapping - #946
Merged
Conversation
A new internal/delegated package verifies externally issued RFC 9068 at+jwt access tokens from one configured OIDC issuer: parse-only header classification under exact size limits, go-oidc discovery on a background retry loop (issuer network unavailability is never startup-fatal), a strict JWKS cache (10s fetch deadline, 64KiB body cap, 32-key cap, 600s fresh + 300s stale-known-kid grace, singleflight unknown-kid refresh with a burst-1/6-per-minute bucket and 10s negative cooldown, atomic replacement), and post-signature claim pins: exact typ/alg/iss/single-string-aud/azp/singleton-scope, bounded sub/jti/context claims with dual code-point+byte limits, forbidden claims, and the exp-iat lifetime rule with bounded skew. Errors split into ErrInvalidToken (401 class) and ErrUnavailable (503 class) so callers can keep the wire distinction without leaking which check failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM
New delegated: config block (enabled, issuer_url, audience, authorized_party, required_scope, allowed_algorithms, lifetime, skew, required/forbidden claim lists) with startup-fatal validation when enabled: missing/malformed core fields, insecure issuer transport in production, unsupported/duplicate algorithms, and empty/duplicate/overlapping/reserved claim lists all refuse to boot. A disabled block is inert and never validated. Issuer network unavailability is deliberately not checked at startup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM
Migration 108 adds external_principal_mappings(issuer, subject,
user_id, created_at, PK (issuer, subject)): the canonical delegated
identity map, additive beside users.google_subject (which keeps its
exact legacy login semantics — never overwritten).
Population is explicit, behind the existing provisioning HMAC:
- POST /api/internal/users/provision gains optional external_issuer;
when present the provision transactionally inserts/replays the
(issuer, external_ref) mapping; omitted preserves the pre-delegated
contract byte-for-byte.
- New POST /api/internal/users/external-principals/attach accepts
{issuer, external_ref, user_id}: 201 create / 200 same-triple replay
/ 409 external_principal_conflict / 404 user_not_found; issuer must
byte-equal the configured delegated issuer, 503
delegated_verifier_not_configured until one is set. It never touches
email/name/google_subject.
Authentication-side lookup (GetUserByExternalPrincipal) is one exact
read-only (issuer, subject) query returning the complete user;
unmapped pairs are (nil, nil) so the caller can keep 401 distinct from
store failure (503, identity.ErrAuthUnavailable).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM
Token-kind dispatch in authenticatePrincipal: before any other
credential path, the protected JOSE header is parsed (under the exact
delegated size limits) solely to classify typ. A positively classified
at+jwt is delegated-owned unconditionally — disabled, unavailable,
malformed, or invalid delegated tokens never fall through to the
agent-JWT, OAuth, or API-key paths (proven by a test that enrolls the
compact string itself as a valid API key). Everything else keeps
today's precedence byte-for-byte; the WS handshake is untouched.
Delegated authentication maps the verified (issuer, subject) through
external_principal_mappings to an account-scoped principal with no
bound agent. The wire split is typed end to end: invalid anything is
the existing 401 unauthorized with the bare Bearer challenge and no
check-specific detail; verifier-not-ready / JWKS-outage / identity-
store failures surface identity.ErrAuthUnavailable and become 503 with
a generic envelope and no WWW-Authenticate on both the legacy mux and
the /v1 surface (the challenge wrapper keys on 401 alone).
Category-only counters follow the bounded-label contract:
e2a_delegated_auth_failures_total{category} and
e2a_delegated_jwks_refresh_total{outcome}. main.go wires the verifier
from config; construction is static-only and discovery retries in the
background.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM
…(M1) parseProtectedHeader applied the verification-time byte caps (typ/alg/ kid) and strict struct typing, and it was shared with Classify. An at+jwt whose OTHER header members were oversized or oddly typed (a 129-char kid, a non-string alg, a numeric kid) was therefore classified as NOT delegated and fell through to the agent-JWT resolver and on to the API-key probe — the exact §10.4/§16 no-fallthrough invariant. Split a lenient peekProtectedTyp (three non-empty segments + decoded-header size cap + read typ only, tolerant of every other member) for classification from the strict parseProtectedHeader kept for verification. The verification pins still reject such tokens, now as delegated 401s. Proven at the classify seam and at the agent dispatch layer (odd-header at+jwt with a valid API-key trap present stays delegated-owned). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM
M3: keyForKid served any cached kid for the whole 600s+300s window with no refresh, so nothing distinguished fresh from stale and a removed or compromised key kept verifying to 900s — breaking the §10.7 725s retention math, which depends on the cache turning over at 600s. Now a known kid is served directly only while fresh (age <= KeysFreshFor); past that a rate-limited singleflight refresh is attempted and the cached kid is served only if that refresh fails, out to the stale grace; a successful refresh with the kid gone is an immediate 401. Unknown kids are still never served from stale state. Tests assert a refresh is attempted at 601s and that a rotated-out key stops verifying once a refresh succeeds. N6: the JWKS refresh-outcome Prometheus Inc now runs after cache.mu is released (decide-under-lock, emit-after-unlock), in both refresh() and the discovery prime. N7: the OIDC discovery response body is capped (maxDiscoveryBytes) via a body-limiting transport on the discovery client, matching the existing explicit cap on the JWKS fetch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM
The rate-limit middleware resolved the principal and fell through on error without caching, and requirePrincipal only reused a SUCCESSFUL resolution — so a failing delegated request ran the verifier twice, and the 401 WWW-Authenticate challenge re-ran it a third time. Because the delegated JWKS refresh bucket is stateful (burst 1), the second Verify of an unknown-kid token flips 401 (key absent after refresh) into 503 (rate-limited) — violating the §10.4/§15 'successful refresh with no such key is 401' rule — and double-counts the failure metric. Introduce a one-shot per-request auth memo at the single authenticatePrincipal chokepoint (both AuthenticatePrincipal and WWWAuthenticateChallenge funnel through it). withRawRequest installs it and authChallenge now runs inside withRawRequest so the challenge builder's request shares it. All three resolutions collapse to one Verify. Proven at the agent layer (stateful stub, memo vs no-memo) and end-to-end through the real v1 middleware chain (poll-limited op → 401, not 503, verifier run once). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM
Replace the reused internal_error on the auth-availability 503 with a dedicated availability-family code auth_unavailable (503, retryable), so a code-first consumer can tell an auth-backend outage (delegated verifier not ready, identity store down) from a genuine e2a bug, and a 5xx-burn SLO can except it. Regenerates cleanly through the catalog into the OpenAPI extension, the ErrorBody.Code doc vocabulary, and docs/api.md (all machine-checked). The legacy-mux text/plain 503 shape is unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM
…d (N1/N2/N3)
N1: the provision path validated external_issuer only for bounded
length, so a trailing-slash (or otherwise-off) issuer got 201 plus a
permanently-unauthenticatable mapping — a verified delegated token's
issuer is byte-compared and would never match. Provision now applies
the same != configured-issuer -> 400 invalid_issuer gate attach uses;
an unset configured issuer makes every external_issuer a mismatch
(fail-closed).
N2: provision's external_ref becomes the mapping subject, so it now
carries the §10.3 bound (1..128 code points / <=512 UTF-8 bytes / no
control chars) via validExternalRef instead of the old 128-byte cap —
identical for ASCII refs, correct for the mapping subject otherwise.
N3: validExternalRef now rejects C1 controls (0x80-0x9f) alongside C0
and DEL, covering the full Unicode Cc category to match Hub's \p{Cc}
check and §10.3.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM
Adds the missing §16 edge coverage: a parametric dual code-point/byte + multibyte + supplementary-plane matrix over the shared string-claim validator (iss/aud/azp/scope/sub/jti/context), decodeSegment boundaries for the header/payload/signature caps (incl. the previously-untested 1024/1025 signature edge), per-claim limit wiring for jti/membership_id/ workspace_id via verify, raw Authorization accepted at exactly 16384, and JWKS acceptance AT the limits (32 keys, a 65536-byte keyset body, a 128-byte kid). Covering scope/kid surfaced that boundedASCII admitted DEL despite its printable-ASCII contract; tightened to 0x20–0x7e. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM
…N10) N10: the required/forbidden claim lists were defaulted to TokenCanopy's console claim shape (workspace_id/membership_id/workspace_role + owner|admin|member + client_id/credential_id/runtime_id/sponsor_id), which §10.3 calls deployment data and the public-boundary rule keeps out of OSS defaults. Removed those defaults (keeping only protocol-level algorithm/lifetime/skew), so enabling the verifier now requires an explicit claim policy — the enabled-config validation already rejects empty lists. config.example.yaml now shows generic placeholder claim names, clearly labelled deployment-specific. A load-level test pins that enabling with no claim lists is a startup error. N9: documents the two delegated metrics in observability.md, and the delegated verifier + external_issuer + the attach endpoint in deployment.md (env table row + two paragraphs). N4: a one-line note at the WS handshake that at+jwt is intentionally not classified there (API-key only; the native console polls over REST). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM
make spec after the auth_unavailable catalog addition — the only diff is the new code in the error.code vocabulary and its machine-readable extension entry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM
make generate-sdk after the auth_unavailable catalog addition. The generated /v1 model description strings copy api/openapi.yaml's error.code vocabulary verbatim; only the ErrorBody model (TS + Python) changes, and only by the new code in the two vocabulary spans (the description text hashes identically to the spec). No Docker generator was available locally, so the two spans were inserted by hand to match the generator output byte-for-byte. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements the generic delegated access-token verifier from the native-console surface design (§10.3–10.5): a disabled-by-default
delegated:config block, token-kind dispatch that makes protected-headertyp="at+jwt"delegated-owned before any existing credential path, an OIDC-discovery/JWKS verifier with the exact cache/refresh policy, theexternal_principal_mappingsidentity table with signed provisioning/attach population, a typed 401/503 wire split, and category-only metrics. A control plane (any OIDC issuer — configured entirely by deployment values, nothing operator-specific in source) can mint short-lived audience-bound tokens for its signed-in humans and call/v1with account-scope authority mapped through the verified(iss, sub)pair.Design conformance
authenticatePrincipalclassifies by parse-only protected-headertypfirst, reading onlytypand tolerant of every other header member (so anat+jwtwith an oversized/oddly-typedkid/algis still delegated-owned; the verification-time pins reject it later as a 401). A positively classifiedat+jwtnever falls through — disabled, malformed, unavailable, or invalid — proven by tests that enroll the exact compact string as a valid API key and show it still rejects, including the odd-header variants. All other bearers keep today's precedence byte-for-byte (agent JWT →ate2a_OAuth → API key → session cookie); the WS handshake is untouched (API-key only, by design).coreos/go-oidcdiscovery on a background retry loop (issuer network unavailability is never startup-fatal); one long-lived verifier per process. Post-signature pins: exacttyp/alg-allowlist/single-stringaud(arrays rejected even when they contain the right value)/iss/azp/singleton scope,exp − iat ≤ max_token_lifetimewith skew only on iat-future/exp-past, bounded nonemptysub/jti, required context claims with allowed-value sets, forbidden claims rejected even as null, ≤32 top-level claims, bounded ASCII claim names, duplicate claims rejected. The required/forbidden claim policy is not defaulted in OSS (only protocol-level algorithm/lifetime/skew are) — enabling the verifier requires an explicit claim policy, so no operator-specific claim names or role values ship in OSS source.external_principal_mappings(issuer, subject, user_id, created_at, PK (issuer, subject)). Auth performs one exact read-only(iss, sub)lookup → complete user (incl.account_class) →ScopeAccountwith emptyAgentID. Never subject-alone/email lookup, never trusts profile claims, never writes.users.google_subjectkeeps its exact legacy behavior.external_issuer— when present it must byte-equal the configured delegated issuer (else400 invalid_issuer, so a trailing-slash issuer can't produce an unauthenticatable mapping), and the provision transactionally inserts/replays the mapping; omitted preserves the pre-delegated code path (no mapping). Provision'sexternal_refnow carries the §10.3 bound (1–128 code points / ≤512 bytes / no control chars, C0+DEL+C1) since it becomes the mapping subject — identical to the old 128-byte cap for ASCII refs. New signedPOST /api/internal/users/external-principals/attachbehind the same provisioning HMAC — 201 create / 200 same-triple replay / 409external_principal_conflict/ 404user_not_found/ 503provisioning_not_configured|delegated_verifier_not_configured; body and issuer byte-compared against the one configured delegated issuer; never touches email/name/google_subject. Statuses/codes/success shape mirror the control plane's attach client exactly.unauthorized+ bareBearer realm="e2a"challenge, no check-specific detail. Verifier-not-ready / JWKS transport with no usable cached key / cooldown / identity-store failure → 503 with the dedicatedauth_unavailableavailability code (Retryable) and noWWW-Authenticate, on both the legacy mux (text/plain) and the/v1envelope. The credential is resolved exactly once per request (a per-request auth memo across the rate-limit middleware, the handler, and the challenge re-run) so the stateful JWKS bucket can't flip a 401 into a 503. Every existing credential keeps working through a total delegated outage.e2a_delegated_auth_failures_total{category}(invalid_token, unknown_subject, verifier_unavailable, identity_store_failure) ande2a_delegated_jwks_refresh_total{outcome}(success, key_absent, transport_error, parse_error, rate_limited); bounded-enum labels, never subjects/issuer text/token data. Ops note: e2a-ops'metric-alert-coveragegate needs its allowlist entries landed before/with this merge.Client surface checklist
Operational risk
delegated:block is byte-for-byte unchanged except the dispatch classifier, which only diverts protected-headertyp="at+jwt"bearers — a shape no existing e2a credential has (e2a agent JWTs carrytypas a payload claim).Validateerrors; https issuer required in production). Issuer network outage degrades only delegated auth to 503; rollback = disable in place (enabled: false/E2A_DELEGATED_ENABLED=false) and redeploy.auth_unavailableavailability code (Retryable), regenerated cleanly through the catalog into the OpenAPI extension, theErrorBody.Codedoc vocabulary,docs/api.md, and theapi/openapi.yamlgolden — the only spec diff. This lets a code-first consumer tell an auth-backend outage from a genuine e2a bug and lets a 5xx-burn SLO except it.Review response (house review round)
Fixed all three confirmed correctness bugs (each TDD'd — failing test first) plus the cleanups:
parseProtectedHeader(shared withVerify) enforced thetyp/alg/kidbyte caps + strict typing, so anat+jwtwith an oversized/oddly-typed other header member was classified NOT-delegated and fell through to the API-key probe. Split a lenientpeekProtectedTyp(readstyponly) for classification from the strict parse kept for verification. Proven at the classify seam and the agent dispatch layer.Verify; the stateful JWKS burst bucket then flipped an unknown-kid 401 into a 503 and double-counted the metric. Added a one-shot per-request auth memo at the singleauthenticatePrincipalchokepoint (withRawRequestinstalls it;authChallengereordered to run inside it). Proven at the agent layer (memo vs no-memo, stateful stub) and end-to-end through the real v1 chain (poll-limited op → 401, verifier run once).keyForKidserved any cached kid for the whole 900 s, so a removed/compromised key kept verifying and §10.7's 725 s retention math was broken. Now fresh (≤600 s) serves directly; past 600 s a refresh is attempted and a known kid is served only on refresh failure, out to the stale grace. Tests assert a fetch at 601 s and that a rotated-out key stops verifying once a refresh succeeds.external_issuerby byte-equality (no unauthenticatable mappings). N2 provision'sexternal_refuses the §10.3 bound (noted above). N3 control-char rejection now spans C1 (0x80–0x9f). N6 JWKS metrics emitted outside the cache lock. N7 OIDC discovery body capped. N8 filled the §16 boundary gaps (dual code-point/byte + multibyte + supplementary-plane matrix, decodeSegment 1024/1025 signature edge, JWKS caps at limit, raw-auth at 16384); this surfaced and fixedboundedASCIIadmitting DEL. N9 documented the metrics + verifier/attach inobservability.md/deployment.md. N4 WS-handshake comment added.config.example.yamlshows generic placeholders. The e2a-ops config supplies the real values. Ops note: the e2a-opsdelegated:YAML MUST therefore includerequired_claims+forbidden_claims(no OSS fallback).Test plan
go build ./...,gofmtcleaninternal/delegated: full claim-rejection matrix, classification-tolerates-odd-header cases, boundary (limit/limit+1, multibyte, supplementary-plane) matrix + JWKS caps at limit + decodeSegment edges, JWKS fresh(600 s)/stale(900 s)/rotated-out/rate/cooldown/singleflight/malformed-response behavior and the discovery body cap, all with a fake clock + httptest issuer (RSA and EC),-racecleaninternal/agent: dispatch no-fallthrough (API-key-trap + odd-header proofs), resolve-once-per-request (memo vs no-memo, stateful stub) + end-to-end through the real v1 chain (401 not 503), mapped/unmapped/unavailable/store-failure outcomes with metric categories, wire-level 401-challenge/503-no-challenge, existing credential-precedence regressions (API key, agent JWT via the real bootstrap flow, tampered JWT,ate2a_, cookie fallback), attach lifecycle/HMAC/bounds/C1-control, provision with/withoutexternal_issuerand issuer-mismatch rejection,-racecleaninternal/httpapi: envelope-level 401/503 wire split (auth_unavailable); full package green including the error-vocabulary/literal-status/docs/api.md/OpenAPI-golden contract scans (golden regenerated — only diff is the new code)internal/identity+internal/config+internal/telemetrygreengo test ./...: green except 4 pre-existing failures (outreach/engagement counters:TestMarkSent*,TestInboundReviewApprovalAtomicallyAdvancesAuthenticatedEngagement,TestInboundActivityRecording_AuthenticatedMailUpdatesCounters) — reproduced identically on cleanorigin/mainin a separate worktree, unrelated to this changedelegated.enabled: true: its local e2e lane green with the dev auth shim deleted🤖 Generated with Claude Code
https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM