From 1e2649d7ea1f663cb46620a1fb0a21587a99f0a5 Mon Sep 17 00:00:00 2001 From: jaideeppyne Date: Wed, 19 Aug 2026 18:29:09 +0530 Subject: [PATCH] Reject non-string/array audience instead of silently discarding it (#6) Audience.UnmarshalJSON only handled the '"' (string) and '[' (array) cases. Any other JSON type for "aud" -- a number, boolean, or object -- matched no case, left err == nil, and returned with the audience silently set to nil. That is more permissive than encoding/json (which rejects those when decoding into []string) and drops a security-relevant claim on the floor: a token whose "aud" is a number or object parses as if it had no audience at all. Reject unexpected JSON types with an ErrTokenForm error, treat JSON null as an absent audience, and stop assigning the array result when its unmarshal failed. Adds regression tests for the rejected types and for the still-accepted string, array, null and omitted forms. --- claims.go | 7 ++++++- claims_time_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/claims.go b/claims.go index 5b11f31..8f97ba9 100644 --- a/claims.go +++ b/claims.go @@ -348,7 +348,12 @@ func (aud *Audience) UnmarshalJSON(data []byte) (err error) { case '[': // it's an array of strings. var audStrings []string err = json.Unmarshal(data, &audStrings) - *aud = audStrings + if err == nil { + *aud = audStrings + } + case 'n': // it's null, treat as an absent audience. + default: // any other JSON type (number, boolean, object) is not a valid audience. + err = fmt.Errorf("%w: aud: must be a string or an array of strings", ErrTokenForm) } } diff --git a/claims_time_test.go b/claims_time_test.go index c41d97f..9ed3d98 100644 --- a/claims_time_test.go +++ b/claims_time_test.go @@ -1,6 +1,7 @@ package jwt import ( + "encoding/json" "errors" "strings" "testing" @@ -126,6 +127,54 @@ func TestRequireExpiry(t *testing.T) { } } +func TestAudienceRejectsInvalidTypes(t *testing.T) { + // Per RFC 7519 ยง4.1.3 the "aud" claim is a string or an array of strings. + // A number, boolean or object used to match no case in Audience.UnmarshalJSON + // and was accepted silently with the audience discarded -- more permissive than + // encoding/json, dropping a security-relevant claim on the floor. See issue #6. + for _, payload := range []string{ + `{"aud":1234}`, + `{"aud":true}`, + `{"aud":{"x":1}}`, + } { + var claims Claims + err := json.Unmarshal([]byte(payload), &claims) + if err == nil { + t.Fatalf("%s: expected an error but it parsed with audience %#v", payload, claims.Audience) + } + if !errors.Is(err, ErrTokenForm) { + t.Fatalf("%s: expected ErrTokenForm but got: %v", payload, err) + } + } +} + +func TestAudienceValidFormsStillAccepted(t *testing.T) { + for _, tc := range []struct { + payload string + want Audience + }{ + {`{"aud":"api"}`, Audience{"api"}}, + {`{"aud":["api","web"]}`, Audience{"api", "web"}}, + {`{"aud":null}`, nil}, // explicit null -> absent audience + {`{"sub":"user"}`, nil}, // omitted -> absent audience + } { + var claims Claims + if err := json.Unmarshal([]byte(tc.payload), &claims); err != nil { + t.Fatalf("%s: unexpected error: %v", tc.payload, err) + } + + got := claims.Audience + if len(got) != len(tc.want) { + t.Fatalf("%s: expected audience %#v but got %#v", tc.payload, tc.want, got) + } + for i := range tc.want { + if got[i] != tc.want[i] { + t.Fatalf("%s: expected audience %#v but got %#v", tc.payload, tc.want, got) + } + } + } +} + func TestAudienceContains(t *testing.T) { aud := Audience{"api.example.com", "web.example.com"}