From 3d0f597b237ce0227a233192f39fada79969d980 Mon Sep 17 00:00:00 2001 From: primewildy <48487184+primewildy@users.noreply.github.com> Date: Mon, 4 May 2026 14:26:53 +0100 Subject: [PATCH] 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. --- hscontrol/types/users.go | 38 +++++++++++++++++++++----- hscontrol/types/users_test.go | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/hscontrol/types/users.go b/hscontrol/types/users.go index 92ed73ad..8b87a6ce 100644 --- a/hscontrol/types/users.go +++ b/hscontrol/types/users.go @@ -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. diff --git a/hscontrol/types/users_test.go b/hscontrol/types/users_test.go index 064388eb..1218979c 100644 --- a/hscontrol/types/users_test.go +++ b/hscontrol/types/users_test.go @@ -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 {