@@ -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"
217224func (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