Skip to content

feat(auth): generic delegated access-token verifier (at+jwt) with external principal mapping - #946

Merged
jiashuoz merged 13 commits into
mainfrom
feat/delegated-verifier
Aug 28, 2026
Merged

feat(auth): generic delegated access-token verifier (at+jwt) with external principal mapping#946
jiashuoz merged 13 commits into
mainfrom
feat/delegated-verifier

Conversation

@jiashuoz

@jiashuoz jiashuoz commented Aug 28, 2026

Copy link
Copy Markdown
Member

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-header typ="at+jwt" delegated-owned before any existing credential path, an OIDC-discovery/JWKS verifier with the exact cache/refresh policy, the external_principal_mappings identity 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 /v1 with account-scope authority mapped through the verified (iss, sub) pair.

Design conformance

  • Dispatch (§10.4): authenticatePrincipal classifies by parse-only protected-header typ first, reading only typ and tolerant of every other header member (so an at+jwt with an oversized/oddly-typed kid/alg is still delegated-owned; the verification-time pins reject it later as a 401). A positively classified at+jwt never 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).
  • Verifier (§10.3): coreos/go-oidc discovery on a background retry loop (issuer network unavailability is never startup-fatal); one long-lived verifier per process. Post-signature pins: exact typ/alg-allowlist/single-string aud (arrays rejected even when they contain the right value)/iss/azp/singleton scope, exp − iat ≤ max_token_lifetime with skew only on iat-future/exp-past, bounded nonempty sub/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.
  • Exact limits: 16384-byte raw Authorization and compact token, 1024/8192/1024-byte decoded header/payload/signature, 32-byte typ, 16-byte alg, 128-byte kid, and every dual code-point/byte claim limit from the design table — each with limit / limit+1 boundary tests including multibyte and supplementary-plane cases.
  • JWKS policy: 10 s fetch deadline, 65536-byte decoded response cap (JWKS and the OIDC discovery document), ≤32 keys, kid ≤128 ASCII bytes. The 600 s fresh window turns the cache over: a known kid is served directly only while fresh; past 600 s a rate-limited singleflight refresh is attempted and the cached kid is served only if that refresh fails, out to the +300 s stale grace (past 900 s even a known key is 503 until a refresh succeeds); a successful refresh with the kid gone is an immediate 401. Unknown kids are never served from stale state. Burst-1 token bucket refilling 6/60 s, 10 s negative-result cooldown, atomic replacement, failed refresh retains the last good set. No remote response bodies are logged, and refresh-outcome metrics are emitted outside the cache lock.
  • Identity (§10.5): migration 108 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) → ScopeAccount with empty AgentID. Never subject-alone/email lookup, never trusts profile claims, never writes. users.google_subject keeps its exact legacy behavior.
  • Population: provision gains optional external_issuer — when present it must byte-equal the configured delegated issuer (else 400 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's external_ref now 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 signed POST /api/internal/users/external-principals/attach behind the same provisioning HMAC — 201 create / 200 same-triple replay / 409 external_principal_conflict / 404 user_not_found / 503 provisioning_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.
  • 401/503 split: invalid anything (incl. unknown subject and verifier disabled) → existing 401 unauthorized + bare Bearer realm="e2a" challenge, no check-specific detail. Verifier-not-ready / JWKS transport with no usable cached key / cooldown / identity-store failure → 503 with the dedicated auth_unavailable availability code (Retryable) and no WWW-Authenticate, on both the legacy mux (text/plain) and the /v1 envelope. 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.
  • Metrics: e2a_delegated_auth_failures_total{category} (invalid_token, unknown_subject, verifier_unavailable, identity_store_failure) and e2a_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-coverage gate needs its allowlist entries landed before/with this merge.

Client surface checklist

  • Go handler + integration tests
  • Migration written + idempotent + safe on prod-sized tables (new empty table + index; no rewrites)

All other rows intentionally skipped: this adds no public /v1 surface and no client-visible API change. The attach endpoint is internal (like provision — deliberately absent from OpenAPI), delegated tokens are minted server-side by a control plane (never by SDK/CLI/MCP users), and openapi.yaml is untouched.

Operational risk

  • Disabled by default; a deployment without a delegated: block is byte-for-byte unchanged except the dispatch classifier, which only diverts protected-header typ="at+jwt" bearers — a shape no existing e2a credential has (e2a agent JWTs carry typ as a payload claim).
  • Enabled-but-misconfigured refuses to start (aggregated Validate errors; 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.
  • 503 availability responses use the dedicated auth_unavailable availability code (Retryable), regenerated cleanly through the catalog into the OpenAPI extension, the ErrorBody.Code doc vocabulary, docs/api.md, and the api/openapi.yaml golden — 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:

  • M1 — classification applied verification pins. parseProtectedHeader (shared with Verify) enforced the typ/alg/kid byte caps + strict typing, so an at+jwt with an oversized/oddly-typed other header member was classified NOT-delegated and fell through to the API-key probe. Split a lenient peekProtectedTyp (reads typ only) for classification from the strict parse kept for verification. Proven at the classify seam and the agent dispatch layer.
  • M2 — credential resolved 2–3× per request. The rate-limit middleware + handler + 401 challenge each re-ran 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 single authenticatePrincipal chokepoint (withRawRequest installs it; authChallenge reordered 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).
  • M3 — 600 s fresh window unimplemented. keyForKid served 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.
  • N1 provision now gates external_issuer by byte-equality (no unauthenticatable mappings). N2 provision's external_ref uses 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 fixed boundedASCII admitting DEL. N9 documented the metrics + verifier/attach in observability.md/deployment.md. N4 WS-handshake comment added.
  • N10 (my call): emptied the operator-specific claim defaults. The required/forbidden claim lists defaulted to the console's exact claim shape, which §10.3 calls deployment data and the public-boundary rule keeps out of OSS. Removed them (kept protocol-level algorithm/lifetime/skew); enabling now requires an explicit claim policy (validated), and config.example.yaml shows generic placeholders. The e2a-ops config supplies the real values. Ops note: the e2a-ops delegated: YAML MUST therefore include required_claims + forbidden_claims (no OSS fallback).

Test plan

  • go build ./..., gofmt clean
  • internal/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), -race clean
  • internal/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/without external_issuer and issuer-mismatch rejection, -race clean
  • internal/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/telemetry green
  • Full go test ./...: green except 4 pre-existing failures (outreach/engagement counters: TestMarkSent*, TestInboundReviewApprovalAtomicallyAdvancesAuthenticatedEngagement, TestInboundActivityRecording_AuthenticatedMailUpdatesCounters) — reproduced identically on clean origin/main in a separate worktree, unrelated to this change
  • After the control-plane side points at a local e2a with delegated.enabled: true: its local e2e lane green with the dev auth shim deleted

🤖 Generated with Claude Code

https://claude.ai/code/session_01NbqHH6fEZHS3JSGJpE5bqM

jiashuoz and others added 13 commits August 27, 2026 16:35
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
@jiashuoz
jiashuoz merged commit 18c6c0e into main Aug 28, 2026
29 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant