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 38a92daca..aa54b0c65 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 @@ -105,7 +113,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, } @@ -118,9 +125,16 @@ 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 { + // 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) @@ -130,6 +144,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..7526be4a9 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() @@ -256,15 +270,26 @@ 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) - } - aud := conf.TokenURL - if conf.Audience != "" { - aud = conf.Audience + // 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 { + // 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 } - 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) @@ -316,3 +341,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) + } + }) + } +}