@@ -10,15 +10,16 @@ import (
1010 "log/slog"
1111 "net/http"
1212 "net/url"
13+ "slices"
1314 "strings"
1415 "time"
1516
1617 "github.com/gin-gonic/gin"
17- "github.com/gotify/server/v2 /auth"
18- "github.com/gotify/server/v2 /config"
19- "github.com/gotify/server/v2 /database"
20- "github.com/gotify/server/v2 /decaymap"
21- "github.com/gotify/server/v2 /model"
18+ "github.com/gotify/server/v3 /auth"
19+ "github.com/gotify/server/v3 /config"
20+ "github.com/gotify/server/v3 /database"
21+ "github.com/gotify/server/v3 /decaymap"
22+ "github.com/gotify/server/v3 /model"
2223 "github.com/rs/zerolog"
2324 "github.com/rs/zerolog/log"
2425 "github.com/zitadel/oidc/v3/pkg/client/rp"
@@ -62,10 +63,14 @@ 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 ,
6872 LinkByUsername : conf .OIDC .LinkByUsername ,
73+ Prompt : conf .OIDC .Prompt ,
6974 pendingSessions : decaymap .NewDecayMap [string , * pendingOIDCSession ](time .Now (), pendingSessionMaxAge ),
7075 }
7176}
@@ -90,10 +95,14 @@ type OIDCAPI struct {
9095 Provider rp.RelyingParty
9196 UserChangeNotifier * UserChangeNotifier
9297 UsernameClaim string
98+ GroupsClaim string
99+ GroupsUser []string
100+ GroupsAdmin []string
93101 PasswordStrength int
94102 SecureCookie bool
95103 AutoRegister bool
96104 LinkByUsername bool
105+ Prompt []string
97106 pendingSessions * decaymap.DecayMap [string , * pendingOIDCSession ]
98107}
99108
@@ -131,7 +140,7 @@ func (a *OIDCAPI) LoginHandler() gin.HandlerFunc {
131140 return
132141 }
133142 a .pendingSessions .Set (time .Now (), state , & pendingOIDCSession {ClientName : clientName , CreatedAt : time .Now ()})
134- rp .AuthURLHandler (func () string { return state }, a .Provider )(w , r )
143+ rp .AuthURLHandler (func () string { return state }, a .Provider , a . promptURLParams () ... )(w , r )
135144 })
136145}
137146
@@ -174,7 +183,14 @@ func (a *OIDCAPI) ElevateHandler(ctx *gin.Context) {
174183 return
175184 }
176185 a .pendingSessions .Set (time .Now (), state , & pendingOIDCSession {CreatedAt : time .Now (), Elevate : & elevate })
177- rp .AuthURLHandler (func () string { return state }, a .Provider )(ctx .Writer , ctx .Request )
186+ rp .AuthURLHandler (func () string { return state }, a .Provider , a .promptURLParams ()... )(ctx .Writer , ctx .Request )
187+ }
188+
189+ func (a * OIDCAPI ) promptURLParams () []rp.URLParamOpt {
190+ if len (a .Prompt ) == 0 {
191+ return nil
192+ }
193+ return []rp.URLParamOpt {rp .WithPromptURLParam (a .Prompt ... )}
178194}
179195
180196// swagger:operation GET /auth/oidc/callback oidc oidcCallback
@@ -207,7 +223,7 @@ func (a *OIDCAPI) ElevateHandler(ctx *gin.Context) {
207223// $ref: "#/definitions/Error"
208224func (a * OIDCAPI ) CallbackHandler () gin.HandlerFunc {
209225 callback := func (w http.ResponseWriter , r * http.Request , tokens * oidc.Tokens [* oidc.IDTokenClaims ], state string , provider rp.RelyingParty , info * oidc.UserInfo ) {
210- user , status , err := a .resolveUser (tokens .IDTokenClaims . GetIssuer () , info )
226+ user , status , err := a .resolveUser (tokens .IDTokenClaims , info )
211227 if err != nil {
212228 http .Error (w , err .Error (), status )
213229 return
@@ -315,6 +331,9 @@ func (a *OIDCAPI) ExternalAuthorizeHandler(ctx *gin.Context) {
315331 rp .AuthURLOpt (rp .WithURLParam ("redirect_uri" , req .RedirectURI )),
316332 rp .WithCodeChallenge (req .CodeChallenge ),
317333 }
334+ for _ , opt := range a .promptURLParams () {
335+ authOpts = append (authOpts , rp .AuthURLOpt (opt ))
336+ }
318337 ctx .JSON (http .StatusOK , & model.OIDCExternalAuthorizeResponse {
319338 AuthorizeURL : rp .AuthURL (state , a .Provider , authOpts ... ),
320339 State : state ,
@@ -373,7 +392,7 @@ func (a *OIDCAPI) ExternalTokenHandler(ctx *gin.Context) {
373392 ctx .AbortWithError (http .StatusInternalServerError , fmt .Errorf ("failed to get user info: %w" , err ))
374393 return
375394 }
376- user , status , resolveErr := a .resolveUser (tokens .IDTokenClaims . GetIssuer () , info )
395+ user , status , resolveErr := a .resolveUser (tokens .IDTokenClaims , info )
377396 if resolveErr != nil {
378397 ctx .AbortWithError (status , resolveErr )
379398 return
@@ -404,7 +423,8 @@ func (a *OIDCAPI) generateState() (string, error) {
404423// this OIDC identity, which requires GOTIFY_OIDC_LINK_BY_USERNAME and
405424// that the user is not already bound to a different identity.
406425// 3. Otherwise auto-register a new user, which requires GOTIFY_OIDC_AUTOREGISTER.
407- 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 ()
408428 if issuer == "" {
409429 return nil , http .StatusInternalServerError , errors .New ("issuer claim was empty" )
410430 }
@@ -424,11 +444,25 @@ func (a *OIDCAPI) resolveUser(issuer string, info *oidc.UserInfo) (*model.User,
424444 if err != nil {
425445 return nil , http .StatusInternalServerError , fmt .Errorf ("database error: %w" , err )
426446 }
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+
427454 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+ }
428462 return user , 0 , nil
429463 }
430464
431- usernameRaw , ok := info . Claims [ a .UsernameClaim ]
465+ usernameRaw , ok := lookupClaim ( a .UsernameClaim , idToken . Claims , info . Claims )
432466 if ! ok {
433467 return nil , http .StatusInternalServerError , fmt .Errorf ("username claim %q is missing" , a .UsernameClaim )
434468 }
@@ -442,12 +476,12 @@ func (a *OIDCAPI) resolveUser(issuer string, info *oidc.UserInfo) (*model.User,
442476 return nil , http .StatusInternalServerError , fmt .Errorf ("database error: %w" , err )
443477 }
444478 if byUsername != nil {
445- return a .linkExistingUser (byUsername , oidcID )
479+ return a .linkExistingUser (byUsername , oidcID , hasAdminGroup )
446480 }
447- return a .registerUser (username , oidcID )
481+ return a .registerUser (username , oidcID , hasAdminGroup )
448482}
449483
450- 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 ) {
451485 if ! a .LinkByUsername {
452486 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 )
453487 return nil , http .StatusForbidden , fmt .Errorf ("a local user with the username %s already exists and linking by username is disabled" , user .Name )
@@ -457,21 +491,34 @@ func (a *OIDCAPI) linkExistingUser(user *model.User, oidcID string) (*model.User
457491 return nil , http .StatusForbidden , fmt .Errorf ("the user %s is already bound to a different OIDC identity" , user .Name )
458492 }
459493 user .OIDCID = & oidcID
494+ if len (a .GroupsAdmin ) > 0 {
495+ user .Admin = hasAdminGroup
496+ }
460497 if err := a .DB .UpdateUser (user ); err != nil {
461498 return nil , http .StatusInternalServerError , fmt .Errorf ("failed to bind user to OIDC identity: %w" , err )
462499 }
500+ log .Warn ().Str ("oidc_id" , oidcID ).Str ("username" , user .Name ).Bool ("admin" , user .Admin ).Msg ("OIDC link by username" )
463501 return user , 0 , nil
464502}
465503
466- 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 ) {
467505 if ! a .AutoRegister {
468506 return nil , http .StatusForbidden , errors .New ("user does not exist and auto-registration is disabled" )
469507 }
470- 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+
471518 if err := a .DB .CreateUser (user ); err != nil {
472519 return nil , http .StatusInternalServerError , fmt .Errorf ("failed to create user: %w" , err )
473520 }
474- 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" )
475522 if err := a .UserChangeNotifier .fireUserAdded (user .ID ); err != nil {
476523 log .Error ().Err (err ).Uint ("user_id" , user .ID ).Msg ("Could not notify user change" )
477524 }
@@ -502,3 +549,59 @@ func (a *OIDCAPI) popPendingSession(key string) (*pendingOIDCSession, bool) {
502549 }
503550 return nil , false
504551}
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