Skip to content

Commit 199e9c0

Browse files
authored
Merge pull request #1033 from UiP9AV6Y/feature/oidc-claim-mapping
feat: add support for mapping user admin status from OIDC claims
2 parents 54deea6 + 585b9e3 commit 199e9c0

8 files changed

Lines changed: 427 additions & 38 deletions

File tree

api/oidc.go

Lines changed: 101 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"log/slog"
1111
"net/http"
1212
"net/url"
13+
"slices"
1314
"strings"
1415
"time"
1516

@@ -62,6 +63,9 @@ func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNo
6263
Provider: provider,
6364
UserChangeNotifier: userChangeNotifier,
6465
UsernameClaim: conf.OIDC.UsernameClaim,
66+
GroupsClaim: conf.OIDC.GroupsClaim,
67+
GroupsUser: conf.OIDC.GroupsUser,
68+
GroupsAdmin: conf.OIDC.GroupsAdmin,
6569
PasswordStrength: conf.PassStrength,
6670
SecureCookie: conf.Server.SecureCookie,
6771
AutoRegister: conf.OIDC.AutoRegister,
@@ -91,6 +95,9 @@ type OIDCAPI struct {
9195
Provider rp.RelyingParty
9296
UserChangeNotifier *UserChangeNotifier
9397
UsernameClaim string
98+
GroupsClaim string
99+
GroupsUser []string
100+
GroupsAdmin []string
94101
PasswordStrength int
95102
SecureCookie bool
96103
AutoRegister bool
@@ -216,7 +223,7 @@ func (a *OIDCAPI) promptURLParams() []rp.URLParamOpt {
216223
// $ref: "#/definitions/Error"
217224
func (a *OIDCAPI) CallbackHandler() gin.HandlerFunc {
218225
callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, provider rp.RelyingParty, info *oidc.UserInfo) {
219-
user, status, err := a.resolveUser(tokens.IDTokenClaims.GetIssuer(), info)
226+
user, status, err := a.resolveUser(tokens.IDTokenClaims, info)
220227
if err != nil {
221228
http.Error(w, err.Error(), status)
222229
return
@@ -385,7 +392,7 @@ func (a *OIDCAPI) ExternalTokenHandler(ctx *gin.Context) {
385392
ctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to get user info: %w", err))
386393
return
387394
}
388-
user, status, resolveErr := a.resolveUser(tokens.IDTokenClaims.GetIssuer(), info)
395+
user, status, resolveErr := a.resolveUser(tokens.IDTokenClaims, info)
389396
if resolveErr != nil {
390397
ctx.AbortWithError(status, resolveErr)
391398
return
@@ -416,7 +423,8 @@ func (a *OIDCAPI) generateState() (string, error) {
416423
// this OIDC identity, which requires GOTIFY_OIDC_LINK_BY_USERNAME and
417424
// that the user is not already bound to a different identity.
418425
// 3. Otherwise auto-register a new user, which requires GOTIFY_OIDC_AUTOREGISTER.
419-
func (a *OIDCAPI) resolveUser(issuer string, info *oidc.UserInfo) (*model.User, int, error) {
426+
func (a *OIDCAPI) resolveUser(idToken *oidc.IDTokenClaims, info *oidc.UserInfo) (*model.User, int, error) {
427+
issuer := idToken.GetIssuer()
420428
if issuer == "" {
421429
return nil, http.StatusInternalServerError, errors.New("issuer claim was empty")
422430
}
@@ -436,11 +444,25 @@ func (a *OIDCAPI) resolveUser(issuer string, info *oidc.UserInfo) (*model.User,
436444
if err != nil {
437445
return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err)
438446
}
447+
448+
hasAdminGroup, status, err := a.resolvePermission(idToken.Claims, info.Claims)
449+
if err != nil {
450+
log.Err(err).Str("oidc_id", oidcID).Interface("idTokenClaims", idToken.Claims).Interface("userinfoClaims", info.Claims).Msg("OIDC: resolve permission")
451+
return nil, status, err
452+
}
453+
439454
if user != nil {
455+
if len(a.GroupsAdmin) > 0 && user.Admin != hasAdminGroup {
456+
user.Admin = hasAdminGroup
457+
if err := a.DB.UpdateUser(user); err != nil {
458+
return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err)
459+
}
460+
log.Warn().Str("oidc_id", oidcID).Str("username", user.Name).Bool("admin", user.Admin).Msg("OIDC change permission")
461+
}
440462
return user, 0, nil
441463
}
442464

443-
usernameRaw, ok := info.Claims[a.UsernameClaim]
465+
usernameRaw, ok := lookupClaim(a.UsernameClaim, idToken.Claims, info.Claims)
444466
if !ok {
445467
return nil, http.StatusInternalServerError, fmt.Errorf("username claim %q is missing", a.UsernameClaim)
446468
}
@@ -454,12 +476,12 @@ func (a *OIDCAPI) resolveUser(issuer string, info *oidc.UserInfo) (*model.User,
454476
return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err)
455477
}
456478
if byUsername != nil {
457-
return a.linkExistingUser(byUsername, oidcID)
479+
return a.linkExistingUser(byUsername, oidcID, hasAdminGroup)
458480
}
459-
return a.registerUser(username, oidcID)
481+
return a.registerUser(username, oidcID, hasAdminGroup)
460482
}
461483

462-
func (a *OIDCAPI) linkExistingUser(user *model.User, oidcID string) (*model.User, int, error) {
484+
func (a *OIDCAPI) linkExistingUser(user *model.User, oidcID string, hasAdminGroup bool) (*model.User, int, error) {
463485
if !a.LinkByUsername {
464486
log.Warn().Str("oidc_id", oidcID).Str("username", user.Name).Msgf("OIDC login rejected: a local user with the username already exists and %s is disabled", config.EnvOIDCLinkByUsername)
465487
return nil, http.StatusForbidden, fmt.Errorf("a local user with the username %s already exists and linking by username is disabled", user.Name)
@@ -469,21 +491,34 @@ func (a *OIDCAPI) linkExistingUser(user *model.User, oidcID string) (*model.User
469491
return nil, http.StatusForbidden, fmt.Errorf("the user %s is already bound to a different OIDC identity", user.Name)
470492
}
471493
user.OIDCID = &oidcID
494+
if len(a.GroupsAdmin) > 0 {
495+
user.Admin = hasAdminGroup
496+
}
472497
if err := a.DB.UpdateUser(user); err != nil {
473498
return nil, http.StatusInternalServerError, fmt.Errorf("failed to bind user to OIDC identity: %w", err)
474499
}
500+
log.Warn().Str("oidc_id", oidcID).Str("username", user.Name).Bool("admin", user.Admin).Msg("OIDC link by username")
475501
return user, 0, nil
476502
}
477503

478-
func (a *OIDCAPI) registerUser(username, oidcID string) (*model.User, int, error) {
504+
func (a *OIDCAPI) registerUser(username, oidcID string, hasAdminGroup bool) (*model.User, int, error) {
479505
if !a.AutoRegister {
480506
return nil, http.StatusForbidden, errors.New("user does not exist and auto-registration is disabled")
481507
}
482-
user := &model.User{Name: username, Admin: false, Pass: nil, OIDCID: &oidcID}
508+
user := &model.User{
509+
Name: username,
510+
Pass: nil,
511+
OIDCID: &oidcID,
512+
}
513+
514+
if len(a.GroupsAdmin) > 0 {
515+
user.Admin = hasAdminGroup
516+
}
517+
483518
if err := a.DB.CreateUser(user); err != nil {
484519
return nil, http.StatusInternalServerError, fmt.Errorf("failed to create user: %w", err)
485520
}
486-
log.Info().Str("oidc_id", oidcID).Str("username", user.Name).Msg("OIDC auto registration")
521+
log.Info().Str("oidc_id", oidcID).Str("username", user.Name).Bool("admin", user.Admin).Msg("OIDC auto registration")
487522
if err := a.UserChangeNotifier.fireUserAdded(user.ID); err != nil {
488523
log.Error().Err(err).Uint("user_id", user.ID).Msg("Could not notify user change")
489524
}
@@ -514,3 +549,59 @@ func (a *OIDCAPI) popPendingSession(key string) (*pendingOIDCSession, bool) {
514549
}
515550
return nil, false
516551
}
552+
553+
func (a *OIDCAPI) resolvePermission(idTokenClaims, userInfoClaims map[string]any) (bool, int, error) {
554+
if a.GroupsClaim == "" {
555+
return false, 0, nil
556+
}
557+
558+
groupsRaw, ok := lookupClaim(a.GroupsClaim, idTokenClaims, userInfoClaims)
559+
if !ok {
560+
return false, http.StatusInternalServerError, fmt.Errorf("groups claim %q is missing", a.GroupsClaim)
561+
}
562+
563+
var groups []string
564+
switch groupsRaw := groupsRaw.(type) {
565+
case []string:
566+
groups = groupsRaw
567+
case []any:
568+
for _, groupRaw := range groupsRaw {
569+
group, ok := groupRaw.(string)
570+
if !ok {
571+
return false, http.StatusInternalServerError, fmt.Errorf("groups claim %q contains a non-string element: %#v", a.GroupsClaim, groupRaw)
572+
}
573+
groups = append(groups, group)
574+
}
575+
case string:
576+
groups = append(groups, groupsRaw)
577+
default:
578+
return false, http.StatusInternalServerError, fmt.Errorf("groups claim %q is not a string or string array: %#v", a.GroupsClaim, groupsRaw)
579+
}
580+
581+
switch {
582+
case containsAny(a.GroupsAdmin, groups):
583+
return true, 0, nil
584+
case len(a.GroupsUser) == 0 || containsAny(a.GroupsUser, groups):
585+
return false, 0, nil
586+
default:
587+
return false, http.StatusForbidden, errors.New("user is not in any allowed group")
588+
}
589+
}
590+
591+
func lookupClaim(name string, idTokenClaims, userInfoClaims map[string]any) (any, bool) {
592+
if value, ok := idTokenClaims[name]; ok {
593+
return value, true
594+
}
595+
value, ok := userInfoClaims[name]
596+
return value, ok
597+
}
598+
599+
func containsAny(configured, actual []string) bool {
600+
for _, value := range actual {
601+
if slices.Contains(configured, value) {
602+
return true
603+
}
604+
}
605+
606+
return false
607+
}

0 commit comments

Comments
 (0)