-
Notifications
You must be signed in to change notification settings - Fork 13
feat(auth): RFC 8725 token typ discipline — reject cross-use tokens (#444 item 5) #447
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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{ | ||
| 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)] | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.