Skip to content

Commit 73968b1

Browse files
committed
identity: validate every audience in multi-audience tokens
VerifyJWT only inspected aud[0] when the claim was an array, and since verification runs with SkipClientIDCheck this manual check is the only audience gate: a token with aud ["other-api", "sam-mesh-audience"] was rejected even though it names an allowed audience. Accept a token when any of its audiences is allowed, and report all of them when none is.
1 parent a8d5abb commit 73968b1

2 files changed

Lines changed: 50 additions & 12 deletions

File tree

internal/identity/oidc.go

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,31 +44,36 @@ func VerifyJWT(ctx context.Context, jwtStr string, allowedAudiences []string, pr
4444
}
4545
iss, _ := claims["iss"].(string)
4646

47-
// 2. Extract the audience
48-
var aud string
47+
// 2. Extract all audiences; the aud claim may be a string or an array.
48+
var auds []string
4949
switch a := claims["aud"].(type) {
5050
case string:
51-
aud = a
51+
auds = []string{a}
5252
case []any:
53-
if len(a) > 0 {
54-
aud, _ = a[0].(string)
53+
for _, v := range a {
54+
if s, ok := v.(string); ok {
55+
auds = append(auds, s)
56+
}
5557
}
5658
}
5759

58-
if aud == "" {
60+
if len(auds) == 0 {
5961
return nil, nil, fmt.Errorf("missing aud claim")
6062
}
6163

62-
// 3. Verify the audience matches one of your expected tenants/platforms
64+
// 3. Accept if any audience matches an allowed one: a multi-audience token
65+
// only needs to be intended for us, whatever else it names.
6366
validAudience := false
64-
for _, allowed := range allowedAudiences {
65-
if aud == allowed {
66-
validAudience = true
67-
break
67+
for _, aud := range auds {
68+
for _, allowed := range allowedAudiences {
69+
if aud == allowed {
70+
validAudience = true
71+
break
72+
}
6873
}
6974
}
7075
if !validAudience {
71-
return nil, nil, fmt.Errorf("untrusted audience: %s", aud)
76+
return nil, nil, fmt.Errorf("untrusted audience(s): %s", strings.Join(auds, ", "))
7277
}
7378

7479
// 4. Route to the correct provider

internal/identity/oidc_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,39 @@ func TestVerifyJWT(t *testing.T) {
157157
}
158158
})
159159

160+
t.Run("allowed audience in any array position succeeds", func(t *testing.T) {
161+
claims := validClaims()
162+
claims["aud"] = []string{"some-other-audience", "sam-mesh-audience"}
163+
tokenStr := signToken(t, key, testKID, claims)
164+
165+
_, _, err := VerifyJWT(ctx, tokenStr, allowedAudiences, providers)
166+
if err != nil {
167+
t.Fatalf("expected success for multi-audience token, got: %v", err)
168+
}
169+
})
170+
171+
t.Run("audience array with no allowed entry is rejected", func(t *testing.T) {
172+
claims := validClaims()
173+
claims["aud"] = []string{"some-other-audience", "yet-another-audience"}
174+
tokenStr := signToken(t, key, testKID, claims)
175+
176+
_, _, err := VerifyJWT(ctx, tokenStr, allowedAudiences, providers)
177+
if err == nil {
178+
t.Fatal("expected error for audience array with no allowed entry")
179+
}
180+
})
181+
182+
t.Run("empty audience array is rejected", func(t *testing.T) {
183+
claims := validClaims()
184+
claims["aud"] = []string{}
185+
tokenStr := signToken(t, key, testKID, claims)
186+
187+
_, _, err := VerifyJWT(ctx, tokenStr, allowedAudiences, providers)
188+
if err == nil {
189+
t.Fatal("expected error for empty audience array")
190+
}
191+
})
192+
160193
t.Run("unknown issuer is rejected", func(t *testing.T) {
161194
claims := validClaims()
162195
claims["iss"] = "https://unknown-issuer.example"

0 commit comments

Comments
 (0)