From 14f703797e426a312ad047627f47687ac01bb2d7 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 23 Aug 2026 10:33:52 +0200 Subject: [PATCH 1/3] fix: read username claim from ID token with userinfo fallback Some IdPs don't provide or only provide the claims in the ID token / userinfo. Most clients will check both claims. --- api/oidc.go | 17 +++++++++++---- api/oidc_test.go | 54 +++++++++++++++++++++++++++++++++++------------- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/api/oidc.go b/api/oidc.go index 4182236f..42e140b7 100644 --- a/api/oidc.go +++ b/api/oidc.go @@ -216,7 +216,7 @@ func (a *OIDCAPI) promptURLParams() []rp.URLParamOpt { // $ref: "#/definitions/Error" func (a *OIDCAPI) CallbackHandler() gin.HandlerFunc { callback := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens[*oidc.IDTokenClaims], state string, provider rp.RelyingParty, info *oidc.UserInfo) { - user, status, err := a.resolveUser(tokens.IDTokenClaims.GetIssuer(), info) + user, status, err := a.resolveUser(tokens.IDTokenClaims, info) if err != nil { http.Error(w, err.Error(), status) return @@ -385,7 +385,7 @@ func (a *OIDCAPI) ExternalTokenHandler(ctx *gin.Context) { ctx.AbortWithError(http.StatusInternalServerError, fmt.Errorf("failed to get user info: %w", err)) return } - user, status, resolveErr := a.resolveUser(tokens.IDTokenClaims.GetIssuer(), info) + user, status, resolveErr := a.resolveUser(tokens.IDTokenClaims, info) if resolveErr != nil { ctx.AbortWithError(status, resolveErr) return @@ -416,7 +416,8 @@ func (a *OIDCAPI) generateState() (string, error) { // this OIDC identity, which requires GOTIFY_OIDC_LINK_BY_USERNAME and // that the user is not already bound to a different identity. // 3. Otherwise auto-register a new user, which requires GOTIFY_OIDC_AUTOREGISTER. -func (a *OIDCAPI) resolveUser(issuer string, info *oidc.UserInfo) (*model.User, int, error) { +func (a *OIDCAPI) resolveUser(idToken *oidc.IDTokenClaims, info *oidc.UserInfo) (*model.User, int, error) { + issuer := idToken.GetIssuer() if issuer == "" { return nil, http.StatusInternalServerError, errors.New("issuer claim was empty") } @@ -440,7 +441,7 @@ func (a *OIDCAPI) resolveUser(issuer string, info *oidc.UserInfo) (*model.User, return user, 0, nil } - usernameRaw, ok := info.Claims[a.UsernameClaim] + usernameRaw, ok := lookupClaim(a.UsernameClaim, idToken.Claims, info.Claims) if !ok { return nil, http.StatusInternalServerError, fmt.Errorf("username claim %q is missing", a.UsernameClaim) } @@ -514,3 +515,11 @@ func (a *OIDCAPI) popPendingSession(key string) (*pendingOIDCSession, bool) { } return nil, false } + +func lookupClaim(name string, idTokenClaims, userInfoClaims map[string]any) (any, bool) { + if value, ok := idTokenClaims[name]; ok { + return value, true + } + value, ok := userInfoClaims[name] + return value, ok +} diff --git a/api/oidc_test.go b/api/oidc_test.go index 00617702..afcdf644 100644 --- a/api/oidc_test.go +++ b/api/oidc_test.go @@ -24,6 +24,10 @@ import ( const testIssuer = "https://idp.example.com" +func newIDToken(issuer string, claims map[string]any) *oidc.IDTokenClaims { + return &oidc.IDTokenClaims{TokenClaims: oidc.TokenClaims{Issuer: issuer}, Claims: claims} +} + func TestOIDCSuite(t *testing.T) { suite.Run(t, new(OIDCSuite)) } @@ -159,7 +163,7 @@ func (s *OIDCSuite) Test_ResolveUser_ReturningUser_MatchedByOIDCID() { // The username claim differs from the stored name; the binding still matches. info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "renamed"}} - user, status, err := s.a.resolveUser(testIssuer, info) + user, status, err := s.a.resolveUser(newIDToken(testIssuer, info.Claims), info) assert.NoError(s.T(), err) assert.Equal(s.T(), 0, status) @@ -173,7 +177,7 @@ func (s *OIDCSuite) Test_ResolveUser_LinkByUsername_BindsExistingUser() { s.db.NewUserWithName(1, "alice") info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "alice"}} - user, _, err := s.a.resolveUser(testIssuer, info) + user, _, err := s.a.resolveUser(newIDToken(testIssuer, info.Claims), info) assert.NoError(s.T(), err) assert.Equal(s.T(), uint(1), user.ID) @@ -192,7 +196,7 @@ func (s *OIDCSuite) Test_ResolveUser_InvalidIssuer() { s.db.NewUserWithName(1, "alice") info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "alice"}} - _, status, err := s.a.resolveUser("://example.org", info) + _, status, err := s.a.resolveUser(newIDToken("://example.org", info.Claims), info) assert.EqualError(s.T(), err, `issuer url "://example.org" is not a valid url: parse "://example.org": missing protocol scheme`) assert.Equal(s.T(), 500, status) @@ -202,7 +206,7 @@ func (s *OIDCSuite) Test_ResolveUser_InvalidIssuer_containsFragment() { s.db.NewUserWithName(1, "alice") info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "alice"}} - _, status, err := s.a.resolveUser(testIssuer+"#", info) + _, status, err := s.a.resolveUser(newIDToken(testIssuer+"#", info.Claims), info) assert.EqualError(s.T(), err, `issuer url "https://idp.example.com#" may not contain a fragment`) assert.Equal(s.T(), 500, status) @@ -212,7 +216,7 @@ func (s *OIDCSuite) Test_ResolveUser_LinkDisabled_RejectsExistingUsername() { s.db.NewUserWithName(1, "alice") info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "alice"}} - _, status, err := s.a.resolveUser(testIssuer, info) + _, status, err := s.a.resolveUser(newIDToken(testIssuer, info.Claims), info) assert.EqualError(s.T(), err, "a local user with the username alice already exists and linking by username is disabled") assert.Equal(s.T(), 403, status) @@ -228,7 +232,7 @@ func (s *OIDCSuite) Test_ResolveUser_LinkByUsername_RejectsDifferentIdentity() { s.db.CreateUser(&model.User{ID: 1, Name: "alice", OIDCID: &otherID}) info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "alice"}} - _, status, err := s.a.resolveUser(testIssuer, info) + _, status, err := s.a.resolveUser(newIDToken(testIssuer, info.Claims), info) assert.EqualError(s.T(), err, "the user alice is already bound to a different OIDC identity") assert.Equal(s.T(), 403, status) @@ -236,7 +240,7 @@ func (s *OIDCSuite) Test_ResolveUser_LinkByUsername_RejectsDifferentIdentity() { func (s *OIDCSuite) Test_ResolveUser_AutoRegister() { info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "newuser"}} - user, status, err := s.a.resolveUser(testIssuer, info) + user, status, err := s.a.resolveUser(newIDToken(testIssuer, info.Claims), info) assert.NoError(s.T(), err) assert.Equal(s.T(), 0, status) @@ -257,7 +261,7 @@ func (s *OIDCSuite) Test_ResolveUser_AutoRegisterDisabled() { s.a.AutoRegister = false info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "newuser"}} - _, status, err := s.a.resolveUser(testIssuer, info) + _, status, err := s.a.resolveUser(newIDToken(testIssuer, info.Claims), info) assert.EqualError(s.T(), err, "user does not exist and auto-registration is disabled") assert.Equal(s.T(), 403, status) @@ -267,7 +271,7 @@ func (s *OIDCSuite) Test_ResolveUser_AutoRegisterDisabled() { func (s *OIDCSuite) Test_ResolveUser_MissingIssuer() { info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "newuser"}} - _, status, err := s.a.resolveUser("", info) + _, status, err := s.a.resolveUser(newIDToken("", info.Claims), info) assert.EqualError(s.T(), err, "issuer claim was empty") assert.Equal(s.T(), 500, status) @@ -276,7 +280,7 @@ func (s *OIDCSuite) Test_ResolveUser_MissingIssuer() { func (s *OIDCSuite) Test_ResolveUser_MissingSubject() { info := &oidc.UserInfo{Claims: map[string]any{"preferred_username": "newuser"}} - _, status, err := s.a.resolveUser(testIssuer, info) + _, status, err := s.a.resolveUser(newIDToken(testIssuer, info.Claims), info) assert.EqualError(s.T(), err, "subject claim was empty") assert.Equal(s.T(), 500, status) @@ -285,7 +289,7 @@ func (s *OIDCSuite) Test_ResolveUser_MissingSubject() { func (s *OIDCSuite) Test_ResolveUser_MissingClaim() { info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{}} - _, status, err := s.a.resolveUser(testIssuer, info) + _, status, err := s.a.resolveUser(newIDToken(testIssuer, info.Claims), info) assert.EqualError(s.T(), err, `username claim "preferred_username" is missing`) assert.Equal(s.T(), 500, status) @@ -294,7 +298,7 @@ func (s *OIDCSuite) Test_ResolveUser_MissingClaim() { func (s *OIDCSuite) Test_ResolveUser_EmptyClaim() { info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": ""}} - _, status, err := s.a.resolveUser(testIssuer, info) + _, status, err := s.a.resolveUser(newIDToken(testIssuer, info.Claims), info) assert.EqualError(s.T(), err, "username claim was empty") assert.Equal(s.T(), 500, status) @@ -303,7 +307,7 @@ func (s *OIDCSuite) Test_ResolveUser_EmptyClaim() { func (s *OIDCSuite) Test_ResolveUser_NilClaim() { info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": nil}} - _, status, err := s.a.resolveUser(testIssuer, info) + _, status, err := s.a.resolveUser(newIDToken(testIssuer, info.Claims), info) assert.EqualError(s.T(), err, "username claim was empty") assert.Equal(s.T(), 500, status) @@ -313,7 +317,7 @@ func (s *OIDCSuite) Test_ResolveUser_CustomClaim() { s.a.UsernameClaim = "email" info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"email": "new@example.com"}} - user, status, err := s.a.resolveUser(testIssuer, info) + user, status, err := s.a.resolveUser(newIDToken(testIssuer, info.Claims), info) assert.NoError(s.T(), err) assert.Equal(s.T(), 0, status) @@ -321,6 +325,28 @@ func (s *OIDCSuite) Test_ResolveUser_CustomClaim() { assert.NotNil(s.T(), user.OIDCID) } +func (s *OIDCSuite) Test_ResolveUser_UsernameFromIDTokenPreferred() { + idTokenClaims := map[string]any{"preferred_username": "from-id-token"} + info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "from-userinfo"}} + + user, status, err := s.a.resolveUser(newIDToken(testIssuer, idTokenClaims), info) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), 0, status) + assert.Equal(s.T(), "from-id-token", user.Name) +} + +func (s *OIDCSuite) Test_ResolveUser_UsernameFromUserInfoFallback() { + idTokenClaims := map[string]any{"email": "unrelated@example.com"} + info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"preferred_username": "from-userinfo"}} + + user, status, err := s.a.resolveUser(newIDToken(testIssuer, idTokenClaims), info) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), 0, status) + assert.Equal(s.T(), "from-userinfo", user.Name) +} + // --- createClient --- func (s *OIDCSuite) Test_CreateClient() { From 3b473fd4acecbc5b965d57a01aafb647ce14d771 Mon Sep 17 00:00:00 2001 From: Gordon Bleux <33967640+UiP9AV6Y@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:35:42 +0200 Subject: [PATCH 2/3] feat: map user admin status from OIDC group claims Co-Authored-By: Jannis Mattheis --- api/oidc.go | 94 ++++++++++++++- api/oidc_test.go | 234 ++++++++++++++++++++++++++++++++++++++ config/config.go | 6 + config/keys.go | 3 + gotify-server.env.example | 24 ++++ 5 files changed, 355 insertions(+), 6 deletions(-) diff --git a/api/oidc.go b/api/oidc.go index 42e140b7..2e27d2a2 100644 --- a/api/oidc.go +++ b/api/oidc.go @@ -10,6 +10,7 @@ import ( "log/slog" "net/http" "net/url" + "slices" "strings" "time" @@ -62,6 +63,9 @@ func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNo Provider: provider, UserChangeNotifier: userChangeNotifier, UsernameClaim: conf.OIDC.UsernameClaim, + GroupsClaim: conf.OIDC.GroupsClaim, + GroupsUser: conf.OIDC.GroupsUser, + GroupsAdmin: conf.OIDC.GroupsAdmin, PasswordStrength: conf.PassStrength, SecureCookie: conf.Server.SecureCookie, AutoRegister: conf.OIDC.AutoRegister, @@ -91,6 +95,9 @@ type OIDCAPI struct { Provider rp.RelyingParty UserChangeNotifier *UserChangeNotifier UsernameClaim string + GroupsClaim string + GroupsUser []string + GroupsAdmin []string PasswordStrength int SecureCookie bool AutoRegister bool @@ -437,7 +444,21 @@ func (a *OIDCAPI) resolveUser(idToken *oidc.IDTokenClaims, info *oidc.UserInfo) if err != nil { return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err) } + + hasAdminGroup, status, err := a.resolvePermission(idToken.Claims, info.Claims) + if err != nil { + log.Err(err).Str("oidc_id", oidcID).Interface("idTokenClaims", idToken.Claims).Interface("userinfoClaims", info.Claims).Msg("OIDC: resolve permission") + return nil, status, err + } + if user != nil { + if len(a.GroupsAdmin) > 0 && user.Admin != hasAdminGroup { + user.Admin = hasAdminGroup + if err := a.DB.UpdateUser(user); err != nil { + return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err) + } + log.Warn().Str("oidc_id", oidcID).Str("username", user.Name).Bool("admin", user.Admin).Msg("OIDC change permission") + } return user, 0, nil } @@ -455,12 +476,12 @@ func (a *OIDCAPI) resolveUser(idToken *oidc.IDTokenClaims, info *oidc.UserInfo) return nil, http.StatusInternalServerError, fmt.Errorf("database error: %w", err) } if byUsername != nil { - return a.linkExistingUser(byUsername, oidcID) + return a.linkExistingUser(byUsername, oidcID, hasAdminGroup) } - return a.registerUser(username, oidcID) + return a.registerUser(username, oidcID, hasAdminGroup) } -func (a *OIDCAPI) linkExistingUser(user *model.User, oidcID string) (*model.User, int, error) { +func (a *OIDCAPI) linkExistingUser(user *model.User, oidcID string, hasAdminGroup bool) (*model.User, int, error) { if !a.LinkByUsername { 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) return nil, http.StatusForbidden, fmt.Errorf("a local user with the username %s already exists and linking by username is disabled", user.Name) @@ -470,21 +491,34 @@ func (a *OIDCAPI) linkExistingUser(user *model.User, oidcID string) (*model.User return nil, http.StatusForbidden, fmt.Errorf("the user %s is already bound to a different OIDC identity", user.Name) } user.OIDCID = &oidcID + if len(a.GroupsAdmin) > 0 { + user.Admin = hasAdminGroup + } if err := a.DB.UpdateUser(user); err != nil { return nil, http.StatusInternalServerError, fmt.Errorf("failed to bind user to OIDC identity: %w", err) } + log.Warn().Str("oidc_id", oidcID).Str("username", user.Name).Bool("admin", user.Admin).Msg("OIDC link by username") return user, 0, nil } -func (a *OIDCAPI) registerUser(username, oidcID string) (*model.User, int, error) { +func (a *OIDCAPI) registerUser(username, oidcID string, hasAdminGroup bool) (*model.User, int, error) { if !a.AutoRegister { return nil, http.StatusForbidden, errors.New("user does not exist and auto-registration is disabled") } - user := &model.User{Name: username, Admin: false, Pass: nil, OIDCID: &oidcID} + user := &model.User{ + Name: username, + Pass: nil, + OIDCID: &oidcID, + } + + if len(a.GroupsAdmin) > 0 { + user.Admin = hasAdminGroup + } + if err := a.DB.CreateUser(user); err != nil { return nil, http.StatusInternalServerError, fmt.Errorf("failed to create user: %w", err) } - log.Info().Str("oidc_id", oidcID).Str("username", user.Name).Msg("OIDC auto registration") + log.Info().Str("oidc_id", oidcID).Str("username", user.Name).Bool("admin", user.Admin).Msg("OIDC auto registration") if err := a.UserChangeNotifier.fireUserAdded(user.ID); err != nil { log.Error().Err(err).Uint("user_id", user.ID).Msg("Could not notify user change") } @@ -516,6 +550,44 @@ func (a *OIDCAPI) popPendingSession(key string) (*pendingOIDCSession, bool) { return nil, false } +func (a *OIDCAPI) resolvePermission(idTokenClaims, userInfoClaims map[string]any) (bool, int, error) { + if a.GroupsClaim == "" { + return false, 0, nil + } + + groupsRaw, ok := lookupClaim(a.GroupsClaim, idTokenClaims, userInfoClaims) + if !ok { + return false, http.StatusInternalServerError, fmt.Errorf("groups claim %q is missing", a.GroupsClaim) + } + + var groups []string + switch groupsRaw := groupsRaw.(type) { + case []string: + groups = groupsRaw + case []any: + for _, groupRaw := range groupsRaw { + group, ok := groupRaw.(string) + if !ok { + return false, http.StatusInternalServerError, fmt.Errorf("groups claim %q contains a non-string element: %#v", a.GroupsClaim, groupRaw) + } + groups = append(groups, group) + } + case string: + groups = append(groups, groupsRaw) + default: + return false, http.StatusInternalServerError, fmt.Errorf("groups claim %q is not a string or string array: %#v", a.GroupsClaim, groupsRaw) + } + + switch { + case containsAny(a.GroupsAdmin, groups): + return true, 0, nil + case len(a.GroupsUser) == 0 || containsAny(a.GroupsUser, groups): + return false, 0, nil + default: + return false, http.StatusForbidden, errors.New("user is not in any allowed group") + } +} + func lookupClaim(name string, idTokenClaims, userInfoClaims map[string]any) (any, bool) { if value, ok := idTokenClaims[name]; ok { return value, true @@ -523,3 +595,13 @@ func lookupClaim(name string, idTokenClaims, userInfoClaims map[string]any) (any value, ok := userInfoClaims[name] return value, ok } + +func containsAny(configured, actual []string) bool { + for _, value := range actual { + if slices.Contains(configured, value) { + return true + } + } + + return false +} diff --git a/api/oidc_test.go b/api/oidc_test.go index afcdf644..913fe21c 100644 --- a/api/oidc_test.go +++ b/api/oidc_test.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "net/url" @@ -313,6 +314,208 @@ func (s *OIDCSuite) Test_ResolveUser_NilClaim() { assert.Equal(s.T(), 500, status) } +func (s *OIDCSuite) Test_ResolveUser_GroupPermissions() { + tests := []struct { + name string + groupsClaim string + groupsUser []string + groupsAdmin []string + groups any + existingUser bool + existingBoundToOIDC bool + existingAdmin bool + linkByUsername bool + wantAdmin bool + wantStatus int + wantErr string + }{ + { + name: "register without claim", + groupsClaim: "", + existingUser: false, + wantAdmin: false, + }, + { + name: "bound without claim", + groupsClaim: "", + existingUser: true, + existingBoundToOIDC: true, + existingAdmin: false, + wantAdmin: false, + }, + { + name: "bound without claim admin", + groupsClaim: "", + existingUser: true, + existingBoundToOIDC: true, + existingAdmin: true, + wantAdmin: true, + }, + { + name: "register with missing claim", + groupsClaim: "groups", + groups: nil, + wantStatus: http.StatusInternalServerError, + wantErr: `groups claim "groups" is missing`, + }, + { + name: "register with invalid claim", + groupsClaim: "groups", + groups: 5, + wantStatus: http.StatusInternalServerError, + wantErr: `groups claim "groups" is not a string or string array: 5`, + }, + { + name: "register with invalid claim element", + groupsClaim: "groups", + groups: []any{"admins", 5}, + wantStatus: http.StatusInternalServerError, + wantErr: `groups claim "groups" contains a non-string element: 5`, + }, + { + name: "register with claim admin", + groupsClaim: "groups", + groupsAdmin: []string{"admins"}, + groups: []any{"admins"}, + wantAdmin: true, + }, + { + name: "register with string array claim admin", + groupsClaim: "groups", + groupsAdmin: []string{"admins"}, + groups: []string{"admins"}, + wantAdmin: true, + }, + { + name: "register with string claim admin", + groupsClaim: "groups", + groupsAdmin: []string{"admins"}, + groups: "admins", + wantAdmin: true, + }, + { + name: "register with claim", + groupsClaim: "groups", + groupsUser: []string{"users"}, + groupsAdmin: []string{"admins"}, + groups: []any{"users"}, + wantAdmin: false, + }, + { + name: "register with claim without user groups", + groupsClaim: "groups", + groupsAdmin: []string{"admins"}, + groups: []any{"other"}, + wantAdmin: false, + }, + { + name: "register with claim in user and admin group admin", + groupsClaim: "groups", + groupsUser: []string{"users"}, + groupsAdmin: []string{"admins"}, + groups: []any{"users", "admins"}, + wantAdmin: true, + }, + { + name: "register with claim without matching group", + groupsClaim: "groups", + groupsUser: []string{"users"}, + groupsAdmin: []string{"admins"}, + groups: []any{"other"}, + wantStatus: http.StatusForbidden, + wantErr: "user is not in any allowed group", + }, + { + name: "bound with claim admin", + groupsClaim: "groups", + groupsAdmin: []string{"admins"}, + groups: []any{"admins"}, + existingUser: true, + existingBoundToOIDC: true, + existingAdmin: false, + wantAdmin: true, + }, + { + name: "bound with claim", + groupsClaim: "groups", + groupsUser: []string{"users"}, + groupsAdmin: []string{"admins"}, + groups: []any{"users"}, + existingUser: true, + existingBoundToOIDC: true, + existingAdmin: true, + wantAdmin: false, + }, + { + name: "bound with claim without matching groups", + groupsClaim: "groups", + groupsUser: []string{"users"}, + groupsAdmin: []string{"admins"}, + groups: []any{"oops"}, + existingUser: true, + existingBoundToOIDC: true, + existingAdmin: true, + wantStatus: http.StatusForbidden, + wantErr: "user is not in any allowed group", + }, + { + name: "link with claim admin", + groupsClaim: "groups", + groupsAdmin: []string{"admins"}, + groups: []any{"admins"}, + existingUser: true, + existingAdmin: false, + linkByUsername: true, + wantAdmin: true, + }, + } + + for i, tc := range tests { + s.Run(tc.name, func() { + username := fmt.Sprintf("user-%d", i) + subject := fmt.Sprintf("sub-%d", i) + oidcID := testIssuer + "#" + subject + + if tc.existingUser { + user := &model.User{Name: username, Admin: tc.existingAdmin} + if tc.existingBoundToOIDC { + user.OIDCID = &oidcID + } + err := s.db.CreateUser(user) + assert.NoError(s.T(), err) + } + + s.a.GroupsClaim = tc.groupsClaim + s.a.GroupsUser = tc.groupsUser + s.a.GroupsAdmin = tc.groupsAdmin + s.a.LinkByUsername = tc.linkByUsername + + claims := map[string]any{"preferred_username": username} + if tc.groups != nil { + claims["groups"] = tc.groups + } + info := &oidc.UserInfo{Subject: subject, Claims: claims} + + user, status, err := s.a.resolveUser(newIDToken(testIssuer, info.Claims), info) + + if tc.wantErr != "" { + assert.EqualError(s.T(), err, tc.wantErr) + assert.Equal(s.T(), tc.wantStatus, status) + return + } + assert.NoError(s.T(), err) + assert.Equal(s.T(), 0, status) + assert.Equal(s.T(), tc.wantAdmin, user.Admin) + + dbUser, err := s.db.GetUserByOIDC(oidcID) + assert.NoError(s.T(), err) + if assert.NotNil(s.T(), dbUser) { + assert.Equal(s.T(), tc.wantAdmin, dbUser.Admin) + } + }) + } +} + func (s *OIDCSuite) Test_ResolveUser_CustomClaim() { s.a.UsernameClaim = "email" @@ -347,6 +550,37 @@ func (s *OIDCSuite) Test_ResolveUser_UsernameFromUserInfoFallback() { assert.Equal(s.T(), "from-userinfo", user.Name) } +func (s *OIDCSuite) Test_ResolveUser_GroupsFromIDTokenPreferred() { + s.a.GroupsClaim = "groups" + s.a.GroupsAdmin = []string{"admins"} + + idTokenClaims := map[string]any{"groups": []any{"admins"}} + info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{ + "preferred_username": "newuser", + "groups": []any{"users"}, + }} + + user, status, err := s.a.resolveUser(newIDToken(testIssuer, idTokenClaims), info) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), 0, status) + assert.True(s.T(), user.Admin) +} + +func (s *OIDCSuite) Test_ResolveUser_GroupsFromUserInfoFallback() { + s.a.GroupsClaim = "groups" + s.a.GroupsAdmin = []string{"admins"} + + idTokenClaims := map[string]any{"preferred_username": "newuser"} + info := &oidc.UserInfo{Subject: "sub-1", Claims: map[string]any{"groups": []any{"admins"}}} + + user, status, err := s.a.resolveUser(newIDToken(testIssuer, idTokenClaims), info) + + assert.NoError(s.T(), err) + assert.Equal(s.T(), 0, status) + assert.True(s.T(), user.Admin) +} + // --- createClient --- func (s *OIDCSuite) Test_CreateClient() { diff --git a/config/config.go b/config/config.go index 5ff1b9eb..ffa7c16b 100644 --- a/config/config.go +++ b/config/config.go @@ -64,6 +64,9 @@ type OIDC struct { ClientID string ClientSecret string UsernameClaim string + GroupsClaim string + GroupsUser []string + GroupsAdmin []string RedirectURL string AutoRegister bool LinkByUsername bool @@ -181,6 +184,9 @@ func Get() (*Configuration, []FutureLog) { add(parseString(&c.OIDC.ClientID, EnvOIDCClientID)) add(parseString(&c.OIDC.ClientSecret, EnvOIDCClientSecret)) add(parseString(&c.OIDC.UsernameClaim, EnvOIDCUsernameClaim)) + add(parseString(&c.OIDC.GroupsClaim, EnvOIDCGroupsClaim)) + add(parseList(&c.OIDC.GroupsUser, EnvOIDCGroupsUser)) + add(parseList(&c.OIDC.GroupsAdmin, EnvOIDCGroupsAdmin)) add(parseString(&c.OIDC.RedirectURL, EnvOIDCRedirectURL)) add(parseBool(&c.OIDC.AutoRegister, EnvOIDCAutoRegister)) add(parseBool(&c.OIDC.LinkByUsername, EnvOIDCLinkByUsername)) diff --git a/config/keys.go b/config/keys.go index b922c8a6..e56919fc 100644 --- a/config/keys.go +++ b/config/keys.go @@ -38,6 +38,9 @@ const ( EnvOIDCClientID = "GOTIFY_OIDC_CLIENTID" EnvOIDCClientSecret = "GOTIFY_OIDC_CLIENTSECRET" EnvOIDCUsernameClaim = "GOTIFY_OIDC_USERNAMECLAIM" + EnvOIDCGroupsClaim = "GOTIFY_OIDC_GROUPS_CLAIM" + EnvOIDCGroupsUser = "GOTIFY_OIDC_GROUPS_USER" + EnvOIDCGroupsAdmin = "GOTIFY_OIDC_GROUPS_ADMIN" EnvOIDCRedirectURL = "GOTIFY_OIDC_REDIRECTURL" EnvOIDCAutoRegister = "GOTIFY_OIDC_AUTOREGISTER" EnvOIDCLinkByUsername = "GOTIFY_OIDC_LINK_BY_USERNAME" diff --git a/gotify-server.env.example b/gotify-server.env.example index 269ec1a6..3912d169 100644 --- a/gotify-server.env.example +++ b/gotify-server.env.example @@ -224,6 +224,30 @@ # Example: email # GOTIFY_OIDC_USERNAMECLAIM=preferred_username +# OIDC ID-token claim containing the group memberships as a list of strings. +# The claim must be part of the requested scopes (GOTIFY_OIDC_SCOPES). When +# set, admin permissions are managed by the identity provider on every login, +# overwriting manual changes. +# +# Type: text +# Example: groups +# GOTIFY_OIDC_GROUPS_CLAIM= + +# Groups allowed to log in via OIDC and whose members are assigned user +# permissions. When empty, every user authenticated by the identity provider +# may log in. +# +# Type: text-list +# Example: messenger,gotify +# GOTIFY_OIDC_GROUPS_USER= + +# Groups allowed to log in via OIDC and whose members are assigned admin +# permissions. +# +# Type: text-list +# Example: admins,gotify-admins +# GOTIFY_OIDC_GROUPS_ADMIN= + # OIDC scopes to request from the identity provider. # Type: text-list # GOTIFY_OIDC_SCOPES=openid,profile,email From 585b9e366a0453c865678b5d9de58adb9d6c6893 Mon Sep 17 00:00:00 2001 From: Jannis Mattheis Date: Sun, 23 Aug 2026 11:34:07 +0200 Subject: [PATCH 3/3] chore: update oidc readme --- test/oidc/README.md | 30 +++++++++++--------- test/oidc/authelia/config/configuration.yml | 1 + test/oidc/authelia/config/users_database.yml | 2 ++ 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/test/oidc/README.md b/test/oidc/README.md index b1377324..2bcd88f5 100644 --- a/test/oidc/README.md +++ b/test/oidc/README.md @@ -5,13 +5,12 @@ Check config in ./dex/config/dex.conf and do a `docker-compose up -d`. Use this gotify config. -``` -oidc: - enabled: true - issuer: http://127.0.0.1:5556/dex - clientid: gotify - clientsecret: secret - redirecturl: http://127.0.0.1:8080/auth/oidc/callback +```ini +GOTIFY_OIDC_ENABLED=true +GOTIFY_OIDC_ISSUER=http://127.0.0.1:5556/dex +GOTIFY_OIDC_CLIENTID=gotify +GOTIFY_OIDC_CLIENTSECRET=secret +GOTIFY_OIDC_REDIRECTURL=http://127.0.0.1:8080/auth/oidc/callback ``` When testing external apps like gotify/android change every occurence of @@ -29,13 +28,16 @@ openssl req -x509 -newkey rsa:4096 -nodes -keyout ./authelia/config/key -out ./a Check config in ./authelia/config/configuration.yml and do a `docker-compose up -d`. Use this gotify config. -``` -oidc: - enabled: true - issuer: https://127.0.0.1:9091 - clientid: gotify - clientsecret: secret - redirecturl: http://127.0.0.1:8080/auth/oidc/callback +```ini +GOTIFY_OIDC_ENABLED=true +GOTIFY_OIDC_ISSUER=https://127.0.0.1:9091 +GOTIFY_OIDC_CLIENTID=gotify +GOTIFY_OIDC_CLIENTSECRET=secret +GOTIFY_OIDC_REDIRECTURL=http://127.0.0.1:8080/auth/oidc/callback +GOTIFY_OIDC_SCOPES=openid,profile,email,groups +# GOTIFY_OIDC_GROUPS_CLAIM=groups +# GOTIFY_OIDC_GROUPS_USER= +# GOTIFY_OIDC_GROUPS_ADMIN=authelia-group ``` When testing external apps like gotify/android change every occurence of diff --git a/test/oidc/authelia/config/configuration.yml b/test/oidc/authelia/config/configuration.yml index d45a8f0a..a2b5ee83 100644 --- a/test/oidc/authelia/config/configuration.yml +++ b/test/oidc/authelia/config/configuration.yml @@ -116,6 +116,7 @@ identity_providers: - 'openid' - 'profile' - 'email' + - 'groups' response_types: - 'code' grant_types: diff --git a/test/oidc/authelia/config/users_database.yml b/test/oidc/authelia/config/users_database.yml index fb545ec2..25f6f607 100644 --- a/test/oidc/authelia/config/users_database.yml +++ b/test/oidc/authelia/config/users_database.yml @@ -3,3 +3,5 @@ users: displayname: "user" password: "$2a$10$JoPsdyz7c9Q1bqhw1.bHrefdNlOWY0/22VQZh33X9vDEl3Du1utqe" # password email: user@gotify.net + groups: + - authelia-group