Skip to content
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

HdonCr/Allow multiple audiences #427

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
70 changes: 38 additions & 32 deletions map_claims_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,61 +5,67 @@ import (
"time"
)

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
Expected bool
Comparison string
Required bool
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 Claim in aud
// Required = true
{Name: "String Aud matching required", MapClaims: MapClaims{"aud": "example.com"}, Expected: true, Required: true, Comparison: "example.com"},
{Name: "[]String Aud with match required", MapClaims: MapClaims{"aud": []string{"example.com", "example.example.com"}}, Expected: true, Required: true, Comparison: "example.com"},

// Required = false
{Name: "String Aud with match not required", MapClaims: MapClaims{"aud": "example.com"}, Expected: true, Required: false, Comparison: "example.com"},
{Name: "Empty String Aud with match not required", MapClaims: MapClaims{}, Expected: true, Required: false, Comparison: "example.com"},
{Name: "Empty String Aud with match not required", MapClaims: MapClaims{"aud": ""}, Expected: true, Required: false, Comparison: "example.com"},
{Name: "Nil String Aud with match not required", MapClaims: MapClaims{"aud": nil}, Expected: true, Required: false, Comparison: "example.com"},
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},

{Name: "[]String Aud with match not required", MapClaims: MapClaims{"aud": []string{"example.com", "example.example.com"}}, Expected: true, Required: false, Comparison: "example.com"},
{Name: "Empty []String Aud with match not required", MapClaims: MapClaims{"aud": []string{}}, Expected: true, Required: false, Comparison: "example.com"},
// 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 Claim in aud
// Non-matching auds and cmps
// Required = true
{Name: "String Aud without match required", MapClaims: MapClaims{"aud": "not.example.com"}, Expected: false, Required: true, Comparison: "example.com"},
{Name: "Empty String Aud without match required", MapClaims: MapClaims{"aud": ""}, Expected: false, Required: true, Comparison: "example.com"},
{Name: "[]String Aud without match required", MapClaims: MapClaims{"aud": []string{"not.example.com", "example.example.com"}}, Expected: false, Required: true, Comparison: "example.com"},
{Name: "Empty []String Aud without match required", MapClaims: MapClaims{"aud": []string{""}}, Expected: false, Required: true, Comparison: "example.com"},
{Name: "String Aud without match not required", MapClaims: MapClaims{"aud": "not.example.com"}, Expected: false, Required: true, Comparison: "example.com"},
{Name: "Empty String Aud without match not required", MapClaims: MapClaims{"aud": ""}, Expected: false, Required: true, Comparison: "example.com"},
{Name: "[]String Aud without match not required", MapClaims: MapClaims{"aud": []string{"not.example.com", "example.example.com"}}, Expected: false, Required: true, Comparison: "example.com"},
{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: "Empty []String Aud without match required", MapClaims: MapClaims{"aud": []string{""}}, Expected: true, Required: false, Comparison: "example.com"},
{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: "example.com"},
{Name: "[]interface{} Aud with match required", MapClaims: MapClaims{"aud": []interface{}{"a", "foo", "example.com"}}, Expected: true, Required: true, Comparison: "example.com"},
{Name: "[]interface{} Aud with match but invalid types", MapClaims: MapClaims{"aud": []interface{}{"a", 5, "example.com"}}, Expected: false, Required: true, Comparison: "example.com"},
{Name: "[]interface{} Aud int with match required", MapClaims: MapClaims{"aud": intListInterface}, Expected: false, Required: true, Comparison: "example.com"},
{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: "example.com"},
{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, WithAudience(test.Comparison))
opts = append(opts, WithAudiences(test.Comparison, test.AllAudMatching))
}

validator := NewValidator(opts...)
Expand Down
12 changes: 8 additions & 4 deletions parser_option.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,17 +66,21 @@ func WithExpirationRequired() ParserOption {
}
}

// WithAudience configures the validator to require the specified audience in
// the `aud` claim. Validation will fail if the audience is not listed in the
// 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.
//
// NOTE: While the `aud` claim is OPTIONAL in a JWT, the handling of it is
// application-specific. Since this validation API is helping developers in
// writing secure application, we decided to REQUIRE the existence of the claim,
// if an audience is expected.
func WithAudience(aud string) ParserOption {
func WithAudiences(auds []string, matchAll bool) ParserOption {
return func(p *Parser) {
p.validator.expectedAud = aud
p.validator.expectedAuds = auds
p.validator.matchAllAud = matchAll
}
}

Expand Down
117 changes: 102 additions & 15 deletions validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,12 @@ type Validator struct {
// unrealistic, i.e., in the future.
verifyIat bool

// expectedAud contains the audience this token expects. Supplying an empty
// string will disable aud checking.
expectedAud string
//expectedAuds contains the audiences this token expects. Supplying an empty
// []string will disable auds checking.
expectedAuds []string
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether we could merge this with the expectedAud above

Copy link
Author

@hdonCr hdonCr Feb 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


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

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

// If we have an expected audience, we also require the audience claim
if v.expectedAud != "" {
if err = v.verifyAudience(claims, v.expectedAud, true); err != nil {
// 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.matchAllAud); err != nil {
errs = append(errs, err)
}
}
Expand Down Expand Up @@ -219,40 +222,124 @@ func (v *Validator) verifyNotBefore(claims Claims, cmp time.Time, required bool)
return errorIfFalse(!cmp.Before(nbf.Add(-v.leeway)), ErrTokenNotValidYet)
}

// verifyAudience compares the aud claim against cmp.
// / 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) verifyAudience(claims Claims, cmp string, required bool) error {
func (v *Validator) verifyAudiences(claims Claims, cmps []string, required bool, matchAllAuds bool) error {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function seems to be very complex, I wonder if we could streamline this implementation a little bit. Furthermore, it should be possible to include the previous implementation of verifyAudience in this as well - or rather merge the two since one is just a special case of the other.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. I would propose to remove the validation for single audiences, since the verifyAudiences already supports validating a single audience by passing a slice with a single element.

I have removed the expectedAud field and verifyAudiences functions and everything related (including tests) to merge verifying one or multiple audiences. Additionally, I have added deduplication for auds and cmps and added documentation.

Note that this is a breaking change.


// Get the audience claim(s) from the token
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@oxisto, regarding your first comment here: https://github.com/golang-jwt/jwt/pull/427/files#r1917080944

I agree that this function is quite complex. I think this is the result of the option to match all auds. Deduplicating both situations is difficult, making the function very long due to two separate flows.

Additionally, the situation in which at least one cmp must match an aud is relatively simple. However, all cmps matching all auds is more difficult to implement.

I have added documentation in an attempt to make the function more legible. If required, I can look into simplifying it further.

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

// If no audience is provided, return an error if required
if len(aud) == 0 {
return errorIfRequired(required, "aud")
}

// use a var here to keep constant time compare when looping over a number of claims
result := false
// Deduplicate the aud and cmps slices
aud = deduplicateStrings(aud)
cmps = deduplicateStrings(cmps)

var stringClaims string
for _, a := range aud {
if subtle.ConstantTimeCompare([]byte(a), []byte(cmp)) != 0 {
result = true

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@oxisto, I am not sure whether this verifyAudiences function is constante time safe. Could you clarify how relevant that is? If so, I can add testing and implement this if not.


// cmps and aud length should match if matchAllAuds is true
if len(cmps) != len(aud) {
return errorIfFalse(false, ErrTokenInvalidAudience)
}

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

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

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

quite a large duplicate code fragment, this is essentially what the existing verifyAudience does. why not use it?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// Concatenate all aud values to stringClaims
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 {

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

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

see above

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// Concatenate all aud values to stringClaims
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)
}
stringClaims = stringClaims + a
}

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

return errorIfFalse(result, ErrTokenInvalidAudience)
return nil
}

// deduplicateStrings removes duplicate elements from a string slice
func deduplicateStrings(slice []string) []string {
unique := make(map[string]bool)
var result []string
for _, item := range slice {
if _, found := unique[item]; !found {
unique[item] = true
result = append(result, item)
}
}
return result
}

// verifyIssuer compares the iss claim in claims against cmp.
Expand Down
66 changes: 54 additions & 12 deletions validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"testing"
"time"
"slices"
)

var ErrFooBar = errors.New("must be foobar")
Expand All @@ -22,12 +23,14 @@ func (m MyCustomClaims) Validate() error {

func Test_Validator_Validate(t *testing.T) {
type fields struct {
leeway time.Duration
timeFunc func() time.Time
verifyIat bool
expectedAud string
expectedIss string
expectedSub string
leeway time.Duration
timeFunc func() time.Time
verifyIat bool
expectedAud string
expectedAuds []string
matchAllAud bool
expectedIss string
expectedSub string
}
type args struct {
claims Claims
Expand Down Expand Up @@ -72,12 +75,13 @@ func Test_Validator_Validate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := &Validator{
leeway: tt.fields.leeway,
timeFunc: tt.fields.timeFunc,
verifyIat: tt.fields.verifyIat,
expectedAud: tt.fields.expectedAud,
expectedIss: tt.fields.expectedIss,
expectedSub: tt.fields.expectedSub,
leeway: tt.fields.leeway,
timeFunc: tt.fields.timeFunc,
verifyIat: tt.fields.verifyIat,
expectedAuds: tt.fields.expectedAuds,
matchAllAud: tt.fields.matchAllAud,
expectedIss: tt.fields.expectedIss,
expectedSub: tt.fields.expectedSub,
}
if err := v.Validate(tt.args.claims); (err != nil) && !errors.Is(err, tt.wantErr) {
t.Errorf("validator.Validate() error = %v, wantErr %v", err, tt.wantErr)
Expand Down Expand Up @@ -259,3 +263,41 @@ func Test_Validator_verifyIssuedAt(t *testing.T) {
})
}
}

func Test_deduplicateStrings(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
}{
{
name: "With duplicates",
input: []string{"a", "b", "a", "c"},
expected: []string{"a", "b", "c"},
},
{
name: "Without duplicates",
input: []string{"a", "b", "c"},
expected: []string{"a", "b", "c"},
},
{
name: "Empty slice",
input: []string{},
expected: []string{},
},
{
name: "All duplicates",
input: []string{"a", "a", "a"},
expected: []string{"a"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
deduplicated := deduplicateStrings(tt.input)
if !slices.Equal(deduplicated, tt.expected) {
t.Errorf("deduplicateStrings() = %v, want %v", deduplicated, tt.expected)
}
})
}
}