From ca00a1fce38118cc3d49fda77fcd46807e64b17d Mon Sep 17 00:00:00 2001 From: hdonCr Date: Tue, 24 Dec 2024 14:52:15 +0100 Subject: [PATCH] Allow for multiple audiences --- map_claims_test.go | 73 ++++++++++++++++++++++++++++++ parser_option.go | 13 ++++++ validator.go | 110 +++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) diff --git a/map_claims_test.go b/map_claims_test.go index 034173d2..ee9e7eaf 100644 --- a/map_claims_test.go +++ b/map_claims_test.go @@ -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", diff --git a/parser_option.go b/parser_option.go index 88a780fb..bbfde46b 100644 --- a/parser_option.go +++ b/parser_option.go @@ -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. diff --git a/validator.go b/validator.go index 008ecd87..df08c01c 100644 --- a/validator.go +++ b/validator.go @@ -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 @@ -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 { @@ -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,