Skip to content

Commit

Permalink
Allow for multiple audiences
Browse files Browse the repository at this point in the history
  • Loading branch information
hdonCr committed Dec 24, 2024
1 parent bc8bdca commit ca00a1f
Show file tree
Hide file tree
Showing 3 changed files with 196 additions and 0 deletions.
73 changes: 73 additions & 0 deletions map_claims_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,79 @@ func TestVerifyAud(t *testing.T) {
}
}

func TestVerifyAuds(t *testing.T) {
var nilInterface interface{}
var nilListInterface []interface{}
var intListInterface interface{} = []int{1, 2, 3}

type test struct {
Name string
MapClaims MapClaims // MapClaims to validate
Expected bool // Whether the validation is expected to pass
Comparison []string // Cmp audience values

AllAudMatching bool // Whether to require all auds matching all cmps
Required bool // Whether the aud claim is required
}

tests := []test{
// Matching auds and cmps
{Name: "[]String aud with all expected cmps required and match required", MapClaims: MapClaims{"aud": []string{"example.com", "example.example.com"}}, Expected: true, Required: true, Comparison: []string{"example.com", "example.example.com"}, AllAudMatching: true},
{Name: "[]String aud with any expected cmps required and match required", MapClaims: MapClaims{"aud": []string{"example.com", "example.example.com"}}, Expected: true, Required: true, Comparison: []string{"example.com", "example.example.com"}, AllAudMatching: false},

// match single expected auds
{Name: "[]String aud with any expected cmps required and match not required, single claim aud", MapClaims: MapClaims{"aud": []string{"example.com"}}, Expected: true, Required: true, Comparison: []string{"example.com", "example.example.com"}, AllAudMatching: false},
{Name: "[]String aud with any expected cmps required and match not required, single expected aud ", MapClaims: MapClaims{"aud": []string{"example.com", "example.example.com"}}, Expected: true, Required: true, Comparison: []string{"example.com"}, AllAudMatching: false},

// Non-matching auds and cmps
// Required = true
{Name: "[]String aud with all expected cmps required and match not required, single claim aud", MapClaims: MapClaims{"aud": []string{"example.com"}}, Expected: false, Required: true, Comparison: []string{"example.com", "example.example.com"}, AllAudMatching: true},
{Name: "[]String aud with all expected cmps required and match not required, single expected aud ", MapClaims: MapClaims{"aud": []string{"example.com", "example.example.com"}}, Expected: false, Required: true, Comparison: []string{"example.com"}, AllAudMatching: true},
{Name: "[]String aud with all expected cmps required and match not required, different auds", MapClaims: MapClaims{"aud": []string{"example.example.com"}}, Expected: false, Required: true, Comparison: []string{"example.com"}, AllAudMatching: true},

{Name: "[]String aud with any expected cmps required and match not required, different auds", MapClaims: MapClaims{"aud": []string{"example.example.com"}}, Expected: false, Required: true, Comparison: []string{"example.com"}, AllAudMatching: false},

// Required = false
{Name: "[]String aud with all expected cmps required and match not required, single claim aud", MapClaims: MapClaims{"aud": []string{"example.com"}}, Expected: true, Required: false, Comparison: []string{"example.com", "example.example.com"}, AllAudMatching: true},
{Name: "[]String aud with all expected cmps required and match not required, single expected aud ", MapClaims: MapClaims{"aud": []string{"example.com", "example.example.com"}}, Expected: true, Required: false, Comparison: []string{"example.com"}, AllAudMatching: true},
{Name: "[]String aud with all expected cmps required and match not required, different auds", MapClaims: MapClaims{"aud": []string{"example.example.com"}}, Expected: true, Required: false, Comparison: []string{"example.com"}, AllAudMatching: true},

{Name: "[]String aud with any expected cmps required and match not required, single claim aud", MapClaims: MapClaims{"aud": []string{"example.com"}}, Expected: true, Required: false, Comparison: []string{"example.com", "example.example.com"}, AllAudMatching: false},
{Name: "[]String aud with any expected cmps required and match not required, single expected aud ", MapClaims: MapClaims{"aud": []string{"example.com", "example.example.com"}}, Expected: true, Required: false, Comparison: []string{"example.com"}, AllAudMatching: false},
{Name: "[]String aud with any expected cmps required and match not required, different auds", MapClaims: MapClaims{"aud": []string{"example.example.com"}}, Expected: true, Required: false, Comparison: []string{"example.com"}, AllAudMatching: false},

// Empty aud
{Name: "Empty aud, with all expected cmps required", MapClaims: MapClaims{"aud": []string{}}, Expected: false, Required: true, Comparison: []string{"example.com", "example.example.com"}, AllAudMatching: true},
{Name: "Empty aud, with any expected cmps required", MapClaims: MapClaims{"aud": []string{}}, Expected: false, Required: true, Comparison: []string{"example.com", "example.example.com"}, AllAudMatching: false},

// []interface{}
{Name: "Empty []interface{} Aud without match required", MapClaims: MapClaims{"aud": nilListInterface}, Expected: true, Required: false, Comparison: []string{"example.com", "example.example.com"}, AllAudMatching: true},
{Name: "[]interface{} Aud with match required", MapClaims: MapClaims{"aud": []interface{}{"a", "foo", "example.com"}}, Expected: true, Required: true, Comparison: []string{"a", "foo", "example.com"}, AllAudMatching: true},
{Name: "[]interface{} Aud with match but invalid types", MapClaims: MapClaims{"aud": []interface{}{"a", 5, "example.com"}}, Expected: false, Required: true, Comparison: []string{"example.com", "example.example.com"}, AllAudMatching: true},
{Name: "[]interface{} Aud int with match required", MapClaims: MapClaims{"aud": intListInterface}, Expected: false, Required: true, Comparison: []string{"example.com", "example.example.com"}, AllAudMatching: true},

// interface{}
{Name: "Empty interface{} Aud without match not required", MapClaims: MapClaims{"aud": nilInterface}, Expected: true, Required: false, Comparison: []string{"example.com", "example.example.com"}, AllAudMatching: true},
}

for _, test := range tests {
t.Run(test.Name, func(t *testing.T) {
var opts []ParserOption

if test.Required {
opts = append(opts, WithAudiences(test.Comparison, test.AllAudMatching))
}

validator := NewValidator(opts...)
got := validator.Validate(test.MapClaims)

if (got == nil) != test.Expected {
t.Errorf("Expected %v, got %v", test.Expected, (got == nil))
}
})
}
}

func TestMapclaimsVerifyIssuedAtInvalidTypeString(t *testing.T) {
mapClaims := MapClaims{
"iat": "foo",
Expand Down
13 changes: 13 additions & 0 deletions parser_option.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ func WithAudience(aud string) ParserOption {
}
}

// WithAudiences configures the validator to require the specified audiences in
// the `auds` claim. Validation will fail if the audience is not listed in the
// token or the `aud` claim is missing.
//
// matchAll is a boolean flag that determines if all expected audiences must be present in the token.
// If matchAll is true, the token must contain all expected audiences. If matchAll is false, the token must contain at least one of the expected audiences.
func WithAudiences(auds []string, matchAll bool) ParserOption {
return func(p *Parser) {
p.validator.expectedAuds = auds
p.validator.expectedAudsMatchAll = matchAll
}
}

// WithIssuer configures the validator to require the specified issuer in the
// `iss` claim. Validation will fail if a different issuer is specified in the
// token or the `iss` claim is missing.
Expand Down
110 changes: 110 additions & 0 deletions validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ type Validator struct {
// string will disable aud checking.
expectedAud string

//expectedAuds contains the audiences this token expects. Supplying an empty
// []string will disable auds checking.
expectedAuds []string

// expectedAudsMatchAll specifies whether all expected audiences must match all auds from claim
expectedAudsMatchAll bool

// expectedIss contains the issuer this token expects. Supplying an empty
// string will disable iss checking.
expectedIss string
Expand Down Expand Up @@ -126,6 +133,13 @@ func (v *Validator) Validate(claims Claims) error {
}
}

// If we have expected audiences, we also require the audiences claim
if len(v.expectedAuds) != 0 {
if err := v.verifyAudiences(claims, v.expectedAuds, true, v.expectedAudsMatchAll); err != nil {
errs = append(errs, err)
}
}

// If we have an expected issuer, we also require the issuer claim
if v.expectedIss != "" {
if err = v.verifyIssuer(claims, v.expectedIss, true); err != nil {
Expand Down Expand Up @@ -255,6 +269,102 @@ func (v *Validator) verifyAudience(claims Claims, cmp string, required bool) err
return errorIfFalse(result, ErrTokenInvalidAudience)
}

// verifyAudiences compares the aud claim against cmps.
// If matchAllAuds is true, all cmps must match a aud.
// If matchAllAuds is false, at least one cmp must match a aud.
//
// If matchAllAuds is true and aud length does not match cmps length, an ErrTokenInvalidAudience error will be returned.
// Note that this does not account for any duplicate aud or cmps
//
// If aud is not set or an empty list, it will succeed if the claim is not required,
// otherwise ErrTokenRequiredClaimMissing will be returned.
//
// Additionally, if any error occurs while retrieving the claim, e.g., when its
// the wrong type, an ErrTokenUnverifiable error will be returned.
func (v *Validator) verifyAudiences(claims Claims, cmps []string, required bool, matchAllAuds bool) error {

aud, err := claims.GetAudience()
if err != nil {
return err
}

if len(aud) == 0 {
return errorIfRequired(required, "aud")
}

var stringClaims string

// If matchAllAuds is true, check if all the cmps matches any of the aud
if matchAllAuds {

// cmps and aud length should match if matchAllAuds is true
// Note that this does not account for possible duplicates
if len(cmps) != len(aud) {
return errorIfFalse(false, ErrTokenInvalidAudience)
}

// Check all cmps values
for _, cmp := range cmps {
matchFound := false
for _, a := range aud {

// Perform constant time comparison
result := subtle.ConstantTimeCompare([]byte(a), []byte(cmp)) != 0

stringClaims = stringClaims + a

// If a match is found, set matchFound to true and break out of inner aud loop and continue to next cmp
if result {
matchFound = true
break
}
}

// If no match was found for the current cmp, return a ErrTokenInvalidAudience error
if !matchFound {
return ErrTokenInvalidAudience
}
}

} else {
// if matchAllAuds is false, check if any of the cmps matches any of the aud

matchFound := false

// Label to break out of both loops if a match is found
outer:

// Check all aud values
for _, a := range aud {
for _, cmp := range cmps {

// Perform constant time comparison
result := subtle.ConstantTimeCompare([]byte(a), []byte(cmp)) != 0

stringClaims = stringClaims + a

// If a match is found, break out of both loops and finish comparison
if result {
matchFound = true
break outer
}
}
}

// If no match was found for any cmp, return an error
if !matchFound {
return errorIfFalse(false, ErrTokenInvalidAudience)
}
}

// case where "" is sent in one or many aud claims
if stringClaims == "" {
return errorIfRequired(required, "aud")
}

return nil
}

// verifyIssuer compares the iss claim in claims against cmp.
//
// If iss is not set, it will succeed if the claim is not required,
Expand Down

0 comments on commit ca00a1f

Please # to comment.