Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/core-concepts/observability-tracing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?"

Expand Down
1 change: 1 addition & 0 deletions docs/security/audit-logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
22 changes: 21 additions & 1 deletion docs/security/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
26 changes: 26 additions & 0 deletions forge-core/auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
}
Expand All @@ -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"
}
Expand Down
6 changes: 6 additions & 0 deletions forge-core/auth/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions forge-core/auth/token_type.go
Original file line number Diff line number Diff line change
@@ -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{

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.

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)]
}
140 changes: 140 additions & 0 deletions forge-core/auth/token_type_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading