Skip to content

Commit f067a0b

Browse files
authored
Handle scope field as string or array in DynamicClientRegistrationResponse (#2083)
* support both string and array formats for scope in DynamicClientRegistrationResponse * fix linting issues * added tests for scopeList unmarshal * adds parallel in tests * fixed tests to use assert instead of reflect
1 parent 6a9563b commit f067a0b

File tree

2 files changed

+136
-6
lines changed

2 files changed

+136
-6
lines changed

pkg/auth/oauth/dynamic_registration.go

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,60 @@ func NewDynamicClientRegistrationRequest(scopes []string, callbackPort int) *Dyn
5757
return registrationRequest
5858
}
5959

60+
// ScopeList represents the "scope" field in a dynamic client registration response.
61+
// Some servers return this as a space-delimited string per RFC 7591, while others
62+
// return it as a JSON array of strings. This type normalizes both into a []string.
63+
//
64+
// Examples of supported inputs:
65+
//
66+
// "openid profile email" → []string{"openid", "profile", "email"}
67+
// ["openid","profile","email"] → []string{"openid", "profile", "email"}
68+
// null → nil
69+
// "" or ["", " "] → nil
70+
type ScopeList []string
71+
72+
// UnmarshalJSON implements custom decoding for ScopeList. It supports both
73+
// string and array encodings of the "scope" field, trimming whitespace and
74+
// normalizing empty values to nil for consistent semantics.
75+
func (s *ScopeList) UnmarshalJSON(data []byte) error {
76+
// Handle explicit null
77+
if strings.TrimSpace(string(data)) == "null" {
78+
*s = nil
79+
return nil
80+
}
81+
82+
// Case 1: space-delimited string
83+
var str string
84+
if err := json.Unmarshal(data, &str); err == nil {
85+
if strings.TrimSpace(str) == "" {
86+
*s = nil
87+
return nil
88+
}
89+
*s = strings.Fields(str)
90+
return nil
91+
}
92+
93+
// Case 2: JSON array
94+
var arr []string
95+
if err := json.Unmarshal(data, &arr); err == nil {
96+
cleaned := make([]string, 0, len(arr))
97+
for _, v := range arr {
98+
if v = strings.TrimSpace(v); v != "" {
99+
cleaned = append(cleaned, v)
100+
}
101+
}
102+
// Normalize: treat all-empty/whitespace arrays the same as ""
103+
if len(cleaned) == 0 {
104+
*s = nil
105+
} else {
106+
*s = cleaned
107+
}
108+
return nil
109+
}
110+
111+
return fmt.Errorf("invalid scope format: %s", string(data))
112+
}
113+
60114
// DynamicClientRegistrationResponse represents the response from dynamic client registration (RFC 7591)
61115
type DynamicClientRegistrationResponse struct {
62116
// Required fields
@@ -70,12 +124,12 @@ type DynamicClientRegistrationResponse struct {
70124
RegistrationClientURI string `json:"registration_client_uri,omitempty"`
71125

72126
// Echo back the essential request fields
73-
ClientName string `json:"client_name,omitempty"`
74-
RedirectURIs []string `json:"redirect_uris,omitempty"`
75-
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
76-
GrantTypes []string `json:"grant_types,omitempty"`
77-
ResponseTypes []string `json:"response_types,omitempty"`
78-
Scopes []string `json:"scope,omitempty"`
127+
ClientName string `json:"client_name,omitempty"`
128+
RedirectURIs []string `json:"redirect_uris,omitempty"`
129+
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`
130+
GrantTypes []string `json:"grant_types,omitempty"`
131+
ResponseTypes []string `json:"response_types,omitempty"`
132+
Scopes ScopeList `json:"scope,omitempty"`
79133
}
80134

81135
// RegisterClientDynamically performs dynamic client registration (RFC 7591)

pkg/auth/oauth/dynamic_registration_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,3 +364,79 @@ func TestDynamicClientRegistrationResponse_Validation(t *testing.T) {
364364
}
365365

366366
// TestIsLocalhost is already defined in oidc_test.go
367+
368+
// TestScopeList_UnmarshalJSON tests that the ScopeList unmarshaling works correctly.
369+
func TestScopeList_UnmarshalJSON(t *testing.T) {
370+
t.Parallel()
371+
372+
tests := []struct {
373+
name string
374+
jsonIn string
375+
want []string
376+
wantErr bool
377+
}{
378+
{
379+
name: "space-delimited string",
380+
jsonIn: `"openid profile email"`,
381+
want: []string{"openid", "profile", "email"},
382+
},
383+
{
384+
name: "empty string => nil",
385+
jsonIn: `""`,
386+
want: nil,
387+
},
388+
{
389+
name: "string with extra spaces",
390+
jsonIn: `" openid profile "`,
391+
want: []string{"openid", "profile"},
392+
},
393+
{
394+
name: "normal array",
395+
jsonIn: `["openid","profile","email"]`,
396+
want: []string{"openid", "profile", "email"},
397+
},
398+
{
399+
name: "array with whitespace and empties",
400+
jsonIn: `[" openid ",""," profile "]`,
401+
want: []string{"openid", "profile"},
402+
},
403+
{
404+
name: "all-empty array => nil",
405+
jsonIn: `[""," "]`,
406+
want: nil,
407+
},
408+
{
409+
name: "explicit null => nil",
410+
jsonIn: `null`,
411+
want: nil,
412+
},
413+
{
414+
name: "invalid type (number)",
415+
jsonIn: `123`,
416+
wantErr: true,
417+
},
418+
{
419+
name: "invalid type (object)",
420+
jsonIn: `{"not":"valid"}`,
421+
wantErr: true,
422+
},
423+
}
424+
425+
for _, tt := range tests {
426+
tt := tt // capture loop variable
427+
t.Run(tt.name, func(t *testing.T) {
428+
t.Parallel()
429+
430+
var s ScopeList
431+
err := json.Unmarshal([]byte(tt.jsonIn), &s)
432+
433+
if tt.wantErr {
434+
assert.Error(t, err, "expected error but got none")
435+
return
436+
}
437+
438+
assert.NoError(t, err, "unexpected unmarshal error")
439+
assert.Equal(t, tt.want, []string(s))
440+
})
441+
}
442+
}

0 commit comments

Comments
 (0)