fix(jwt): accept JSON array for SpaceDelimited scope claim

Some issuers (e.g. PaperOS) emit `scope` as a JSON array (`[]` or
`["openid","profile"]`) instead of the RFC 6749 space-delimited string.
SpaceDelimited.UnmarshalJSON now accepts both forms; a JSON array
is converted to the equivalent slice. Other non-string, non-array
values still return an error.

Adds test cases: array_values and array_empty.
This commit is contained in:
AJ ONeal 2026-03-26 16:58:37 -06:00
parent 690cf90d67
commit 2a9cec75ef
No known key found for this signature in database
2 changed files with 37 additions and 6 deletions

View File

@ -220,6 +220,23 @@ func TestCov_SpaceDelimited_UnmarshalJSON(t *testing.T) {
t.Fatalf("expected empty, got %v", s) t.Fatalf("expected empty, got %v", s)
} }
}) })
t.Run("array_values", func(t *testing.T) {
var s SpaceDelimited
json.Unmarshal([]byte(`["openid","profile"]`), &s)
if len(s) != 2 || s[0] != "openid" || s[1] != "profile" {
t.Fatalf("got %v", s)
}
})
t.Run("array_empty", func(t *testing.T) {
var s SpaceDelimited
json.Unmarshal([]byte(`[]`), &s)
if s == nil {
t.Fatal("expected non-nil empty SpaceDelimited, got nil")
}
if len(s) != 0 {
t.Fatalf("expected empty, got %v", s)
}
})
t.Run("invalid", func(t *testing.T) { t.Run("invalid", func(t *testing.T) {
var s SpaceDelimited var s SpaceDelimited
if err := json.Unmarshal([]byte(`123`), &s); err == nil { if err := json.Unmarshal([]byte(`123`), &s); err == nil {

View File

@ -89,11 +89,12 @@ type SpaceDelimited []string
// UnmarshalJSON decodes a space-separated string into a slice. // UnmarshalJSON decodes a space-separated string into a slice.
// An empty string "" unmarshals to a non-nil empty SpaceDelimited{}, // An empty string "" unmarshals to a non-nil empty SpaceDelimited{},
// preserving the distinction from a nil (absent) SpaceDelimited. // preserving the distinction from a nil (absent) SpaceDelimited.
//
// As a compatibility extension, it also accepts a JSON array of strings,
// because some issuers (e.g. PaperOS) emit scope as [] instead of "".
func (s *SpaceDelimited) UnmarshalJSON(data []byte) error { func (s *SpaceDelimited) UnmarshalJSON(data []byte) error {
var str string var str string
if err := json.Unmarshal(data, &str); err != nil { if err := json.Unmarshal(data, &str); err == nil {
return fmt.Errorf("space-delimited must be a string: %w: %w", ErrInvalidPayload, err)
}
if str == "" { if str == "" {
*s = SpaceDelimited{} *s = SpaceDelimited{}
return nil return nil
@ -102,6 +103,19 @@ func (s *SpaceDelimited) UnmarshalJSON(data []byte) error {
return nil return nil
} }
// Fallback: accept a JSON array of strings (non-standard but used in the wild).
var ss []string
if err := json.Unmarshal(data, &ss); err != nil {
return fmt.Errorf("space-delimited must be a string or array of strings: %w: %w", ErrInvalidPayload, err)
}
if ss == nil {
*s = SpaceDelimited{}
} else {
*s = SpaceDelimited(ss)
}
return nil
}
// IsZero reports whether the slice is absent (nil). // IsZero reports whether the slice is absent (nil).
// Used by encoding/json with the omitzero tag option to omit the field // Used by encoding/json with the omitzero tag option to omit the field
// when it is nil, while still marshaling a non-nil empty SpaceDelimited as "". // when it is nil, while still marshaling a non-nil empty SpaceDelimited as "".