Skip to content

Commit d764fd9

Browse files
committed
feat: auto-redirect to OIDC IdP on logout (#991)
Add GOTIFY_OIDC_AUTO_REDIRECT to skip the login page and redirect straight to the configured OIDC provider. Only takes effect when local auth is disabled, since local login would otherwise be unreachable. Add GOTIFY_OIDC_AUTO_REDIRECT_REQUIRE_REAUTH (default false) to send prompt=login on that redirect, so logging out of Gotify doesn't silently log the user back in via an existing IdP session. Does not end that IdP session, so other apps using it are unaffected. Wire both flags through gotifyinfo/injected UI config and the WebUI login page, which now redirects instead of showing the OIDC button when enabled.
1 parent ef41eba commit d764fd9

14 files changed

Lines changed: 326 additions & 25 deletions

File tree

api/oidc.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNo
6666
SecureCookie: conf.Server.SecureCookie,
6767
AutoRegister: conf.OIDC.AutoRegister,
6868
LinkByUsername: conf.OIDC.LinkByUsername,
69+
PromptLogin: conf.OIDC.RequireReauth,
6970
pendingSessions: decaymap.NewDecayMap[string, *pendingOIDCSession](time.Now(), pendingSessionMaxAge),
7071
}
7172
}
@@ -94,6 +95,7 @@ type OIDCAPI struct {
9495
SecureCookie bool
9596
AutoRegister bool
9697
LinkByUsername bool
98+
PromptLogin bool
9799
pendingSessions *decaymap.DecayMap[string, *pendingOIDCSession]
98100
}
99101

@@ -131,7 +133,11 @@ func (a *OIDCAPI) LoginHandler() gin.HandlerFunc {
131133
return
132134
}
133135
a.pendingSessions.Set(time.Now(), state, &pendingOIDCSession{ClientName: clientName, CreatedAt: time.Now()})
134-
rp.AuthURLHandler(func() string { return state }, a.Provider)(w, r)
136+
var urlParams []rp.URLParamOpt
137+
if a.PromptLogin {
138+
urlParams = append(urlParams, rp.WithPromptURLParam("login"))
139+
}
140+
rp.AuthURLHandler(func() string { return state }, a.Provider, urlParams...)(w, r)
135141
})
136142
}
137143

api/oidc_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
package api
22

33
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
47
"net/http/httptest"
8+
"net/url"
59
"strings"
610
"testing"
711
"time"
@@ -14,6 +18,7 @@ import (
1418
"github.com/gotify/server/v2/test/testdb"
1519
"github.com/stretchr/testify/assert"
1620
"github.com/stretchr/testify/suite"
21+
"github.com/zitadel/oidc/v3/pkg/client/rp"
1722
"github.com/zitadel/oidc/v3/pkg/oidc"
1823
)
1924

@@ -62,6 +67,60 @@ func (s *OIDCSuite) Test_GenerateState_Unique() {
6267
assert.NotEqual(s.T(), s1, s2)
6368
}
6469

70+
// --- LoginHandler ---
71+
72+
func newDiscoveryServer(t *testing.T) *httptest.Server {
73+
t.Helper()
74+
mux := http.NewServeMux()
75+
server := httptest.NewServer(mux)
76+
t.Cleanup(server.Close)
77+
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
78+
w.Header().Set("Content-Type", "application/json")
79+
_ = json.NewEncoder(w).Encode(map[string]any{
80+
"issuer": server.URL,
81+
"authorization_endpoint": server.URL + "/authorize",
82+
"token_endpoint": server.URL + "/token",
83+
"userinfo_endpoint": server.URL + "/userinfo",
84+
"jwks_uri": server.URL + "/keys",
85+
})
86+
})
87+
return server
88+
}
89+
90+
func (s *OIDCSuite) Test_LoginHandler_PromptLogin() {
91+
issuer := newDiscoveryServer(s.T())
92+
93+
provider, err := rp.NewRelyingPartyOIDC(
94+
context.Background(), issuer.URL, "client", "secret", "https://gotify.example/callback", []string{"openid"},
95+
)
96+
assert.NoError(s.T(), err)
97+
s.a.Provider = provider
98+
99+
tests := []struct {
100+
name string
101+
promptLogin bool
102+
wantPrompt string
103+
}{
104+
{name: "auto redirect enabled", promptLogin: true, wantPrompt: "login"},
105+
{name: "auto redirect disabled", promptLogin: false, wantPrompt: ""},
106+
}
107+
108+
for _, tc := range tests {
109+
s.Run(tc.name, func() {
110+
s.a.PromptLogin = tc.promptLogin
111+
recorder := httptest.NewRecorder()
112+
ctx, _ := gin.CreateTestContext(recorder)
113+
ctx.Request = httptest.NewRequest("GET", "/auth/oidc/login?name=testclient", nil)
114+
115+
s.a.LoginHandler()(ctx)
116+
117+
location, err := url.Parse(recorder.Header().Get("Location"))
118+
assert.NoError(s.T(), err)
119+
assert.Equal(s.T(), tc.wantPrompt, location.Query().Get("prompt"))
120+
})
121+
}
122+
}
123+
65124
func (s *OIDCSuite) Test_ResolveUser_ReturningUser_MatchedByOIDCID() {
66125
oidcID := testIssuer + "#sub-1"
67126
s.db.CreateUser(&model.User{ID: 1, Name: "alice", OIDCID: &oidcID})

config/config.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ type OIDC struct {
6969
LinkByUsername bool
7070
Scopes []string
7171
IDPName string
72+
AutoRedirect bool
73+
RequireReauth bool
7274
}
7375

7476
type Configuration struct {
@@ -183,6 +185,8 @@ func Get() (*Configuration, []FutureLog) {
183185
add(parseBool(&c.OIDC.LinkByUsername, EnvOIDCLinkByUsername))
184186
add(parseList(&c.OIDC.Scopes, EnvOIDCScopes))
185187
add(parseString(&c.OIDC.IDPName, EnvOIDCIDPName))
188+
add(parseBool(&c.OIDC.AutoRedirect, EnvOIDCAutoRedirect))
189+
add(parseBool(&c.OIDC.RequireReauth, EnvOIDCAutoRedirectRequireReauth))
186190

187191
add(parseString(&c.NoColor, EnvNoColor))
188192

@@ -194,6 +198,20 @@ func Get() (*Configuration, []FutureLog) {
194198
if c.Registration && !c.LocalAuthEnabled {
195199
logs = append(logs, futureFatal("registration requires local authentication to be enabled"))
196200
}
201+
if c.OIDC.AutoRedirect && c.LocalAuthEnabled {
202+
logs = append(logs, futureWarn(
203+
"GOTIFY_OIDC_AUTO_REDIRECT has no effect while local authentication is enabled",
204+
))
205+
}
206+
c.OIDC.AutoRedirect = c.OIDC.AutoRedirect && !c.LocalAuthEnabled
207+
208+
if c.OIDC.RequireReauth && !c.OIDC.AutoRedirect {
209+
logs = append(logs, futureWarn(
210+
"GOTIFY_OIDC_AUTO_REDIRECT_REQUIRE_REAUTH has no effect unless GOTIFY_OIDC_AUTO_REDIRECT is also in effect",
211+
))
212+
}
213+
c.OIDC.RequireReauth = c.OIDC.RequireReauth && c.OIDC.AutoRedirect
214+
197215
return c, logs
198216
}
199217

config/config_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,117 @@ func TestLocalAuthDisabled(t *testing.T) {
8484
}
8585
}
8686

87+
func TestOIDCAutoRedirect(t *testing.T) {
88+
tests := []struct {
89+
name string
90+
localAuthEnabled string
91+
want bool
92+
warns []FutureLog
93+
}{
94+
{
95+
name: "local auth disabled",
96+
localAuthEnabled: "false",
97+
want: true,
98+
},
99+
{
100+
name: "local auth enabled",
101+
localAuthEnabled: "true",
102+
want: false,
103+
warns: []FutureLog{futureWarn(
104+
"GOTIFY_OIDC_AUTO_REDIRECT has no effect while local authentication is enabled",
105+
)},
106+
},
107+
}
108+
109+
for _, tc := range tests {
110+
t.Run(tc.name, func(t *testing.T) {
111+
mode.Set(mode.TestDev)
112+
t.Setenv(EnvOIDCAutoRedirect, "true")
113+
t.Setenv(EnvLocalAuthEnabled, tc.localAuthEnabled)
114+
t.Setenv(EnvOIDCEnabled, "true")
115+
116+
conf, logs := Get()
117+
assert.Equal(t, tc.want, conf.OIDC.AutoRedirect)
118+
119+
var warns []FutureLog
120+
for _, entry := range logs {
121+
if entry.Level == zerolog.WarnLevel {
122+
warns = append(warns, entry)
123+
}
124+
}
125+
assert.Equal(t, tc.warns, warns)
126+
})
127+
}
128+
}
129+
130+
func TestOIDCPromptLogin(t *testing.T) {
131+
tests := []struct {
132+
name string
133+
autoRedirect string
134+
localAuthEnabled string
135+
requireReauth string
136+
want bool
137+
warns []FutureLog
138+
}{
139+
{
140+
name: "auto redirect and require reauth enabled",
141+
autoRedirect: "true",
142+
localAuthEnabled: "false",
143+
requireReauth: "true",
144+
want: true,
145+
},
146+
{
147+
name: "auto redirect enabled, require reauth left at default",
148+
autoRedirect: "true",
149+
localAuthEnabled: "false",
150+
requireReauth: "false",
151+
want: false,
152+
},
153+
{
154+
name: "require reauth enabled but auto redirect disabled",
155+
autoRedirect: "false",
156+
localAuthEnabled: "false",
157+
requireReauth: "true",
158+
want: false,
159+
warns: []FutureLog{futureWarn(
160+
"GOTIFY_OIDC_AUTO_REDIRECT_REQUIRE_REAUTH has no effect unless GOTIFY_OIDC_AUTO_REDIRECT is also in effect",
161+
)},
162+
},
163+
{
164+
name: "require reauth enabled but local auth enabled",
165+
autoRedirect: "true",
166+
localAuthEnabled: "true",
167+
requireReauth: "true",
168+
want: false,
169+
warns: []FutureLog{
170+
futureWarn("GOTIFY_OIDC_AUTO_REDIRECT has no effect while local authentication is enabled"),
171+
futureWarn("GOTIFY_OIDC_AUTO_REDIRECT_REQUIRE_REAUTH has no effect unless GOTIFY_OIDC_AUTO_REDIRECT is also in effect"),
172+
},
173+
},
174+
}
175+
176+
for _, tc := range tests {
177+
t.Run(tc.name, func(t *testing.T) {
178+
mode.Set(mode.TestDev)
179+
t.Setenv(EnvOIDCEnabled, "true")
180+
t.Setenv(EnvOIDCAutoRedirect, tc.autoRedirect)
181+
t.Setenv(EnvLocalAuthEnabled, tc.localAuthEnabled)
182+
t.Setenv(EnvOIDCAutoRedirectRequireReauth, tc.requireReauth)
183+
184+
conf, logs := Get()
185+
assert.Equal(t, tc.want, conf.OIDC.RequireReauth)
186+
187+
var warns []FutureLog
188+
for _, entry := range logs {
189+
if entry.Level == zerolog.WarnLevel {
190+
warns = append(warns, entry)
191+
}
192+
}
193+
assert.Equal(t, tc.warns, warns)
194+
})
195+
}
196+
}
197+
87198
func TestFile(t *testing.T) {
88199
mode.Set(mode.TestDev)
89200
dir := t.TempDir()

config/error.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,10 @@ func futureFatal(msg string) FutureLog {
1515
Msg: msg,
1616
}
1717
}
18+
19+
func futureWarn(msg string) FutureLog {
20+
return FutureLog{
21+
Level: zerolog.WarnLevel,
22+
Msg: msg,
23+
}
24+
}

config/keys.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,7 @@ const (
4343
EnvLocalAuthEnabled = "GOTIFY_LOCALAUTH_ENABLED"
4444
EnvOIDCScopes = "GOTIFY_OIDC_SCOPES"
4545
EnvOIDCIDPName = "GOTIFY_OIDC_IDP_NAME"
46+
EnvOIDCAutoRedirect = "GOTIFY_OIDC_AUTO_REDIRECT"
47+
EnvOIDCAutoRedirectRequireReauth = "GOTIFY_OIDC_AUTO_REDIRECT_REQUIRE_REAUTH"
4648
EnvNoColor = "NOCOLOR"
4749
)

docs/spec.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2948,7 +2948,8 @@
29482948
"register",
29492949
"localAuth",
29502950
"oidc",
2951-
"oidcIdpName"
2951+
"oidcIdpName",
2952+
"oidcAutoRedirect"
29522953
],
29532954
"properties": {
29542955
"localAuth": {
@@ -2963,6 +2964,12 @@
29632964
"x-go-name": "Oidc",
29642965
"example": true
29652966
},
2967+
"oidcAutoRedirect": {
2968+
"description": "If the WebUI should automatically redirect to the OIDC identity\nprovider instead of showing the login page. Always false while local\nauthentication is enabled.",
2969+
"type": "boolean",
2970+
"x-go-name": "OIDCAutoRedirect",
2971+
"example": false
2972+
},
29662973
"oidcIdpName": {
29672974
"description": "Name of the OIDC identity provider.",
29682975
"type": "string",

gotify-server.env.example

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,23 @@
224224
# Type: text-list
225225
# GOTIFY_OIDC_SCOPES=openid,profile,email
226226

227+
# Automatically redirect to the OIDC identity provider instead of showing the
228+
# login page. Only takes effect if GOTIFY_OIDC_ENABLED is true and
229+
# GOTIFY_LOCALAUTH_ENABLED is false, since local login would otherwise be
230+
# unreachable.
231+
#
232+
# Type: boolean
233+
# GOTIFY_OIDC_AUTO_REDIRECT=false
234+
235+
# Send prompt=login to the OIDC provider on auto-redirect, so that logging
236+
# out of Gotify does not silently and invisibly log the user back in via an
237+
# existing IdP session. This does not end that IdP session, so other
238+
# applications using it are unaffected. Only takes effect if
239+
# GOTIFY_OIDC_AUTO_REDIRECT is also in effect.
240+
#
241+
# Type: boolean
242+
# GOTIFY_OIDC_AUTO_REDIRECT_REQUIRE_REAUTH=false
243+
227244
# Enable authentication via username and password.
228245
# Type: boolean
229246
# GOTIFY_LOCALAUTH_ENABLED=true

model/gotifyinfo.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,11 @@ type GotifyInfo struct {
2929
// required: true
3030
// example: OIDC
3131
OIDCIDPName string `json:"oidcIdpName"`
32+
// If the WebUI should automatically redirect to the OIDC identity
33+
// provider instead of showing the login page. Always false while local
34+
// authentication is enabled.
35+
//
36+
// required: true
37+
// example: false
38+
OIDCAutoRedirect bool `json:"oidcAutoRedirect"`
3239
}

router/router.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
120120
userChangeNotifier.OnUserDeleted(pluginManager.RemoveUser)
121121
userChangeNotifier.OnUserAdded(pluginManager.InitializeForUserID)
122122

123-
ui.Register(g, *vInfo, conf.Registration, conf.LocalAuthEnabled, conf.OIDC.Enabled, conf.OIDC.IDPName)
123+
ui.Register(g, *vInfo, conf.Registration, conf.LocalAuthEnabled, conf.OIDC.Enabled, conf.OIDC.IDPName, conf.OIDC.AutoRedirect)
124124

125125
if conf.OIDC.Enabled {
126126
oidcHandler := api.NewOIDC(conf, db, userChangeNotifier)
@@ -192,11 +192,12 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
192192
// $ref: "#/definitions/GotifyInfo"
193193
g.GET("gotifyinfo", func(ctx *gin.Context) {
194194
ctx.JSON(200, &model.GotifyInfo{
195-
Version: vInfo.Version,
196-
Oidc: conf.OIDC.Enabled,
197-
Register: conf.Registration,
198-
LocalAuth: conf.LocalAuthEnabled,
199-
OIDCIDPName: conf.OIDC.IDPName,
195+
Version: vInfo.Version,
196+
Oidc: conf.OIDC.Enabled,
197+
Register: conf.Registration,
198+
LocalAuth: conf.LocalAuthEnabled,
199+
OIDCIDPName: conf.OIDC.IDPName,
200+
OIDCAutoRedirect: conf.OIDC.AutoRedirect,
200201
})
201202
})
202203

0 commit comments

Comments
 (0)