From aa933ab56a97a193cfb008dfe1d4fa801bc2761f Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 7 Sep 2026 15:09:25 -0400 Subject: [PATCH 1/2] feat(auth): reject non-access token types at inbound verification (#444 item 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/core-concepts/observability-tracing.md | 2 +- docs/security/audit-logging.md | 1 + docs/security/authentication.md | 22 ++- forge-core/auth/middleware.go | 26 ++++ forge-core/auth/provider.go | 6 + forge-core/auth/token_type.go | 70 ++++++++++ forge-core/auth/token_type_test.go | 140 ++++++++++++++++++++ 7 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 forge-core/auth/token_type.go create mode 100644 forge-core/auth/token_type_test.go diff --git a/docs/core-concepts/observability-tracing.md b/docs/core-concepts/observability-tracing.md index ca4ac1a9..02d6b615 100644 --- a/docs/core-concepts/observability-tracing.md +++ b/docs/core-concepts/observability-tracing.md @@ -160,7 +160,7 @@ Wraps the `Provider.Chain.Verify` call in `forge-core/auth/middleware.go`. Witho | `forge.auth.token_kind` | `jwt` / `opaque` / `sigv4` / `iap_jwt` / `empty` — mirrors the audit `token_kind` field | | `forge.auth.decision` | `verify` on success, `fail` on any rejection | | `forge.auth.user_id` / `org_id` | from `Identity` on success | -| `forge.auth.fail_reason` | `missing_token` / `rejected` / `invalid` / `not_for_me` / `provider_unavailable` / `infrastructure` — only on failure; matches the `auth.FailReason` vocabulary used by the audit `auth_fail` event | +| `forge.auth.fail_reason` | `missing_token` / `rejected` / `invalid` / `not_for_me` / `provider_unavailable` / `wrong_token_type` / `infrastructure` — only on failure; matches the `auth.FailReason` vocabulary used by the audit `auth_fail` event | Span closes BEFORE `installSequenceCounterMiddleware` runs, so it sits outside the per-invocation sequence counter scope — the right scope, since the question is "did the caller authenticate?", not "what did the agent do?" diff --git a/docs/security/audit-logging.md b/docs/security/audit-logging.md index 0bfb2f0e..3a2b4121 100644 --- a/docs/security/audit-logging.md +++ b/docs/security/audit-logging.md @@ -275,6 +275,7 @@ OIDC `tid`/`org_id`-mapped claim depending on the provider. | `rejected` | Provider recognized + denied (allowlist miss, expired, bad sig, scope mismatch) | Check `allowed_principals` / `tenant_id` / token freshness | | `invalid` | Token malformed (bad base64, unsupported alg, missing required field) | Token construction bug on the caller side | | `provider_unavailable` | Verifier endpoint down (STS / JWKS / Graph 5xx, network error) | Provider-side incident; not a token issue | +| `wrong_token_type` | Bearer's JWT `typ` header declares a non-access initializ media type — a chain token, workload credential, or mandate — presented where an access token is expected (RFC 8725, #444). Rejected before the provider chain runs | Token-confusion / cross-use: a token minted for another leg is being replayed as an access token. Investigate the caller | ### Token kind values (`fields.token_kind`) diff --git a/docs/security/authentication.md b/docs/security/authentication.md index fb0702d7..c230ccb6 100644 --- a/docs/security/authentication.md +++ b/docs/security/authentication.md @@ -42,6 +42,26 @@ returns `ErrTokenRejected`, the chain does NOT try provider B. Otherwise an attacker could downgrade by presenting a malformed token of type A and hoping to be authenticated as type B. +### Token `typ` discipline (RFC 8725) + +Before the chain runs, the middleware enforces explicit token typing on the +inbound bearer (agent-identity, #444). The platform mints JWTs with a `typ` +header naming an initializ media type; a token minted for a **non-access** +purpose must never be usable as a caller's access token. So a bearer whose JWT +`typ` header is one of: + +- `application/vnd.initializ.chain-token+jwt` (agent-to-agent chain token) +- `application/vnd.initializ.workload-credential+jwt` (projected workload credential) +- `application/vnd.initializ.mandate+jwt` (delegation mandate) + +is **rejected with 401 before any provider is consulted** (audit +`fail_reason: wrong_token_type`), closing the token-confusion / cross-use gap +where a token issued for one leg is replayed as an access token. This is a +**denylist**: `application/vnd.initializ.platform-bearer+jwt`, an absent/unknown +`typ`, and non-JWT bearers (opaque `static_token`, `aws_sigv4`) all pass through +to normal verification untouched. The reject is a header-only check and needs no +signature — a token declaring a non-access purpose is refused regardless. + ### Loopback `static_token` is auto-prepended Forge writes a random token to `.forge/runtime.token` (mode `0600`) on @@ -421,7 +441,7 @@ When tracing is enabled (`observability.tracing.enabled: true`), the auth middle | `forge.auth.token_kind` | `jwt` / `opaque` / `sigv4` / `iap_jwt` / `empty` — mirrors the audit field | | `forge.auth.decision` | `verify` (success) or `fail` (any rejection) | | `forge.auth.user_id` / `forge.auth.org_id` | from `Identity` on success | -| `forge.auth.fail_reason` | `missing_token` / `rejected` / `invalid` / `not_for_me` / `provider_unavailable` / `infrastructure` — only on failure | +| `forge.auth.fail_reason` | `missing_token` / `rejected` / `invalid` / `not_for_me` / `provider_unavailable` / `wrong_token_type` / `infrastructure` — only on failure | Span Status is set to `Error` on the failure path so the error-rate dashboards count auth rejections consistently across the rest of the Forge span families. See [Observability — Tracing](../core-concepts/observability-tracing.md#authverify) for the full hierarchy. diff --git a/forge-core/auth/middleware.go b/forge-core/auth/middleware.go index 32cd2842..216b7f16 100644 --- a/forge-core/auth/middleware.go +++ b/forge-core/auth/middleware.go @@ -176,6 +176,28 @@ func Middleware(opts MiddlewareOptions) func(http.Handler) http.Handler { return } + // RFC 8725 explicit typing (#444 item 5): reject a bearer that + // declares a non-access initializ media type (chain token / + // workload credential / mandate) BEFORE the provider chain runs — + // no provider should ever verify a cross-use token. Gated on + // kind == "jwt" so opaque (static/loopback) and sigv4 bearers skip + // the check for free; a JWT with no/unknown/platform-bearer typ + // passes through to normal verification (denylist, never breaks + // existing tokens). The reject needs no signature check. + if kind == "jwt" && IsRejectedInboundTokenType(token) { + _, span := coreruntime.Tracer().Start(r.Context(), "auth.verify") + span.SetAttributes( + attribute.String(observability.AttrForgeAuthTokenKind, kind), + attribute.String(observability.AttrForgeAuthDecision, "fail"), + attribute.String(observability.AttrForgeAuthFailReason, FailReason(ErrWrongTokenType)), + ) + span.SetStatus(codes.Error, classifyAuthFailure(ErrWrongTokenType)) + span.End() + notifyAuth(opts.OnAuth, r, nil, ErrWrongTokenType, kind) + writeAuthError(w, classifyAuthFailure(ErrWrongTokenType)) + return + } + // Open auth.verify around the Provider.Verify call so any // outbound http.client spans the provider opens (JWKS // fetch, AWS STS verify, IAP token introspect, AAD Graph) @@ -349,6 +371,8 @@ func FailReason(err error) string { return "provider_unavailable" case errors.Is(err, ErrTokenNotForMe): return "not_for_me" + case errors.Is(err, ErrWrongTokenType): + return "wrong_token_type" default: return "infrastructure" } @@ -375,6 +399,8 @@ func classifyAuthFailure(err error) string { // the client can be different from "invalid token". This is also // the operator-facing signal in /healthz-style probes. return "auth provider unavailable" + case errors.Is(err, ErrWrongTokenType): + return "wrong token type" default: return "auth provider error" } diff --git a/forge-core/auth/provider.go b/forge-core/auth/provider.go index ed1a3f70..ce527d1d 100644 --- a/forge-core/auth/provider.go +++ b/forge-core/auth/provider.go @@ -167,6 +167,12 @@ var ( ErrInvalidToken = errors.New("auth: invalid token") ErrProviderUnavailable = errors.New("auth: provider unavailable") ErrProviderNotConfigured = errors.New("auth: provider not configured") + // ErrWrongTokenType — the bearer explicitly declares (via its JWT `typ` + // header) a non-access initializ media type (chain token / workload + // credential / mandate) that must not be used as an access token + // (RFC 8725 explicit typing, #444 item 5). Rejected before the provider + // chain runs — no provider should ever see a cross-use token. + ErrWrongTokenType = errors.New("auth: wrong token type for access") ) // MarkRuntimeInternal returns a copy of id marked as minted by the runtime's diff --git a/forge-core/auth/token_type.go b/forge-core/auth/token_type.go new file mode 100644 index 00000000..0123ed64 --- /dev/null +++ b/forge-core/auth/token_type.go @@ -0,0 +1,70 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "strings" +) + +// RFC 8725 explicit token typing (agent-identity, #444 item 5). +// +// The platform mints JWTs with an explicit `typ` header naming an initializ +// media type (api-next#36). Forge enforces the discipline on the INBOUND path: +// a token minted for a different purpose — a chain token, a workload +// credential, a mandate — must be REJECTED where an access token is expected, +// so a token issued for one leg can't be replayed as a caller's access token +// (a token-confusion / cross-use attack, RFC 8725 §2.8 / §3.11). +const ( + // MediaTypePlatformBearer is the valid inbound access-token type. Accepted. + MediaTypePlatformBearer = "application/vnd.initializ.platform-bearer+jwt" + // MediaTypeChainToken is an agent-to-agent chain token (#444 item 3). + MediaTypeChainToken = "application/vnd.initializ.chain-token+jwt" + // MediaTypeWorkloadCredential is the projected workload credential (#444 item 1). + MediaTypeWorkloadCredential = "application/vnd.initializ.workload-credential+jwt" + // MediaTypeMandate is an L2 delegation mandate object. + MediaTypeMandate = "application/vnd.initializ.mandate+jwt" +) + +// rejectedInboundTokenTypes are the initializ JWT media types that must never +// be accepted as an inbound access token. Denylist by design: an absent / +// unknown / platform-bearer `typ` passes through to normal verification, so +// 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{ + MediaTypeChainToken: true, + MediaTypeWorkloadCredential: true, + MediaTypeMandate: true, +} + +// jwtHeaderTyp decodes — WITHOUT verifying the signature — the `typ` header of +// a compact-JWS token, returning "" when token is not a well-formed JWT or +// carries no typ. No signature check is needed for a reject decision: if the +// header declares a non-access purpose, the token must be refused regardless of +// whether its signature would validate. +func jwtHeaderTyp(token string) string { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return "" + } + // JWS uses base64url without padding (RFC 7515 §2). + raw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return "" + } + var hdr struct { + Typ string `json:"typ"` + } + if json.Unmarshal(raw, &hdr) != nil { + return "" + } + return hdr.Typ +} + +// IsRejectedInboundTokenType reports whether a bearer token explicitly declares +// (via its JWT `typ` header) one of the non-access initializ media types that +// must be rejected where an access token is expected (#444 item 5). Non-JWT, +// no-typ, unknown-typ, and platform-bearer tokens return false. +func IsRejectedInboundTokenType(token string) bool { + return rejectedInboundTokenTypes[jwtHeaderTyp(token)] +} diff --git a/forge-core/auth/token_type_test.go b/forge-core/auth/token_type_test.go new file mode 100644 index 00000000..f04d12ae --- /dev/null +++ b/forge-core/auth/token_type_test.go @@ -0,0 +1,140 @@ +package auth + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// makeJWTWithTyp builds a compact-JWS-shaped token (header.payload.sig) whose +// header carries the given `typ` (omitted when ""). The signature segment is a +// placeholder — the typ check reads only the unverified header. +func makeJWTWithTyp(t *testing.T, typ string) string { + t.Helper() + hdr := map[string]any{"alg": "none"} + if typ != "" { + hdr["typ"] = typ + } + h, _ := json.Marshal(hdr) + p, _ := json.Marshal(map[string]any{"sub": "x"}) + enc := base64.RawURLEncoding.EncodeToString + return enc(h) + "." + enc(p) + ".sig" +} + +func TestIsRejectedInboundTokenType(t *testing.T) { + cases := []struct { + name string + token string + want bool + }{ + {"chain token rejected", makeJWTWithTyp(t, MediaTypeChainToken), true}, + {"workload credential rejected", makeJWTWithTyp(t, MediaTypeWorkloadCredential), true}, + {"mandate rejected", makeJWTWithTyp(t, MediaTypeMandate), true}, + {"platform bearer accepted", makeJWTWithTyp(t, MediaTypePlatformBearer), false}, + {"no typ header accepted", makeJWTWithTyp(t, ""), false}, + {"unknown typ accepted", makeJWTWithTyp(t, "application/at+jwt"), false}, + {"opaque non-jwt accepted", "not-a-jwt-opaque-secret", false}, + {"two-segment non-jwt accepted", "aaa.bbb", false}, + {"garbage header segment accepted", "!!!.bbb.ccc", false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsRejectedInboundTokenType(tc.token); got != tc.want { + t.Errorf("IsRejectedInboundTokenType(%q) = %v, want %v", tc.token, got, tc.want) + } + }) + } +} + +// recordingProvider accepts ANY token and records whether Verify ran — so a +// test can prove the typ gate rejects BEFORE the provider chain is consulted. +type recordingProvider struct { + called bool +} + +func (p *recordingProvider) Name() string { return "recording" } +func (p *recordingProvider) Verify(_ context.Context, _ string, _ Headers) (*Identity, error) { + p.called = true + id := Identity{UserID: "u1", Source: "recording"} + return &id, nil +} + +func TestMiddleware_RejectsWrongTokenType_BeforeChain(t *testing.T) { + okHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + t.Run("chain token is 401 and never reaches the provider", func(t *testing.T) { + prov := &recordingProvider{} + mw := Middleware(MiddlewareOptions{Chain: NewChainProvider(prov), SkipPaths: DefaultSkipPaths()}) + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "Bearer "+makeJWTWithTyp(t, MediaTypeChainToken)) + rec := httptest.NewRecorder() + mw(okHandler).ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Errorf("status = %d, want 401", rec.Code) + } + if prov.called { + t.Error("provider chain must NOT be consulted for a cross-use token — typ reject happens first") + } + }) + + t.Run("platform-bearer passes through to the chain", func(t *testing.T) { + prov := &recordingProvider{} + mw := Middleware(MiddlewareOptions{Chain: NewChainProvider(prov), SkipPaths: DefaultSkipPaths()}) + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "Bearer "+makeJWTWithTyp(t, MediaTypePlatformBearer)) + rec := httptest.NewRecorder() + mw(okHandler).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200", rec.Code) + } + if !prov.called { + t.Error("a platform-bearer token must reach the provider chain") + } + }) + + t.Run("opaque (non-JWT) token skips the typ gate and reaches the chain", func(t *testing.T) { + prov := &recordingProvider{} + mw := Middleware(MiddlewareOptions{Chain: NewChainProvider(prov), SkipPaths: DefaultSkipPaths()}) + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "Bearer opaque-loopback-secret") + rec := httptest.NewRecorder() + mw(okHandler).ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status = %d, want 200 (opaque tokens must not be typ-gated)", rec.Code) + } + if !prov.called { + t.Error("opaque token must reach the provider chain") + } + }) +} + +func TestMiddleware_WrongTokenType_AuditReason(t *testing.T) { + var gotErr error + var gotKind string + mw := Middleware(MiddlewareOptions{ + Chain: NewChainProvider(&recordingProvider{}), + SkipPaths: DefaultSkipPaths(), + OnAuth: func(_ *http.Request, _ *Identity, err error, kind string) { + gotErr = err + gotKind = kind + }, + }) + req := httptest.NewRequest("POST", "/", nil) + req.Header.Set("Authorization", "Bearer "+makeJWTWithTyp(t, MediaTypeMandate)) + mw(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(httptest.NewRecorder(), req) + + if FailReason(gotErr) != "wrong_token_type" { + t.Errorf("audit fail reason = %q, want wrong_token_type", FailReason(gotErr)) + } + if gotKind != "jwt" { + t.Errorf("token_kind = %q, want jwt", gotKind) + } +} From 84537d576f881587aa572bb1d2f9b0e0f05a436f Mon Sep 17 00:00:00 2001 From: MK Date: Mon, 7 Sep 2026 16:13:54 -0400 Subject: [PATCH 2/2] =?UTF-8?q?review(auth):=20pin=20denylist=20=E2=86=94?= =?UTF-8?q?=20api-next=20cross-repo=20contract=20(#447)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- forge-core/auth/token_type.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/forge-core/auth/token_type.go b/forge-core/auth/token_type.go index 0123ed64..605a6118 100644 --- a/forge-core/auth/token_type.go +++ b/forge-core/auth/token_type.go @@ -31,6 +31,15 @@ const ( // 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. +// +// CROSS-REPO CONTRACT — keep in sync with the platform's own ingress guard, +// api-next `helper/tokentype.go` (`ourNonPlatformTypes` / `RejectForeignTokenClass`). +// The two denylists must name the SAME non-access classes with exact-match +// semantics; they match as of api-next develop. There is no shared source of +// truth across the repos, so when api-next adds a fourth non-access media type +// this set MUST gain it in lockstep — otherwise forge silently keeps accepting +// that class as an access token, reopening the cross-use gap. Drift is tracked +// on the #444 enforcement epic. var rejectedInboundTokenTypes = map[string]bool{ MediaTypeChainToken: true, MediaTypeWorkloadCredential: true,