Skip to content

jwt: move the scope from assertion payload to the request parameter as in RFC7521 #781

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

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
12 changes: 6 additions & 6 deletions jws/jws.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
21 changes: 19 additions & 2 deletions jwt/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand All @@ -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)
Expand All @@ -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)
Expand Down
100 changes: 92 additions & 8 deletions jwt/jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,20 @@ func TestJWTFetch_AssertionPayload(t *testing.T) {
"private1": "claim1",
},
},
{
Email: "[email protected]",
PrivateKey: dummyPrivateKey,
PrivateKeyID: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
TokenURL: ts.URL,
Audiences: []string{"https://api.example.com"},
},
{
Email: "[email protected]",
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()
Expand All @@ -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)
Expand Down Expand Up @@ -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: "[email protected]",
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)
}
})
}
}