oidc: handle groups claim as string or array (FlexibleStringSlice)

Some OIDC providers (notably JumpCloud) return the `groups` claim as
a plain string when the user belongs to a single group, rather than
a single-element array:

  Single group:    {"groups": "MyGroup"}
  Multiple groups: {"groups": ["Group1", "Group2"]}

This causes `json.Unmarshal` to fail with:

  cannot unmarshal string into Go struct field OIDCClaims.groups of type []string

This is the same class of issue as juanfont#2293 (FlexibleBoolean for
email_verified). The fix follows the same pattern: introduce a
FlexibleStringSlice type with a custom UnmarshalJSON that accepts
both a string and a []string, and use it for the Groups field in
both OIDCClaims and OIDCUserInfo.
This commit is contained in:
primewildy 2026-05-04 14:26:53 +01:00 committed by GitHub
parent 76ee29352b
commit 3d0f597b23
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 83 additions and 6 deletions

View File

@ -24,6 +24,9 @@ import (
// ErrCannotParseBoolean is returned when a value cannot be parsed as boolean.
var ErrCannotParseBoolean = errors.New("cannot parse value as boolean")
// ErrCannotParseStringSlice is returned when a value cannot be parsed as string or []string.
var ErrCannotParseStringSlice = errors.New("cannot parse value as string or []string")
type UserID uint64
type Users []User
@ -229,6 +232,29 @@ func (u UserView) MarshalZerologObject(e *zerolog.Event) {
u.ж.MarshalZerologObject(e)
}
// FlexibleStringSlice handles OIDC providers (e.g. JumpCloud) that return the
// groups claim as a plain string when the user belongs to a single group,
// instead of a single-element array.
type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
var arr []string
err := json.Unmarshal(data, &arr)
if err == nil {
*f = arr
return nil
}
var single string
err = json.Unmarshal(data, &single)
if err == nil {
*f = []string{single}
return nil
}
return fmt.Errorf("%w: %s", ErrCannotParseStringSlice, string(data))
}
// FlexibleBoolean handles JumpCloud's JSON where email_verified is returned as a
// string "true" or "false" instead of a boolean.
// This maps bool to a specific type with a custom unmarshaler to
@ -269,9 +295,9 @@ type OIDCClaims struct {
// Name is the user's full name.
Name string `json:"name,omitempty"`
Groups []string `json:"groups,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
Groups FlexibleStringSlice `json:"groups,omitempty"`
Email string `json:"email,omitempty"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
ProfilePictureURL string `json:"picture,omitempty"`
Username string `json:"preferred_username,omitempty"`
}
@ -387,9 +413,9 @@ type OIDCUserInfo struct {
FamilyName string `json:"family_name"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
Groups []string `json:"groups"`
Picture string `json:"picture"`
EmailVerified FlexibleBoolean `json:"email_verified,omitempty"`
Groups FlexibleStringSlice `json:"groups"`
Picture string `json:"picture"`
}
// FromClaim overrides a User from OIDC claims.

View File

@ -61,6 +61,57 @@ func TestUnmarshallOIDCClaims(t *testing.T) {
EmailVerified: false,
},
},
{
name: "groups-array",
jsonstr: `
{
"sub": "test4",
"email": "test4@test.no",
"email_verified": true,
"groups": ["Group1", "Group2"]
}
`,
want: OIDCClaims{
Sub: "test4",
Email: "test4@test.no",
EmailVerified: true,
Groups: FlexibleStringSlice{"Group1", "Group2"},
},
},
{
name: "groups-single-string",
jsonstr: `
{
"sub": "test5",
"email": "test5@test.no",
"email_verified": true,
"groups": "SingleGroup"
}
`,
want: OIDCClaims{
Sub: "test5",
Email: "test5@test.no",
EmailVerified: true,
Groups: FlexibleStringSlice{"SingleGroup"},
},
},
{
name: "groups-empty-array",
jsonstr: `
{
"sub": "test6",
"email": "test6@test.no",
"email_verified": true,
"groups": []
}
`,
want: OIDCClaims{
Sub: "test6",
Email: "test6@test.no",
EmailVerified: true,
Groups: FlexibleStringSlice{},
},
},
}
for _, tt := range tests {