From 307a48043ef1fca36fdd067abc14f202e921de2b Mon Sep 17 00:00:00 2001 From: Jakub Hrozek Date: Fri, 3 Jul 2026 19:50:02 +0100 Subject: [PATCH] Validate aud and resource claims in returned ID-JAG JWT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The XAA IdP exchange (RFC 8693) returned an ID-JAG JWT without validating its aud or resource claims against the values requested in the exchange. If the IdP minted an ID-JAG for a different audience or resource, ToolHive forwarded it to the target AS unchanged — a token- misbinding / confused-deputy risk. Add validateIDJAGClaims to check the aud claim (REQUIRED per draft §3.1, membership check per RFC 7519 §4.1.3) and the resource claim (OPTIONAL per §3.1, validated when one was requested) inside the returned ID-JAG JWT. A non-JWT assertion that fails to parse is now rejected rather than forwarded. The typ header check remains logged- only by design (the draft §3.1 MUST is on the issuer; ToolHive is the holder/presenter). Closes #5696 --- pkg/vmcp/auth/strategies/xaa.go | 109 +++++++- pkg/vmcp/auth/strategies/xaa_test.go | 356 +++++++++++++++++++++++++-- 2 files changed, 438 insertions(+), 27 deletions(-) diff --git a/pkg/vmcp/auth/strategies/xaa.go b/pkg/vmcp/auth/strategies/xaa.go index 221309dcba..8e72041047 100644 --- a/pkg/vmcp/auth/strategies/xaa.go +++ b/pkg/vmcp/auth/strategies/xaa.go @@ -224,7 +224,10 @@ func (*XAAStrategy) authenticateWithClientCredentials( // performIDPExchange runs the RFC 8693 token exchange at the IdP to obtain an // ID-JAG. It verifies the response's issued_token_type matches the ID-JAG URN -// per draft-ietf-oauth-identity-assertion-authz-grant §4.3.3. +// (draft §4.3.3) and that token_type is N_A (draft §5.2), logs a mismatch on +// the JWT typ header, rejects an empty or unparsable assertion, and validates +// the aud/resource binding claims against what was requested (confused-deputy +// defence; see validateIDJAGClaims). func (*XAAStrategy) performIDPExchange( ctx context.Context, idToken string, config *xaaParsedConfig, ) (string, error) { @@ -277,19 +280,107 @@ func (*XAAStrategy) performIDPExchange( const idJAGJWTType = "oauth-id-jag+jwt" parser := jwt.NewParser() parsed, _, parseErr := parser.ParseUnverified(assertion, jwt.MapClaims{}) - if parseErr == nil { - if typ, _ := parsed.Header["typ"].(string); !strings.EqualFold(typ, idJAGJWTType) { - slog.Debug("xaa: ID-JAG JWT typ header mismatch", - "got", typ, "want", idJAGJWTType, - ) - } - } else { - slog.Debug("xaa: could not parse ID-JAG JWT header for typ check", "error", parseErr) + if parseErr != nil { + // A non-parseable assertion cannot carry valid aud/resource claims and + // therefore cannot be a valid ID-JAG. Fail fast rather than forwarding + // an unvalidated assertion to the target AS. + return "", fmt.Errorf("IdP exchange: ID-JAG assertion is not a valid JWT: %w", parseErr) + } + if typ, _ := parsed.Header["typ"].(string); !strings.EqualFold(typ, idJAGJWTType) { + slog.Debug("xaa: ID-JAG JWT typ header mismatch", + "got", typ, "want", idJAGJWTType, + ) + } + // The assertion cannot fail: ParseUnverified was called with a literal + // jwt.MapClaims{}, which it decodes into in place. The !ok branch is + // defensive. + claims, ok := parsed.Claims.(jwt.MapClaims) + if !ok { + return "", fmt.Errorf("IdP exchange: ID-JAG claims are not MapClaims") + } + if err := validateIDJAGClaims(claims, config.targetAudience, config.targetResource); err != nil { + return "", err } return assertion, nil } +// validateIDJAGClaims checks the aud and resource claims of the returned ID-JAG +// JWT against the values requested in the IdP exchange (draft §3.1, §5). +// +// Per draft §4.4.1, aud MUST be a single-value issuer identifier: either a JSON +// string, or a JSON array containing exactly one element. A multi-element aud +// array is malformed and is rejected (see audMatches). resource is checked +// only when one was requested (the resource parameter is OPTIONAL per draft +// §4.3); when one was requested, ToolHive requires the returned ID-JAG to echo +// it — a deliberate strict-binding policy, stricter than the draft, which +// leaves resource OPTIONAL in the response even when requested. +// +// This is defence-in-depth against token misbinding: if the IdP mints an ID-JAG +// for a different audience/resource, the target AS should reject it — but +// ToolHive fails fast rather than forwarding a mismatched assertion. +// +// Per RFC 7519 §4.1.3, the aud claim may be a string or an array of strings; +// golang-jwt/v5 decodes a JSON array as []interface{} whose elements are +// strings. resource legitimately allows an array of URIs per draft §3.1, so a +// membership check (claimContainsString) is used there; aud does not permit +// multiple values, so a dedicated single-value check (audMatches) is used +// instead. +func validateIDJAGClaims(claims jwt.MapClaims, wantAudience, wantResource string) error { + // aud is REQUIRED and single-valued per draft §4.4.1. + if !audMatches(claims, wantAudience) { + return fmt.Errorf("IdP exchange: ID-JAG aud claim %v does not match requested audience %q", claims["aud"], wantAudience) + } + + // Deliberately stricter than the draft: resource is OPTIONAL in the ID-JAG + // even when requested, but ToolHive rejects an ID-JAG that omits it once + // the operator has configured a resource to bind to. + if wantResource != "" && !claimContainsString(claims, "resource", wantResource) { + return fmt.Errorf( + "IdP exchange: ID-JAG resource claim %v does not contain requested resource %q", + claims["resource"], wantResource, + ) + } + + return nil +} + +// audMatches reports whether the aud claim is a single-value issuer identifier +// equal to want, per draft §4.4.1: either a JSON string, or a JSON array +// containing exactly one element. Any other shape — missing, multi-element +// array, empty array, wrong type, or a non-string element — is rejected. +func audMatches(claims jwt.MapClaims, want string) bool { + switch v := claims["aud"].(type) { + case string: + return v == want + case []interface{}: + if len(v) != 1 { + return false + } + s, ok := v[0].(string) + return ok && s == want + default: + return false + } +} + +// claimContainsString reports whether the named claim contains want. Per RFC +// 7519 the claim may be a single string or an array; golang-jwt/v5 decodes JSON +// arrays as []interface{} whose elements are strings. +func claimContainsString(claims jwt.MapClaims, key, want string) bool { + switch v := claims[key].(type) { + case string: + return v == want + case []interface{}: + for _, e := range v { + if s, ok := e.(string); ok && s == want { + return true + } + } + } + return false +} + // performTargetGrant runs the RFC 7523 JWT Bearer grant at the target AS using // the ID-JAG assertion returned by IdP exchange. func (*XAAStrategy) performTargetGrant( diff --git a/pkg/vmcp/auth/strategies/xaa_test.go b/pkg/vmcp/auth/strategies/xaa_test.go index 46f6ea71f3..6d3f69eb9a 100644 --- a/pkg/vmcp/auth/strategies/xaa_test.go +++ b/pkg/vmcp/auth/strategies/xaa_test.go @@ -92,8 +92,8 @@ func createXAAIdPServer(t *testing.T, expectedIDToken, idJAGToReturn string) *ht } // createXAATargetServer creates a mock target AS server that validates target -// grant (RFC 7523 JWT Bearer grant) requests and returns an access token. -func createXAATargetServer(t *testing.T, expectedAssertion, accessTokenToReturn string) *httptest.Server { +// grant (RFC 7523 JWT Bearer grant) requests and returns a fixed access token. +func createXAATargetServer(t *testing.T, expectedAssertion string) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Helper() @@ -109,7 +109,7 @@ func createXAATargetServer(t *testing.T, expectedAssertion, accessTokenToReturn w.Header().Set("Content-Type", "application/json") err = json.NewEncoder(w).Encode(map[string]any{ - "access_token": accessTokenToReturn, + "access_token": "final-access-token", "token_type": "Bearer", "expires_in": 3600, }) @@ -117,6 +117,28 @@ func createXAATargetServer(t *testing.T, expectedAssertion, accessTokenToReturn })) } +// xaaStrategyWithResource builds a BackendAuthStrategy for xaa with the standard +// test config including TargetResource, pointing at the provided IdP/target URLs. +func xaaStrategyWithResource(idpURL, targetURL string) *authtypes.BackendAuthStrategy { + return createXAAStrategy(func(cfg *authtypes.XAAConfig) { + cfg.IDPTokenURL = idpURL + cfg.TargetTokenURL = targetURL + cfg.TargetAudience = testTargetAudience + cfg.TargetResource = testTargetResource + cfg.SubjectProviderName = testProviderGitHub + }) +} + +// mustNotBeCalledTargetServer returns a mock target AS whose handler fails the +// test if invoked. Used by error-path cases where the IdP exchange must reject +// the assertion before the target grant runs. +func mustNotBeCalledTargetServer(t *testing.T) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Error("target AS must not be called when IdP exchange rejects the ID-JAG") + })) +} + func TestXAAStrategy_Validate(t *testing.T) { t.Parallel() @@ -582,8 +604,9 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }, setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { t.Helper() - idpServer := createXAAIdPServer(t, "user-id-token-jwt", "the-id-jag-token") - targetServer := createXAATargetServer(t, "the-id-jag-token", "final-access-token") + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := createXAATargetServer(t, idJAG) return idpServer, targetServer }, strategy: func(idpURL, targetURL string) *authtypes.BackendAuthStrategy { @@ -642,7 +665,8 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }, setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { t.Helper() - idpServer := createXAAIdPServer(t, "valid-id-token", "valid-id-jag") + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, testTargetResource) + idpServer := createXAAIdPServer(t, "valid-id-token", idJAG) targetServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnauthorized) err := json.NewEncoder(w).Encode(map[string]any{ @@ -778,7 +802,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }, setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { t.Helper() - wrongTypJWT := buildJWTWithTypHeader(t, "JWT") + wrongTypJWT := buildIDJAGJWT(t, "JWT", testTargetAudience, testTargetResource) idpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") err := json.NewEncoder(w).Encode(map[string]any{ @@ -789,7 +813,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) assert.NoError(t, err) })) - targetServer := createXAATargetServer(t, wrongTypJWT, "final-access-token") + targetServer := createXAATargetServer(t, wrongTypJWT) return idpServer, targetServer }, strategy: func(idpURL, targetURL string) *authtypes.BackendAuthStrategy { @@ -815,7 +839,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }, setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { t.Helper() - correctTypJWT := buildJWTWithTypHeader(t, "oauth-id-jag+jwt") + correctTypJWT := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, testTargetResource) idpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") err := json.NewEncoder(w).Encode(map[string]any{ @@ -826,7 +850,7 @@ func TestXAAStrategy_Authenticate(t *testing.T) { }) assert.NoError(t, err) })) - targetServer := createXAATargetServer(t, correctTypJWT, "final-access-token") + targetServer := createXAATargetServer(t, correctTypJWT) return idpServer, targetServer }, strategy: func(idpURL, targetURL string) *authtypes.BackendAuthStrategy { @@ -844,6 +868,261 @@ func TestXAAStrategy_Authenticate(t *testing.T) { assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) }, }, + // aud claim validation (draft §3.1: aud is REQUIRED). + { + name: "IdP exchange rejects ID-JAG with missing aud claim", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", nil, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "aud"}, + }, + { + name: "IdP exchange rejects ID-JAG with wrong aud (string)", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", "https://wrong.example.com", testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "aud"}, + }, + { + name: "IdP exchange rejects ID-JAG with wrong aud (array)", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + []string{"https://wrong.example.com", "https://other.example.com"}, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "aud"}, + }, + { + name: "IdP exchange rejects ID-JAG with multi-element aud array", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + []string{testTargetAudience, "https://extra.example.com"}, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "aud"}, + }, + { + name: "IdP exchange accepts ID-JAG with aud as single-element array", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + []string{testTargetAudience}, testTargetResource) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := createXAATargetServer(t, idJAG) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: false, + checkAuthHeader: func(t *testing.T, req *http.Request) { + t.Helper() + assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) + }, + }, + // resource claim validation (draft §3.1: resource is OPTIONAL, validated + // when one was requested). + { + name: "IdP exchange rejects ID-JAG with wrong resource", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, "https://wrong.example.com") + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "resource"}, + }, + { + name: "IdP exchange rejects ID-JAG with missing resource when requested", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + // resource omitted (empty string) while config requests one. + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, "") + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "resource"}, + }, + { + name: "IdP exchange accepts ID-JAG with resource as array containing TargetResource", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + testTargetAudience, []string{testTargetResource}) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := createXAATargetServer(t, idJAG) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: false, + checkAuthHeader: func(t *testing.T, req *http.Request) { + t.Helper() + assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) + }, + }, + { + name: "IdP exchange accepts ID-JAG with resource array containing a non-string element", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + testTargetAudience, []interface{}{float64(123), testTargetResource}) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := createXAATargetServer(t, idJAG) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: false, + checkAuthHeader: func(t *testing.T, req *http.Request) { + t.Helper() + assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) + }, + }, + { + name: "IdP exchange rejects ID-JAG with resource array containing only a non-string element", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", + testTargetAudience, []interface{}{float64(123)}) + idpServer := createXAAIdPServer(t, "user-id-token-jwt", idJAG) + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "resource"}, + }, + // resource check skipped when TargetResource is not configured + // (wantResource == "" early-return branch in validateIDJAGClaims). + { + name: "IdP exchange accepts ID-JAG when TargetResource is not configured", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idJAG := buildIDJAGJWT(t, "oauth-id-jag+jwt", testTargetAudience, nil) + // TargetResource is unset, so the exchange request carries no + // resource parameter; use an IdP mock that does not assert it. + idpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Helper() + assert.Equal(t, http.MethodPost, r.Method) + require.NoError(t, r.ParseForm()) + assert.Equal(t, oauthproto.GrantTypeTokenExchange, r.Form.Get("grant_type")) + assert.Equal(t, "user-id-token-jwt", r.Form.Get("subject_token")) + assert.Equal(t, oauthproto.TokenTypeIDJAG, r.Form.Get("requested_token_type")) + assert.NotEmpty(t, r.Form.Get("audience"), "audience must be set") + assert.Empty(t, r.Form.Get("resource"), "resource must be absent when not configured") + + w.Header().Set("Content-Type", "application/json") + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "access_token": idJAG, + "token_type": "N_A", + "issued_token_type": oauthproto.TokenTypeIDJAG, + "expires_in": 300, + })) + })) + targetServer := createXAATargetServer(t, idJAG) + return idpServer, targetServer + }, + strategy: func(idpURL, targetURL string) *authtypes.BackendAuthStrategy { + return createXAAStrategy(func(cfg *authtypes.XAAConfig) { + cfg.IDPTokenURL = idpURL + cfg.TargetTokenURL = targetURL + cfg.TargetAudience = testTargetAudience + // TargetResource intentionally omitted. + cfg.SubjectProviderName = testProviderGitHub + }) + }, + expectError: false, + checkAuthHeader: func(t *testing.T, req *http.Request) { + t.Helper() + assert.Equal(t, "Bearer final-access-token", req.Header.Get("Authorization")) + }, + }, + // parse failure: a non-JWT assertion cannot be a valid ID-JAG. + { + name: "IdP exchange rejects non-JWT assertion", + setupCtx: func() context.Context { + return createContextWithUpstreamIDTokens( + map[string]string{testProviderGitHub: "user-id-token-jwt"}) + }, + setupServers: func(t *testing.T) (*httptest.Server, *httptest.Server) { + t.Helper() + idpServer := createXAAIdPServer(t, "user-id-token-jwt", "not-a-jwt") + targetServer := mustNotBeCalledTargetServer(t) + return idpServer, targetServer + }, + strategy: xaaStrategyWithResource, + expectError: true, + errorContains: []string{"IdP exchange", "not a valid JWT"}, + }, } for _, tt := range tests { @@ -886,23 +1165,64 @@ func TestXAAStrategy_Authenticate(t *testing.T) { } } -// buildJWTWithTypHeader signs a minimal JWT with the given typ header value using -// a throwaway RSA key. The token is not intended to be verified — it exists only -// to exercise the header-parsing path in performIDPExchange. -func buildJWTWithTypHeader(t *testing.T, typ string) string { +// testJWTSigningKey is a throwaway RSA key shared by every buildIDJAGJWT call +// in this file. Tokens built with it are only ever consumed via +// ParseUnverified (signature never checked), so a single package-level key +// avoids generating a fresh RSA-2048 key per test case. +var testJWTSigningKey = func() *rsa.PrivateKey { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(err) + } + return key +}() + +// buildIDJAGJWT signs a JWT with the given typ header, aud claim, and resource +// claim using testJWTSigningKey. aud and resource may each be a string, +// []string (to exercise the array form), or []interface{} (to exercise +// heterogeneous arrays); a nil or empty string omits the claim. The token is +// not intended to be verified — it exists to exercise the claim-validation +// path in performIDPExchange. +func buildIDJAGJWT(t *testing.T, typ string, aud, resource any) string { t.Helper() - key, err := rsa.GenerateKey(rand.Reader, 2048) - require.NoError(t, err) + claims := jwt.MapClaims{"sub": "test"} + setClaim(t, claims, "aud", aud) + setClaim(t, claims, "resource", resource) - token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{"sub": "test"}) + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) token.Header["typ"] = typ - signed, err := token.SignedString(key) + signed, err := token.SignedString(testJWTSigningKey) require.NoError(t, err) return signed } +// setClaim sets a string-or-array claim on a MapClaims, omitting it when v is +// nil or an empty string. []string is marshalled as a JSON array (decoded back +// by golang-jwt as []interface{}, exercising the array branch of +// claimContainsString). []interface{} is set directly, allowing heterogeneous +// arrays (e.g. mixing strings with non-string elements) to exercise the +// type-guard in claimContainsString's array branch. +func setClaim(t *testing.T, claims jwt.MapClaims, key string, v any) { + t.Helper() + switch c := v.(type) { + case nil: + // omit + case string: + if c == "" { + return + } + claims[key] = c + case []string: + claims[key] = c + case []interface{}: + claims[key] = c + default: + t.Fatalf("unsupported %s type %T", key, v) + } +} + func TestXAAStrategy_ClientSecretEnv(t *testing.T) { t.Parallel()