Skip to content

Commit be711be

Browse files
mdariiMaxim Darii
authored andcommitted
add support for multiple audiences as described in RFC 7519
1 parent 5a6f7b3 commit be711be

File tree

3 files changed

+62
-13
lines changed

3 files changed

+62
-13
lines changed

jws/jws.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,12 @@ import (
3232
// permissions being requested (scopes), the target of the token, the issuer,
3333
// the time the token was issued, and the lifetime of the token.
3434
type ClaimSet struct {
35-
Iss string `json:"iss"` // email address of the client_id of the application making the access token request
36-
Scope string `json:"scope,omitempty"` // space-delimited list of the permissions the application requests
37-
Aud string `json:"aud"` // descriptor of the intended target of the assertion (Optional).
38-
Exp int64 `json:"exp"` // the expiration time of the assertion (seconds since Unix epoch)
39-
Iat int64 `json:"iat"` // the time the assertion was issued (seconds since Unix epoch)
40-
Typ string `json:"typ,omitempty"` // token type (Optional).
35+
Iss string `json:"iss"` // email address of the client_id of the application making the access token request
36+
Scope string `json:"scope,omitempty"` // space-delimited list of the permissions the application requests
37+
Aud interface{} `json:"aud"` // descriptor of the intended target of the assertion. Can be string or []string per RFC 7519.
38+
Exp int64 `json:"exp"` // the expiration time of the assertion (seconds since Unix epoch)
39+
Iat int64 `json:"iat"` // the time the assertion was issued (seconds since Unix epoch)
40+
Typ string `json:"typ,omitempty"` // token type (Optional).
4141

4242
// Email for which the application is requesting delegated access (Optional).
4343
Sub string `json:"sub,omitempty"`

jwt/jwt.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,16 @@ type Config struct {
6464
// Audience optionally specifies the intended audience of the
6565
// request. If empty, the value of TokenURL is used as the
6666
// intended audience.
67+
// Deprecated: Use Audiences for multiple audiences or for RFC 7519 compliance.
6768
Audience string
6869

70+
// Audiences optionally specifies the intended audiences of the
71+
// request as a slice of strings. This field takes precedence over
72+
// Audience if both are set. If empty, Audience or TokenURL is used.
73+
// Per RFC 7519, when there's a single audience, it will be serialized
74+
// as a string; when there are multiple audiences, as an array.
75+
Audiences []string
76+
6977
// PrivateClaims optionally specifies custom private claims in the JWT.
7078
// See http://tools.ietf.org/html/draft-jones-json-web-token-10#section-4.3
7179
PrivateClaims map[string]any
@@ -117,9 +125,22 @@ func (js jwtSource) Token() (*oauth2.Token, error) {
117125
if t := js.conf.Expires; t > 0 {
118126
claimSet.Exp = time.Now().Add(t).Unix()
119127
}
120-
if aud := js.conf.Audience; aud != "" {
128+
129+
// Handle audience per RFC 7519: single string or array of strings
130+
if len(js.conf.Audiences) > 0 {
131+
// Use new Audiences field (takes precedence)
132+
if len(js.conf.Audiences) == 1 {
133+
// Single audience: use string per RFC 7519
134+
claimSet.Aud = js.conf.Audiences[0]
135+
} else {
136+
// Multiple audiences: use array per RFC 7519
137+
claimSet.Aud = js.conf.Audiences
138+
}
139+
} else if aud := js.conf.Audience; aud != "" {
140+
// Use legacy Audience field for backward compatibility
121141
claimSet.Aud = aud
122142
}
143+
// If neither is set, Aud remains as TokenURL (set above)
123144
h := *defaultHeader
124145
h.KeyID = js.conf.PrivateKeyID
125146
payload, err := jws.Encode(&h, claimSet, pk)

jwt/jwt_test.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,20 @@ func TestJWTFetch_AssertionPayload(t *testing.T) {
232232
"private1": "claim1",
233233
},
234234
},
235+
{
236+
237+
PrivateKey: dummyPrivateKey,
238+
PrivateKeyID: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
239+
TokenURL: ts.URL,
240+
Audiences: []string{"https://api.example.com"},
241+
},
242+
{
243+
244+
PrivateKey: dummyPrivateKey,
245+
PrivateKeyID: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
246+
TokenURL: ts.URL,
247+
Audiences: []string{"https://api.example.com", "https://other.example.com"},
248+
},
235249
} {
236250
t.Run(conf.Email, func(t *testing.T) {
237251
_, err := conf.TokenSource(context.Background()).Token()
@@ -259,13 +273,27 @@ func TestJWTFetch_AssertionPayload(t *testing.T) {
259273
// Scope should NOT be in the JWT claim set according to RFC 7521
260274
if claimSet.Scope != "" {
261275
t.Errorf("payload scope should be empty but got %q; scopes should be sent as request parameter", claimSet.Scope)
276+
} // Check audience handling per RFC 7519
277+
var expectedAud interface{}
278+
if len(conf.Audiences) > 0 {
279+
if len(conf.Audiences) == 1 {
280+
expectedAud = conf.Audiences[0]
281+
} else {
282+
// When JSON unmarshals an array, it becomes []interface{}
283+
expectedAudSlice := make([]interface{}, len(conf.Audiences))
284+
for i, aud := range conf.Audiences {
285+
expectedAudSlice[i] = aud
286+
}
287+
expectedAud = expectedAudSlice
288+
}
289+
} else if conf.Audience != "" {
290+
expectedAud = conf.Audience
291+
} else {
292+
expectedAud = conf.TokenURL
262293
}
263-
aud := conf.TokenURL
264-
if conf.Audience != "" {
265-
aud = conf.Audience
266-
}
267-
if got, want := claimSet.Aud, aud; got != want {
268-
t.Errorf("payload audience = %q; want %q", got, want)
294+
295+
if !reflect.DeepEqual(claimSet.Aud, expectedAud) {
296+
t.Errorf("payload audience = %v (type %T); want %v (type %T)", claimSet.Aud, claimSet.Aud, expectedAud, expectedAud)
269297
}
270298
if got, want := claimSet.Sub, conf.Subject; got != want {
271299
t.Errorf("payload subject = %q; want %q", got, want)

0 commit comments

Comments
 (0)