Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions api/oidc.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ func NewOIDC(conf *config.Configuration, db *database.GormDatabase, userChangeNo
SecureCookie: conf.Server.SecureCookie,
AutoRegister: conf.OIDC.AutoRegister,
LinkByUsername: conf.OIDC.LinkByUsername,
Prompt: conf.OIDC.Prompt,
pendingSessions: decaymap.NewDecayMap[string, *pendingOIDCSession](time.Now(), pendingSessionMaxAge),
}
}
Expand Down Expand Up @@ -94,6 +95,7 @@ type OIDCAPI struct {
SecureCookie bool
AutoRegister bool
LinkByUsername bool
Prompt string
pendingSessions *decaymap.DecayMap[string, *pendingOIDCSession]
}

Expand Down Expand Up @@ -131,7 +133,7 @@ func (a *OIDCAPI) LoginHandler() gin.HandlerFunc {
return
}
a.pendingSessions.Set(time.Now(), state, &pendingOIDCSession{ClientName: clientName, CreatedAt: time.Now()})
rp.AuthURLHandler(func() string { return state }, a.Provider)(w, r)
rp.AuthURLHandler(func() string { return state }, a.Provider, a.promptURLParams()...)(w, r)
})
}

Expand Down Expand Up @@ -174,7 +176,16 @@ func (a *OIDCAPI) ElevateHandler(ctx *gin.Context) {
return
}
a.pendingSessions.Set(time.Now(), state, &pendingOIDCSession{CreatedAt: time.Now(), Elevate: &elevate})
rp.AuthURLHandler(func() string { return state }, a.Provider)(ctx.Writer, ctx.Request)
rp.AuthURLHandler(func() string { return state }, a.Provider, a.promptURLParams()...)(ctx.Writer, ctx.Request)
}

// promptURLParams returns the `prompt` URL param option to send to the OIDC
// provider, if GOTIFY_OIDC_PROMPT is set to a non-empty value.
func (a *OIDCAPI) promptURLParams() []rp.URLParamOpt {
if a.Prompt == "" {
return nil
}
return []rp.URLParamOpt{rp.WithPromptURLParam(a.Prompt)}
}

// swagger:operation GET /auth/oidc/callback oidc oidcCallback
Expand Down
81 changes: 81 additions & 0 deletions api/oidc_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package api

import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
Expand All @@ -14,6 +18,7 @@ import (
"github.com/gotify/server/v2/test/testdb"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/oidc"
)

Expand Down Expand Up @@ -62,6 +67,82 @@ func (s *OIDCSuite) Test_GenerateState_Unique() {
assert.NotEqual(s.T(), s1, s2)
}

// --- LoginHandler ---

func newDiscoveryServer(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
server := httptest.NewServer(mux)
t.Cleanup(server.Close)
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"issuer": server.URL,
"authorization_endpoint": server.URL + "/authorize",
"token_endpoint": server.URL + "/token",
"userinfo_endpoint": server.URL + "/userinfo",
"jwks_uri": server.URL + "/keys",
})
})
return server
}

func (s *OIDCSuite) Test_LoginHandler_Prompt() {
issuer := newDiscoveryServer(s.T())

provider, err := rp.NewRelyingPartyOIDC(
context.Background(), issuer.URL, "client", "secret", "https://gotify.example/callback", []string{"openid"},
)
assert.NoError(s.T(), err)
s.a.Provider = provider

tests := []struct {
name string
prompt string
wantPrompt string
}{
{name: "default prompt", prompt: "login", wantPrompt: "login"},
{name: "custom prompt", prompt: "consent", wantPrompt: "consent"},
{name: "empty prompt disables the parameter", prompt: "", wantPrompt: ""},
}

for _, tc := range tests {
s.Run(tc.name, func() {
s.a.Prompt = tc.prompt
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest("GET", "/auth/oidc/login?name=testclient", nil)

s.a.LoginHandler()(ctx)

location, err := url.Parse(recorder.Header().Get("Location"))
assert.NoError(s.T(), err)
assert.Equal(s.T(), tc.wantPrompt, location.Query().Get("prompt"))
})
}
}

func (s *OIDCSuite) Test_ElevateHandler_Prompt() {
issuer := newDiscoveryServer(s.T())

provider, err := rp.NewRelyingPartyOIDC(
context.Background(), issuer.URL, "client", "secret", "https://gotify.example/callback", []string{"openid"},
)
assert.NoError(s.T(), err)
s.a.Provider = provider
s.a.Prompt = "login"

recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest("GET", "/auth/oidc/elevate?id=1&durationSeconds=60", nil)

s.a.ElevateHandler(ctx)

location, err := url.Parse(recorder.Header().Get("Location"))
assert.NoError(s.T(), err)
assert.Equal(s.T(), "login", location.Query().Get("prompt"))
}

func (s *OIDCSuite) Test_ResolveUser_ReturningUser_MatchedByOIDCID() {
oidcID := testIssuer + "#sub-1"
s.db.CreateUser(&model.User{ID: 1, Name: "alice", OIDCID: &oidcID})
Expand Down
5 changes: 5 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ type OIDC struct {
LinkByUsername bool
Scopes []string
IDPName string
AutoRedirect bool
Prompt string
}

type Configuration struct {
Expand Down Expand Up @@ -119,6 +121,7 @@ func Get() (*Configuration, []FutureLog) {
AutoRegister: true,
Scopes: []string{"openid", "profile", "email"},
IDPName: "OIDC",
Prompt: "login",
},
}

Expand Down Expand Up @@ -183,6 +186,8 @@ func Get() (*Configuration, []FutureLog) {
add(parseBool(&c.OIDC.LinkByUsername, EnvOIDCLinkByUsername))
add(parseList(&c.OIDC.Scopes, EnvOIDCScopes))
add(parseString(&c.OIDC.IDPName, EnvOIDCIDPName))
add(parseBool(&c.OIDC.AutoRedirect, EnvOIDCAutoRedirect))
add(parseOIDCPrompt(&c.OIDC.Prompt, EnvOIDCPrompt))

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

Expand Down
120 changes: 120 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,126 @@ func TestLocalAuthDisabled(t *testing.T) {
}
}

func TestOIDCAutoRedirect(t *testing.T) {
tests := []struct {
name string
localAuthEnabled string
}{
{name: "local auth disabled", localAuthEnabled: "false"},
{name: "local auth enabled", localAuthEnabled: "true"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mode.Set(mode.TestDev)
t.Setenv(EnvOIDCAutoRedirect, "true")
t.Setenv(EnvLocalAuthEnabled, tc.localAuthEnabled)
t.Setenv(EnvOIDCEnabled, "true")

conf, logs := Get()
// GOTIFY_OIDC_AUTO_REDIRECT always takes effect, regardless of
// GOTIFY_LOCALAUTH_ENABLED. Local admins opt out per-request via
// the WebUI's ?redirect=false login URL param.
assert.True(t, conf.OIDC.AutoRedirect)

var warns []FutureLog
for _, entry := range logs {
if entry.Level == zerolog.WarnLevel {
warns = append(warns, entry)
}
}
assert.Empty(t, warns)
})
}
}

func TestOIDCPrompt(t *testing.T) {
tests := []struct {
name string
autoRedirect string
localAuthEnabled string
setPrompt bool
prompt string
want string
}{
{
name: "defaults to login regardless of auto redirect",
autoRedirect: "false",
localAuthEnabled: "true",
want: "login",
},
{
name: "custom prompt independent of auto redirect",
autoRedirect: "false",
localAuthEnabled: "true",
setPrompt: true,
prompt: "consent",
want: "consent",
},
{
name: "empty prompt disables the parameter",
autoRedirect: "true",
localAuthEnabled: "false",
setPrompt: true,
prompt: "",
want: "",
},
{
name: "space-delimited combination of valid values",
autoRedirect: "false",
localAuthEnabled: "true",
setPrompt: true,
prompt: "login consent",
want: "login consent",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mode.Set(mode.TestDev)
t.Setenv(EnvOIDCEnabled, "true")
t.Setenv(EnvOIDCAutoRedirect, tc.autoRedirect)
t.Setenv(EnvLocalAuthEnabled, tc.localAuthEnabled)
if tc.setPrompt {
t.Setenv(EnvOIDCPrompt, tc.prompt)
}

conf, _ := Get()
assert.Equal(t, tc.want, conf.OIDC.Prompt)
})
}
}

func TestOIDCPromptInvalid(t *testing.T) {
tests := []struct {
name string
prompt string
}{
{name: "unknown value", prompt: "bogus"},
{name: "unknown value combined with valid ones", prompt: "login bogus"},
{name: "none combined with other values", prompt: "none login"},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mode.Set(mode.TestDev)
t.Setenv(EnvOIDCPrompt, tc.prompt)

conf, logs := Get()
// The invalid value is rejected and the default is kept.
assert.Equal(t, "login", conf.OIDC.Prompt)

var fatals []FutureLog
for _, entry := range logs {
if entry.Level == zerolog.FatalLevel {
fatals = append(fatals, entry)
}
}
assert.Len(t, fatals, 1)
})
}
}

func TestFile(t *testing.T) {
mode.Set(mode.TestDev)
dir := t.TempDir()
Expand Down
2 changes: 2 additions & 0 deletions config/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,7 @@ const (
EnvLocalAuthEnabled = "GOTIFY_LOCALAUTH_ENABLED"
EnvOIDCScopes = "GOTIFY_OIDC_SCOPES"
EnvOIDCIDPName = "GOTIFY_OIDC_IDP_NAME"
EnvOIDCAutoRedirect = "GOTIFY_OIDC_AUTO_REDIRECT"
EnvOIDCPrompt = "GOTIFY_OIDC_PROMPT"
EnvNoColor = "NOCOLOR"
)
39 changes: 39 additions & 0 deletions config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,42 @@ func parseLogLevel(target *LogLevel, env string) error {
}
return target.Decode(raw)
}

// validOIDCPromptValues are the `prompt` values defined by the OIDC spec
// (https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest).
var validOIDCPromptValues = map[string]bool{
"none": true,
"login": true,
"consent": true,
"select_account": true,
}

// parseOIDCPrompt parses GOTIFY_OIDC_PROMPT. Per the OIDC spec, it must be
// empty (to omit the prompt parameter) or a space-delimited combination of
// none, login, consent, select_account, where none must not be combined
// with the other values.
func parseOIDCPrompt(target *string, env string) error {
raw, ok, err := lookupEnv(env)
if err != nil {
return err
}
if !ok {
return nil
}
values := strings.Fields(raw)
hasNone := false
for _, value := range values {
if !validOIDCPromptValues[value] {
return fmt.Errorf(
"invalid value for %s (%q): must be a space-delimited combination of none, login, consent, select_account",
env, raw,
)
}
hasNone = hasNone || value == "none"
}
if hasNone && len(values) > 1 {
return fmt.Errorf("invalid value for %s (%q): none must not be combined with other values", env, raw)
}
*target = raw
return nil
}
9 changes: 8 additions & 1 deletion docs/spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -2948,7 +2948,8 @@
"register",
"localAuth",
"oidc",
"oidcIdpName"
"oidcIdpName",
"oidcAutoRedirect"
],
"properties": {
"localAuth": {
Expand All @@ -2963,6 +2964,12 @@
"x-go-name": "Oidc",
"example": true
},
"oidcAutoRedirect": {
"description": "If the WebUI should automatically redirect to the OIDC identity\nprovider instead of showing the login page.",
"type": "boolean",
"x-go-name": "OIDCAutoRedirect",
"example": false
},
"oidcIdpName": {
"description": "Name of the OIDC identity provider.",
"type": "string",
Expand Down
20 changes: 20 additions & 0 deletions gotify-server.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,26 @@
# Type: text-list
# GOTIFY_OIDC_SCOPES=openid,profile,email

# Automatically redirect to the OIDC identity provider instead of showing the
# login page. Only takes effect if GOTIFY_OIDC_ENABLED is true. Users can
# still reach the login form by visiting the WebUI login route with
# ?redirect=false, e.g. https://push.example.com/#/login?redirect=false
#
# Type: boolean
# GOTIFY_OIDC_AUTO_REDIRECT=false

# Value of the prompt parameter sent to the OIDC provider on login and
# session elevation. The default of login forces reauthentication, so that
# logging out of Gotify (or, with GOTIFY_OIDC_AUTO_REDIRECT, an existing IdP
# session) does not silently and invisibly log the user back in. This does
# not end that IdP session, so other applications using it are unaffected.
# Must be empty (to not send a prompt parameter) or a space-delimited
# combination of none, login, consent, select_account, where none must not
# be combined with the other values. See the OIDC spec for details.
#
# Type: text
# GOTIFY_OIDC_PROMPT=login

# Enable authentication via username and password.
# Type: boolean
# GOTIFY_LOCALAUTH_ENABLED=true
Expand Down
Loading
Loading