Skip to content

Commit 74eba4b

Browse files
WilliamSoderberggtsteffaniak
authored andcommitted
Dynamic scopes for OIDC (#1414)
1 parent 75c8d78 commit 74eba4b

File tree

2 files changed

+67
-84
lines changed

2 files changed

+67
-84
lines changed

backend/http/oidc.go

Lines changed: 43 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -20,60 +20,51 @@ import (
2020
"golang.org/x/oauth2"
2121
)
2222

23-
// userInfo struct to hold user claims from either UserInfo or ID token
23+
// userInfo holds all claims dynamically, plus pre-parsed Groups.
2424
type userInfo struct {
25-
Name string `json:"name"`
26-
PreferredUsername string `json:"preferred_username"`
27-
Username string `json:"username"`
28-
Email string `json:"email"`
29-
Sub string `json:"sub"`
30-
Phone string `json:"phone_number"`
31-
Groups []string `json:"-"` // Handled manually by userInfoUnmarshaller
25+
Claims map[string]interface{}
26+
Groups []string
3227
}
3328

34-
// userInfoUnmarshaller is a custom unmarshaller that handles configurable groups claim
29+
// userInfoUnmarshaller handles unmarshalling all claims dynamically,
30+
// while optionally parsing a configurable groups claim.
3531
type userInfoUnmarshaller struct {
3632
userInfo *userInfo
3733
groupsClaim string
3834
}
3935

4036
// UnmarshalJSON implements the json.Unmarshaler interface
4137
func (u *userInfoUnmarshaller) UnmarshalJSON(data []byte) error {
42-
// First, unmarshal the basic userInfo fields
43-
if err := json.Unmarshal(data, u.userInfo); err != nil {
38+
var raw map[string]interface{}
39+
if err := json.Unmarshal(data, &raw); err != nil {
4440
return err
4541
}
4642

47-
// Parse the JSON to access the groups claim field
48-
var rawData map[string]interface{}
49-
if err := json.Unmarshal(data, &rawData); err != nil {
50-
return err
51-
}
52-
53-
// Look for the groups claim using the configured field name
54-
if groupsValue, exists := rawData[u.groupsClaim]; exists {
55-
switch v := groupsValue.(type) {
56-
case []interface{}:
57-
// It's already an array, convert to []string
58-
groups := make([]string, len(v))
59-
for i, group := range v {
60-
if str, ok := group.(string); ok {
61-
groups[i] = str
43+
// Extract groups if configured
44+
if u.groupsClaim != "" {
45+
if v, ok := raw[u.groupsClaim]; ok {
46+
switch val := v.(type) {
47+
case []interface{}:
48+
groups := make([]string, len(val))
49+
for i, g := range val {
50+
if s, ok := g.(string); ok {
51+
groups[i] = strings.TrimSpace(s)
52+
}
6253
}
63-
}
64-
u.userInfo.Groups = groups
65-
case string:
66-
// It's a string, split by commas
67-
if v != "" {
68-
u.userInfo.Groups = strings.Split(v, ",")
69-
// Trim whitespace from each group
70-
for i, group := range u.userInfo.Groups {
71-
u.userInfo.Groups[i] = strings.TrimSpace(group)
54+
u.userInfo.Groups = groups
55+
case string:
56+
if val != "" {
57+
parts := strings.Split(val, ",")
58+
for i := range parts {
59+
parts[i] = strings.TrimSpace(parts[i])
60+
}
61+
u.userInfo.Groups = parts
7262
}
7363
}
7464
}
7565
}
7666

67+
u.userInfo.Claims = raw
7768
return nil
7869
}
7970

@@ -100,7 +91,7 @@ func oidcLoginHandler(w http.ResponseWriter, r *http.Request, d *requestContext)
10091
ClientSecret: oidcCfg.ClientSecret,
10192
Endpoint: oidcCfg.Provider.Endpoint(),
10293
RedirectURL: fmt.Sprintf("%s%sapi/auth/oidc/callback", origin, config.Server.BaseURL),
103-
Scopes: strings.Split(oidcCfg.Scopes, " "),
94+
Scopes: strings.Fields(oidcCfg.Scopes),
10495
}
10596

10697
nonce := utils.InsecureRandomIdentifier(16)
@@ -157,7 +148,7 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
157148
ClientSecret: oidcCfg.ClientSecret,
158149
Endpoint: oidcCfg.Provider.Endpoint(), // Use endpoint from discovered provider
159150
RedirectURL: redirectURL, // Use the dynamically determined redirect URL
160-
Scopes: strings.Split(oidcCfg.Scopes, " "),
151+
Scopes: strings.Fields(oidcCfg.Scopes),
161152
}
162153

163154
// Exchange the authorization code for tokens
@@ -186,10 +177,10 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
186177

187178
// Verify the ID token
188179
// This uses the verifier initialized with the provider's JWKS endpoint and client ID
189-
idToken, err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
190-
if err != nil {
180+
idToken, verify_err := oidcCfg.Verifier.Verify(ctx, rawIDToken)
181+
if verify_err != nil {
191182
// this might not be necessary for certain providers like authentik
192-
logger.Debugf("failed to verify ID token: %v. This might be expected, falling back to UserInfo endpoint.", err)
183+
logger.Debugf("failed to verify ID token: %v. This might be expected, falling back to UserInfo endpoint.", verify_err)
193184
// Verification failed, claimsFromIDToken remains false
194185
} else {
195186
// Decode the ID token claims using custom unmarshaller
@@ -203,30 +194,13 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
203194

204195
// Decide if we rely on ID token claims or still need UserInfo
205196
// Even if parsing succeeded, if essential claims are missing, use UserInfo
206-
switch oidcCfg.UserIdentifier {
207-
case "email":
208-
if userdata.Email != "" {
209-
claimsFromIDToken = true
210-
loginUsername = userdata.Email
211-
}
212-
case "username":
213-
if userdata.Username != "" {
214-
claimsFromIDToken = true
215-
loginUsername = userdata.Username
216-
}
217-
case "preferred_username":
218-
if userdata.PreferredUsername != "" {
219-
claimsFromIDToken = true
220-
loginUsername = userdata.PreferredUsername
221-
}
222-
case "phone":
223-
if userdata.Phone != "" {
224-
claimsFromIDToken = true
225-
loginUsername = userdata.Phone
226-
}
197+
if _, ok := userdata.Claims[oidcCfg.UserIdentifier]; ok {
198+
claimsFromIDToken = true
227199
}
228200
}
201+
logger.Debugf("Failed to verify ID token: %v", verify_err)
229202
}
203+
230204
} else {
231205
logger.Debug("No ID token found in token response or it was empty. Falling back to UserInfo endpoint.")
232206
// claimsFromIDToken remains false
@@ -247,22 +221,17 @@ func oidcCallbackHandler(w http.ResponseWriter, r *http.Request, d *requestConte
247221
logger.Errorf("failed to decode user info from endpoint: %v", err)
248222
return http.StatusInternalServerError, fmt.Errorf("failed to decode user info from endpoint: %v", err)
249223
}
250-
// Decide if we rely on ID token claims or still need UserInfo
251-
// Even if parsing succeeded, if essential claims are missing, use UserInfo
252-
switch oidcCfg.UserIdentifier {
253-
case "email":
254-
loginUsername = userdata.Email
255-
case "username":
256-
loginUsername = userdata.Username
257-
case "preferred_username":
258-
loginUsername = userdata.PreferredUsername
259-
case "phone":
260-
loginUsername = userdata.Phone
224+
}
225+
226+
// --- Determine login username dynamically ---
227+
if val, ok := userdata.Claims[oidcCfg.UserIdentifier]; ok {
228+
if v, ok := val.(string); ok {
229+
loginUsername = v
261230
}
262231
}
263232
if loginUsername == "" {
264-
logger.Errorf("No valid username found for identifier '%v' in ID token or UserInfo response.", oidcCfg.UserIdentifier)
265-
return http.StatusInternalServerError, fmt.Errorf("no valid username found in ID token or UserInfo response from claims")
233+
logger.Errorf("No valid username found for identifier '%v' in claims.", oidcCfg.UserIdentifier)
234+
return http.StatusInternalServerError, fmt.Errorf("no valid username found for identifier '%v'", oidcCfg.UserIdentifier)
266235
}
267236

268237
// Proceed to log the user in with the OIDC data

backend/http/oidc_test.go

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ func TestUserInfoUnmarshaller(t *testing.T) {
1818
jsonData: `{"name":"John","email":"john@example.com","groups":["admin","users"]}`,
1919
groupsClaim: "groups",
2020
expected: userInfo{
21-
Name: "John",
22-
Email: "john@example.com",
21+
Claims: map[string]interface{}{
22+
"name": "John",
23+
"email": "john@example.com",
24+
"groups": []interface{}{"admin","users"},
25+
},
2326
Groups: []string{"admin", "users"},
2427
},
2528
},
@@ -28,8 +31,11 @@ func TestUserInfoUnmarshaller(t *testing.T) {
2831
jsonData: `{"name":"Jane","email":"jane@example.com","roles":["admin","users"]}`,
2932
groupsClaim: "roles",
3033
expected: userInfo{
31-
Name: "Jane",
32-
Email: "jane@example.com",
34+
Claims: map[string]interface{}{
35+
"name": "Jane",
36+
"email": "jane@example.com",
37+
"roles": []interface{}{"admin","users"},
38+
},
3339
Groups: []string{"admin", "users"},
3440
},
3541
},
@@ -38,8 +44,11 @@ func TestUserInfoUnmarshaller(t *testing.T) {
3844
jsonData: `{"name":"Bob","email":"bob@example.com","groups":"admin, users, guests"}`,
3945
groupsClaim: "groups",
4046
expected: userInfo{
41-
Name: "Bob",
42-
Email: "bob@example.com",
47+
Claims: map[string]interface{}{
48+
"name": "Bob",
49+
"email": "bob@example.com",
50+
"groups": "admin, users, guests",
51+
},
4352
Groups: []string{"admin", "users", "guests"},
4453
},
4554
},
@@ -48,8 +57,10 @@ func TestUserInfoUnmarshaller(t *testing.T) {
4857
jsonData: `{"name":"Alice","email":"alice@example.com"}`,
4958
groupsClaim: "groups",
5059
expected: userInfo{
51-
Name: "Alice",
52-
Email: "alice@example.com",
60+
Claims: map[string]interface{}{
61+
"name": "Alice",
62+
"email": "alice@example.com",
63+
},
5364
Groups: nil,
5465
},
5566
},
@@ -58,8 +69,11 @@ func TestUserInfoUnmarshaller(t *testing.T) {
5869
jsonData: `{"name":"Charlie","email":"charlie@example.com","groups":[]}`,
5970
groupsClaim: "groups",
6071
expected: userInfo{
61-
Name: "Charlie",
62-
Email: "charlie@example.com",
72+
Claims: map[string]interface{}{
73+
"name": "Charlie",
74+
"email": "charlie@example.com",
75+
"groups": []interface{}{},
76+
},
6377
Groups: []string{},
6478
},
6579
},

0 commit comments

Comments
 (0)