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..605a6118 --- /dev/null +++ b/forge-core/auth/token_type.go @@ -0,0 +1,79 @@ +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. +// +// 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, + 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) + } +}