feat(auth): RFC 8725 token typ discipline — reject cross-use tokens (#444 item 5) - #447
Conversation
… item 5) RFC 8725 explicit token typing: a JWT minted for a non-access purpose must never be usable as a caller's access token. The auth middleware now rejects an inbound bearer whose JWT `typ` header declares one of the non-access initializ media types — chain-token, workload-credential, or mandate — with 401 BEFORE the provider chain runs, closing the token-confusion / cross-use gap. - New forge-core/auth/token_type.go: the four media-type constants (platform-bearer accepted; chain-token / workload-credential / mandate rejected) + an unverified JWT-header `typ` decode. Denylist by design: platform-bearer, absent/unknown typ, and non-JWT bearers (opaque static/loopback, sigv4) pass through untouched — never breaks existing tokens. Rejection needs no signature check. - Middleware gate sits at the single inbound access-token choke point (covers oidc/azure_ad/gcp_iap/aws_sigv4/http_verifier + the loopback static_token, incl. the delegating http_verifier that never sees the token bytes), gated on token_kind == jwt. - New ErrWrongTokenType sentinel + `wrong_token_type` fail reason (FailReason + classifyAuthFailure) so the auth_fail audit event and the auth.verify span carry a distinct, alertable reason. Tests: typ parser (all four types + no-typ + unknown + opaque + malformed header), middleware rejects chain-token before the chain is consulted, platform-bearer + opaque pass through, audit reason = wrong_token_type. golangci-lint clean; full auth suite passes. Docs: authentication.md (typ discipline section) + audit-logging.md (reason row) + the two fail_reason span-attribute enumerations.
initializ-mk
left a comment
There was a problem hiding this comment.
Verdict: approve-grade, with one drift-sync change request on the denylist. A clean, security-sound PR — I verified the auth-critical parts against forge source AND the companion contract in api-next develop.
Verified correct
- Parser is safe.
jwtHeaderTypis an unverified, header-only decode that fails open to""on any malformed input (not 3 parts / bad base64url / bad JSON). Because the typ check only ADDS a reject and never bypasses normal verification, fail-open here is fail-safe — a weird token still faces the full verifier. The exact-3-parts requirement is fine: a real platform-minted chain token is a signed 3-part JWS. - Single choke point.
token/kindare extracted once at the top ofMiddleware; the gate precedes the soleChain.Verifyatmiddleware.go:211, so no provider ever sees a cross-use token. gcp_iap's header-JWT (kind=="iap_jwt") is correctly out of scope — Google-issued, never an initializ non-access token. sigv4/opaque skip via thekind == "jwt"gate. - Cross-repo contract matches (verified against api-next
develop). forge's four media-type constants are byte-for-byte identical tohelper/tokentype.go, and forge's denylist{chain-token, workload-credential, mandate}exactly mirrors the platform'sourNonPlatformTypes/RejectForeignTokenClass— same three types, same exact-match semantics, same denylist rationale (keep accepting external OIDCat+jwt). The platform mints the full long-form media type (newPlatformToken), so no short-form/case variation is reachable on a signed token. This is the crux, and it holds. - Denylist correctness. platform-bearer / absent / unknown / opaque / sigv4 all pass through; distinct
wrong_token_typeaudit reason +auth.verifyspan attribute; docs (authentication.md, audit-logging.md, bothfail_reasonspan enums) updated.
Change requested
- Cross-repo drift risk on the denylist (see inline). forge's
rejectedInboundTokenTypesduplicates api-next'sourNonPlatformTypes; they match today, but live in separate repos with no shared source. If api-next adds a fourth non-access class, forge won't reject it until manually synced — silently reopening a cross-use gap for that class. Pin the correspondence with a comment referencingapi-next helper/tokentype.go+ a tracking note on the enforcement issue.
Note (no action)
- forge's
jwtHeaderTyprequires exactly 3 parts + unpadded base64url, while the platform'sJOSEHeadertolerates >=2 parts + padded. Not a gap (a 2-part/alg:none/padded token falls through to normal verification, which rejects it) — just an asymmetry worth being aware of.
The design is right: unverified reject-by-typ at the one inbound choke point, denylist that provably mirrors the platform's own ingress guard, fail-safe throughout. All 10 CI checks green.
| // existing tokens (and any third-party OIDC token, which carries no initializ | ||
| // typ) are unaffected — only a token that explicitly declares one of these | ||
| // non-access purposes is refused. | ||
| var rejectedInboundTokenTypes = map[string]bool{ |
There was a problem hiding this comment.
Change requested (drift-sync, low severity but it is a security denylist): this set duplicates api-next develop helper/tokentype.go ourNonPlatformTypes = {chain-token, workload-credential, mandate}. I verified they match byte-for-byte today, and this mirrors the platform-ingress guard RejectForeignTokenClass exactly. The risk is silent drift: the two denylists have no shared source, so if api-next adds a fourth non-access media type, forge keeps accepting it as an inbound access token until someone manually adds it here — a cross-use gap that opens quietly. Please pin the correspondence: a comment here pointing at api-next helper/tokentype.go ourNonPlatformTypes, and a note on the enforcement tracking issue (api-next #35) to update both in lockstep. Bonus: a small test listing the expected media-type strings would fail loudly if the constants ever get edited out of sync with the platform.
Addresses the #447 review: forge's rejectedInboundTokenTypes duplicates api-next's ourNonPlatformTypes (helper/tokentype.go) with no shared source of truth, so a fourth non-access class added there would silently go unrejected here. Documented the correspondence + exact-match semantics on the denylist and the lockstep-update obligation; drift tracked on #444.
|
Addressed in 84537d5. Pinned the cross-repo correspondence on |
initializ-mk
left a comment
There was a problem hiding this comment.
Change request resolved. ✅ All 10 CI checks green.
Commit 84537d5 pins the cross-repo contract exactly as asked: the comment on rejectedInboundTokenTypes now names the platform source (api-next helper/tokentype.go — ourNonPlatformTypes / RejectForeignTokenClass), states the invariant (same non-access classes, exact-match semantics, matching as of api-next develop), warns that there is no shared source of truth so a fourth api-next class MUST be added here in lockstep or the cross-use gap silently reopens, and points at the #444 enforcement epic for tracking. That captures both the why and the how — the drift risk is now visible to anyone editing this set.
(The suggested string-vector test was explicitly a bonus, and I would not hold on it: the real drift risk is an external addition in api-next that no forge-side test can observe, so the comment + tracking note is the right mechanism. The existing typ-parser tests already pin the four constant values against accidental in-repo edits.)
Nothing else changed; the verified-correct core from the prior review (safe fail-open parser, single Chain.Verify choke point, denylist mirroring the platform ingress guard, distinct wrong_token_type reason) stands. LGTM for #444 item 5 — clean, responsive iteration.
Part of #444 (agent identity L1–L4). Item 5 — RFC 8725 token
typdiscipline. Independent of the other items; branches off main.What
A JWT minted for a non-access purpose must never be usable as a caller's access token. The auth middleware now rejects an inbound bearer whose JWT
typheader declares a non-access initializ media type —chain-token,workload-credential, ormandate— with 401 before the provider chain runs, closing the token-confusion / cross-use gap (RFC 8725 §2.8/§3.11).Media types (api-next#36):
application/vnd.initializ.platform-bearer+jwt— the valid access token → acceptedapplication/vnd.initializ.chain-token+jwt/workload-credential+jwt/mandate+jwt→ rejected where an access token is expectedWhy this design
http_verifierdelegates (never sees the token bytes) andstatic_tokenis opaque — so a per-provider check couldn't cover them. The middleware, holding the raw bearer, is the only spot that covers 100%.platform-bearer, absent/unknowntyp, and non-JWT bearers (opaque static/loopback, sigv4) pass straight through to normal verification — so nothing existing breaks, including third-party OIDC tokens (which carry no initializtyp).token_kind == "jwt"so opaque/sigv4 skip it for free.ErrWrongTokenType→ 401 with a distinctwrong_token_typeaudit reason +auth.verifyspan attribute, so operators can alert on cross-use attempts specifically.Tests
typparser across all four media types + no-typ + unknown + opaque + malformed-header; middleware rejects chain-token before the chain is consulted (asserts the provider'sVerifynever ran); platform-bearer and opaque both pass through; audit reason =wrong_token_type,token_kind = jwt.golangci-lintclean; fullforge-core/authsuite (+ all provider subpackages) passes.Docs
authentication.md(new "Tokentypdiscipline" section under Chain semantics),audit-logging.md(thewrong_token_typefail-reason row), and bothfail_reasonspan-attribute enumerations (authentication.md+observability-tracing.md).Scope
Item 5 only. This covers the inbound access-token verification points that exist today. When item 3 lands the inbound chain-token verification, its own verifier will assert
typ == chain-token+jwt(the positive direction) — this PR is the negative direction (reject chain-token where an access token is expected).