From 5a6f7b34af4529dfcc1096763c487a2592a31104 Mon Sep 17 00:00:00 2001 From: Maxim Darii Date: Thu, 3 Jul 2025 10:33:25 +0200 Subject: [PATCH 1/4] move the scope from assertion payload to the request parameter --- jwt/jwt.go | 4 +++- jwt/jwt_test.go | 64 +++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/jwt/jwt.go b/jwt/jwt.go index 38a92daca..d2f9c2f18 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -105,7 +105,6 @@ func (js jwtSource) Token() (*oauth2.Token, error) { hc := oauth2.NewClient(js.ctx, nil) claimSet := &jws.ClaimSet{ Iss: js.conf.Email, - Scope: strings.Join(js.conf.Scopes, " "), Aud: js.conf.TokenURL, PrivateClaims: js.conf.PrivateClaims, } @@ -130,6 +129,9 @@ func (js jwtSource) Token() (*oauth2.Token, error) { v := url.Values{} v.Set("grant_type", defaultGrantType) v.Set("assertion", payload) + if len(js.conf.Scopes) > 0 { + v.Set("scope", strings.Join(js.conf.Scopes, " ")) + } resp, err := hc.PostForm(js.conf.TokenURL, v) if err != nil { return nil, fmt.Errorf("oauth2: cannot fetch token: %v", err) diff --git a/jwt/jwt_test.go b/jwt/jwt_test.go index c7619a10a..5457d1223 100644 --- a/jwt/jwt_test.go +++ b/jwt/jwt_test.go @@ -256,8 +256,9 @@ func TestJWTFetch_AssertionPayload(t *testing.T) { if got, want := claimSet.Iss, conf.Email; got != want { t.Errorf("payload email = %q; want %q", got, want) } - if got, want := claimSet.Scope, strings.Join(conf.Scopes, " "); got != want { - t.Errorf("payload scope = %q; want %q", got, want) + // Scope should NOT be in the JWT claim set according to RFC 7521 + if claimSet.Scope != "" { + t.Errorf("payload scope should be empty but got %q; scopes should be sent as request parameter", claimSet.Scope) } aud := conf.TokenURL if conf.Audience != "" { @@ -316,3 +317,62 @@ func TestTokenRetrieveError(t *testing.T) { t.Fatalf("got %#v, expected %#v", errStr, expected) } } + +func TestJWTFetch_ScopeParameter(t *testing.T) { + var receivedScope string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + receivedScope = r.Form.Get("scope") + + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "access_token": "90d64460d14870c08c81352a05dedd3465940a7c", + "scope": "user", + "token_type": "bearer", + "expires_in": 3600 + }`)) + })) + defer ts.Close() + + tests := []struct { + name string + scopes []string + expectedScope string + }{ + { + name: "no scopes", + scopes: nil, + expectedScope: "", + }, + { + name: "single scope", + scopes: []string{"read"}, + expectedScope: "read", + }, + { + name: "multiple scopes", + scopes: []string{"read", "write", "admin"}, + expectedScope: "read write admin", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + conf := &Config{ + Email: "aaa@xxx.com", + PrivateKey: dummyPrivateKey, + TokenURL: ts.URL, + Scopes: tt.scopes, + } + + _, err := conf.TokenSource(context.Background()).Token() + if err != nil { + t.Fatalf("Failed to fetch token: %v", err) + } + + if got, want := receivedScope, tt.expectedScope; got != want { + t.Errorf("received scope parameter = %q; want %q", got, want) + } + }) + } +} From be711bec9af5ab665f3ab229224422b6182f420e Mon Sep 17 00:00:00 2001 From: Maxim Darii Date: Thu, 3 Jul 2025 11:29:33 +0200 Subject: [PATCH 2/4] add support for multiple audiences as described in RFC 7519 --- jws/jws.go | 12 ++++++------ jwt/jwt.go | 23 ++++++++++++++++++++++- jwt/jwt_test.go | 40 ++++++++++++++++++++++++++++++++++------ 3 files changed, 62 insertions(+), 13 deletions(-) diff --git a/jws/jws.go b/jws/jws.go index 9bc484406..4477f7d7a 100644 --- a/jws/jws.go +++ b/jws/jws.go @@ -32,12 +32,12 @@ import ( // permissions being requested (scopes), the target of the token, the issuer, // the time the token was issued, and the lifetime of the token. type ClaimSet struct { - Iss string `json:"iss"` // email address of the client_id of the application making the access token request - Scope string `json:"scope,omitempty"` // space-delimited list of the permissions the application requests - Aud string `json:"aud"` // descriptor of the intended target of the assertion (Optional). - Exp int64 `json:"exp"` // the expiration time of the assertion (seconds since Unix epoch) - Iat int64 `json:"iat"` // the time the assertion was issued (seconds since Unix epoch) - Typ string `json:"typ,omitempty"` // token type (Optional). + Iss string `json:"iss"` // email address of the client_id of the application making the access token request + Scope string `json:"scope,omitempty"` // space-delimited list of the permissions the application requests + Aud interface{} `json:"aud"` // descriptor of the intended target of the assertion. Can be string or []string per RFC 7519. + Exp int64 `json:"exp"` // the expiration time of the assertion (seconds since Unix epoch) + Iat int64 `json:"iat"` // the time the assertion was issued (seconds since Unix epoch) + Typ string `json:"typ,omitempty"` // token type (Optional). // Email for which the application is requesting delegated access (Optional). Sub string `json:"sub,omitempty"` diff --git a/jwt/jwt.go b/jwt/jwt.go index d2f9c2f18..9ac397405 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -64,8 +64,16 @@ type Config struct { // Audience optionally specifies the intended audience of the // request. If empty, the value of TokenURL is used as the // intended audience. + // Deprecated: Use Audiences for multiple audiences or for RFC 7519 compliance. Audience string + // Audiences optionally specifies the intended audiences of the + // request as a slice of strings. This field takes precedence over + // Audience if both are set. If empty, Audience or TokenURL is used. + // Per RFC 7519, when there's a single audience, it will be serialized + // as a string; when there are multiple audiences, as an array. + Audiences []string + // PrivateClaims optionally specifies custom private claims in the JWT. // See http://tools.ietf.org/html/draft-jones-json-web-token-10#section-4.3 PrivateClaims map[string]any @@ -117,9 +125,22 @@ func (js jwtSource) Token() (*oauth2.Token, error) { if t := js.conf.Expires; t > 0 { claimSet.Exp = time.Now().Add(t).Unix() } - if aud := js.conf.Audience; aud != "" { + + // Handle audience per RFC 7519: single string or array of strings + if len(js.conf.Audiences) > 0 { + // Use new Audiences field (takes precedence) + if len(js.conf.Audiences) == 1 { + // Single audience: use string per RFC 7519 + claimSet.Aud = js.conf.Audiences[0] + } else { + // Multiple audiences: use array per RFC 7519 + claimSet.Aud = js.conf.Audiences + } + } else if aud := js.conf.Audience; aud != "" { + // Use legacy Audience field for backward compatibility claimSet.Aud = aud } + // If neither is set, Aud remains as TokenURL (set above) h := *defaultHeader h.KeyID = js.conf.PrivateKeyID payload, err := jws.Encode(&h, claimSet, pk) diff --git a/jwt/jwt_test.go b/jwt/jwt_test.go index 5457d1223..1a32ffc65 100644 --- a/jwt/jwt_test.go +++ b/jwt/jwt_test.go @@ -232,6 +232,20 @@ func TestJWTFetch_AssertionPayload(t *testing.T) { "private1": "claim1", }, }, + { + Email: "aaa3@xxx.com", + PrivateKey: dummyPrivateKey, + PrivateKeyID: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + TokenURL: ts.URL, + Audiences: []string{"https://api.example.com"}, + }, + { + Email: "aaa4@xxx.com", + PrivateKey: dummyPrivateKey, + PrivateKeyID: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + TokenURL: ts.URL, + Audiences: []string{"https://api.example.com", "https://other.example.com"}, + }, } { t.Run(conf.Email, func(t *testing.T) { _, err := conf.TokenSource(context.Background()).Token() @@ -259,13 +273,27 @@ func TestJWTFetch_AssertionPayload(t *testing.T) { // Scope should NOT be in the JWT claim set according to RFC 7521 if claimSet.Scope != "" { t.Errorf("payload scope should be empty but got %q; scopes should be sent as request parameter", claimSet.Scope) + } // Check audience handling per RFC 7519 + var expectedAud interface{} + if len(conf.Audiences) > 0 { + if len(conf.Audiences) == 1 { + expectedAud = conf.Audiences[0] + } else { + // When JSON unmarshals an array, it becomes []interface{} + expectedAudSlice := make([]interface{}, len(conf.Audiences)) + for i, aud := range conf.Audiences { + expectedAudSlice[i] = aud + } + expectedAud = expectedAudSlice + } + } else if conf.Audience != "" { + expectedAud = conf.Audience + } else { + expectedAud = conf.TokenURL } - aud := conf.TokenURL - if conf.Audience != "" { - aud = conf.Audience - } - if got, want := claimSet.Aud, aud; got != want { - t.Errorf("payload audience = %q; want %q", got, want) + + if !reflect.DeepEqual(claimSet.Aud, expectedAud) { + t.Errorf("payload audience = %v (type %T); want %v (type %T)", claimSet.Aud, claimSet.Aud, expectedAud, expectedAud) } if got, want := claimSet.Sub, conf.Subject; got != want { t.Errorf("payload subject = %q; want %q", got, want) From e9d852aa47660c3cf44a3816d2c051539d27431f Mon Sep 17 00:00:00 2001 From: Maxim Darii Date: Fri, 4 Jul 2025 11:37:08 +0200 Subject: [PATCH 3/4] use array per RFC 7519 if Audiences provided --- jwt/jwt.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/jwt/jwt.go b/jwt/jwt.go index 9ac397405..aa54b0c65 100644 --- a/jwt/jwt.go +++ b/jwt/jwt.go @@ -128,14 +128,8 @@ func (js jwtSource) Token() (*oauth2.Token, error) { // Handle audience per RFC 7519: single string or array of strings if len(js.conf.Audiences) > 0 { - // Use new Audiences field (takes precedence) - if len(js.conf.Audiences) == 1 { - // Single audience: use string per RFC 7519 - claimSet.Aud = js.conf.Audiences[0] - } else { - // Multiple audiences: use array per RFC 7519 - claimSet.Aud = js.conf.Audiences - } + // Multiple audiences: use array per RFC 7519 + claimSet.Aud = js.conf.Audiences } else if aud := js.conf.Audience; aud != "" { // Use legacy Audience field for backward compatibility claimSet.Aud = aud From 4ef8b3a59f5b04b8e53bfc749ef888272fca97eb Mon Sep 17 00:00:00 2001 From: Maxim Darii Date: Fri, 4 Jul 2025 11:39:09 +0200 Subject: [PATCH 4/4] fix test --- jwt/jwt_test.go | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/jwt/jwt_test.go b/jwt/jwt_test.go index 1a32ffc65..7526be4a9 100644 --- a/jwt/jwt_test.go +++ b/jwt/jwt_test.go @@ -276,16 +276,12 @@ func TestJWTFetch_AssertionPayload(t *testing.T) { } // Check audience handling per RFC 7519 var expectedAud interface{} if len(conf.Audiences) > 0 { - if len(conf.Audiences) == 1 { - expectedAud = conf.Audiences[0] - } else { - // When JSON unmarshals an array, it becomes []interface{} - expectedAudSlice := make([]interface{}, len(conf.Audiences)) - for i, aud := range conf.Audiences { - expectedAudSlice[i] = aud - } - expectedAud = expectedAudSlice + // When JSON unmarshals an array, it becomes []interface{} + expectedAudSlice := make([]interface{}, len(conf.Audiences)) + for i, aud := range conf.Audiences { + expectedAudSlice[i] = aud } + expectedAud = expectedAudSlice } else if conf.Audience != "" { expectedAud = conf.Audience } else {