Skip to content

feat(auth): RFC 8725 token typ discipline — reject cross-use tokens (#444 item 5) - #447

Merged
initializ-mk merged 2 commits into
mainfrom
feat/token-typ-discipline
Sep 7, 2026
Merged

feat(auth): RFC 8725 token typ discipline — reject cross-use tokens (#444 item 5)#447
initializ-mk merged 2 commits into
mainfrom
feat/token-typ-discipline

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

Part of #444 (agent identity L1–L4). Item 5 — RFC 8725 token typ discipline. 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 typ header declares a non-access initializ media typechain-token, workload-credential, or mandate — 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 → accepted
  • application/vnd.initializ.chain-token+jwt / workload-credential+jwt / mandate+jwtrejected where an access token is expected

Why this design

  • Single choke point. Forge has exactly one inbound access-token verification point — the auth middleware → provider chain. I mapped every path first: external A2A (oidc/azure_ad/gcp_iap/aws_sigv4/http_verifier), the loopback runtime token, K8s CronJob triggers, and channel callbacks all converge there. Critically, http_verifier delegates (never sees the token bytes) and static_token is opaque — so a per-provider check couldn't cover them. The middleware, holding the raw bearer, is the only spot that covers 100%.
  • Denylist, not allowlist. Only a token that explicitly declares one of the three non-access types is refused. platform-bearer, absent/unknown typ, 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 initializ typ).
  • No signature needed. The reject is a header-only, unverified decode — if the header says chain-token, it's refused regardless of whether the signature would validate. Gated on token_kind == "jwt" so opaque/sigv4 skip it for free.
  • Fail-safe. New ErrWrongTokenType → 401 with a distinct wrong_token_type audit reason + auth.verify span attribute, so operators can alert on cross-use attempts specifically.

Tests

typ parser across all four media types + no-typ + unknown + opaque + malformed-header; middleware rejects chain-token before the chain is consulted (asserts the provider's Verify never ran); platform-bearer and opaque both pass through; audit reason = wrong_token_type, token_kind = jwt. golangci-lint clean; full forge-core/auth suite (+ all provider subpackages) passes.

Docs

authentication.md (new "Token typ discipline" section under Chain semantics), audit-logging.md (the wrong_token_type fail-reason row), and both fail_reason span-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).

… 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 initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. jwtHeaderTyp is 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/kind are extracted once at the top of Middleware; the gate precedes the sole Chain.Verify at middleware.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 the kind == "jwt" gate.
  • Cross-repo contract matches (verified against api-next develop). forge's four media-type constants are byte-for-byte identical to helper/tokentype.go, and forge's denylist {chain-token, workload-credential, mandate} exactly mirrors the platform's ourNonPlatformTypes / RejectForeignTokenClass — same three types, same exact-match semantics, same denylist rationale (keep accepting external OIDC at+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_type audit reason + auth.verify span attribute; docs (authentication.md, audit-logging.md, both fail_reason span enums) updated.

Change requested

  1. Cross-repo drift risk on the denylist (see inline). forge's rejectedInboundTokenTypes duplicates api-next's ourNonPlatformTypes; 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 referencing api-next helper/tokentype.go + a tracking note on the enforcement issue.

Note (no action)

  • forge's jwtHeaderTyp requires exactly 3 parts + unpadded base64url, while the platform's JOSEHeader tolerates >=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{

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@initializ-mk

Copy link
Copy Markdown
Contributor Author

Addressed in 84537d5. Pinned the cross-repo correspondence on rejectedInboundTokenTypes: a comment referencing api-next helper/tokentype.go (ourNonPlatformTypes / RejectForeignTokenClass), the exact-match semantics, and the lockstep-update obligation when api-next adds a non-access class. Added a drift-tracking note on the #444 enforcement epic. Thanks for verifying the byte-for-byte contract match against api-next develop — that was the crux.

@initializ-mk initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.goourNonPlatformTypes / 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.

@initializ-mk
initializ-mk merged commit e6272d2 into main Sep 7, 2026
10 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