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
16 changes: 14 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,14 @@ 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)
}

func (a *OIDCAPI) promptURLParams() []rp.URLParamOpt {
if len(a.Prompt) == 0 {
return nil
}
return []rp.URLParamOpt{rp.WithPromptURLParam(a.Prompt...)}
}

// swagger:operation GET /auth/oidc/callback oidc oidcCallback
Expand Down Expand Up @@ -315,6 +324,9 @@ func (a *OIDCAPI) ExternalAuthorizeHandler(ctx *gin.Context) {
rp.AuthURLOpt(rp.WithURLParam("redirect_uri", req.RedirectURI)),
rp.WithCodeChallenge(req.CodeChallenge),
}
for _, opt := range a.promptURLParams() {
authOpts = append(authOpts, rp.AuthURLOpt(opt))
}
ctx.JSON(http.StatusOK, &model.OIDCExternalAuthorizeResponse{
AuthorizeURL: rp.AuthURL(state, a.Provider, authOpts...),
State: state,
Expand Down
124 changes: 124 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/v3/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,92 @@ 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_AuthURL() {
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: []string{"login"}, wantPrompt: "login"},
{name: "custom prompt", prompt: []string{"consent"}, wantPrompt: "consent"},
{name: "empty prompt disables the parameter", prompt: []string{}, 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)
query := location.Query()
assert.NotEmpty(s.T(), query.Get("state"))
assert.Equal(s.T(), tc.wantPrompt, query.Get("prompt"))
assert.Equal(s.T(), "client", query.Get("client_id"))
assert.Equal(s.T(), "https://gotify.example/callback", query.Get("redirect_uri"))
assert.Equal(s.T(), "openid", query.Get("scope"))
})
}
}

func (s *OIDCSuite) Test_ElevateHandler_AuthURL() {
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 = []string{"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)
query := location.Query()
assert.NotEmpty(s.T(), query.Get("state"))
assert.Equal(s.T(), "login", query.Get("prompt"))
assert.Equal(s.T(), "client", query.Get("client_id"))
assert.Equal(s.T(), "https://gotify.example/callback", query.Get("redirect_uri"))
assert.Equal(s.T(), "openid", query.Get("scope"))
}

func (s *OIDCSuite) Test_ResolveUser_ReturningUser_MatchedByOIDCID() {
oidcID := testIssuer + "#sub-1"
s.db.CreateUser(&model.User{ID: 1, Name: "alice", OIDCID: &oidcID})
Expand Down Expand Up @@ -250,6 +341,39 @@ func (s *OIDCSuite) Test_CreateClient() {

// --- ExternalAuthorizeHandler ---

func (s *OIDCSuite) Test_ExternalAuthorizeHandler_AuthURL() {
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 = []string{"login"}

recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest("POST", "/auth/oidc/external/authorize", strings.NewReader(
`{"code_challenge":"challenge","redirect_uri":"gotify://oidc/callback","name":"Android Phone"}`,
))
ctx.Request.Header.Set("Content-Type", "application/json")

s.a.ExternalAuthorizeHandler(ctx)

assert.Equal(s.T(), 200, recorder.Code)
response := model.OIDCExternalAuthorizeResponse{}
assert.NoError(s.T(), json.Unmarshal(recorder.Body.Bytes(), &response))
authorizeURL, err := url.Parse(response.AuthorizeURL)
assert.NoError(s.T(), err)
query := authorizeURL.Query()
assert.Equal(s.T(), response.State, query.Get("state"))
assert.Equal(s.T(), "gotify://oidc/callback", query.Get("redirect_uri"))
assert.Equal(s.T(), "challenge", query.Get("code_challenge"))
assert.Equal(s.T(), "login", query.Get("prompt"))
assert.Equal(s.T(), "client", query.Get("client_id"))
assert.Equal(s.T(), "openid", query.Get("scope"))
}

func (s *OIDCSuite) Test_ExternalAuthorizeHandler_MissingFields() {
s.ctx.Request = httptest.NewRequest("POST", "/auth/oidc/external/authorize", strings.NewReader(`{}`))
s.ctx.Request.Header.Set("Content-Type", "application/json")
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: []string{"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(parseList(&c.OIDC.Prompt, EnvOIDCPrompt))

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

Expand Down
3 changes: 3 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ func TestConfigEnv(t *testing.T) {
t.Setenv("GOTIFY_SERVER_CORS_ALLOWHEADERS", "Authorization,content-type")
t.Setenv("GOTIFY_SERVER_STREAM_ALLOWEDORIGINS", ".+.example.com,otherdomain.com")
t.Setenv("GOTIFY_OIDC_IDP_NAME", "Company XYZ SSO")
t.Setenv("GOTIFY_OIDC_PROMPT", "")

conf, _ := Get()
assert.Equal(t, 80, conf.Server.Port, "should use defaults")
Expand All @@ -35,6 +36,8 @@ func TestConfigEnv(t *testing.T) {
assert.Equal(t, []string{"Authorization", "content-type"}, conf.Server.Cors.AllowHeaders)
assert.Equal(t, []string{".+.example.com", "otherdomain.com"}, conf.Server.Stream.AllowedOrigins)
assert.Equal(t, "Company XYZ SSO", conf.OIDC.IDPName)
assert.Equal(t, []string{}, conf.OIDC.Prompt)
assert.Equal(t, []string{"openid", "profile", "email"}, conf.OIDC.Scopes)
}

func TestLocalAuthDisabled(t *testing.T) {
Expand Down
4 changes: 3 additions & 1 deletion config/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const (
EnvUploadedImagesDir = "GOTIFY_UPLOADEDIMAGESDIR"
EnvPluginsDir = "GOTIFY_PLUGINSDIR"
EnvRegistration = "GOTIFY_REGISTRATION"
EnvLocalAuthEnabled = "GOTIFY_LOCALAUTH_ENABLED"
EnvOIDCEnabled = "GOTIFY_OIDC_ENABLED"
EnvOIDCIssuer = "GOTIFY_OIDC_ISSUER"
EnvOIDCClientID = "GOTIFY_OIDC_CLIENTID"
Expand All @@ -40,8 +41,9 @@ const (
EnvOIDCRedirectURL = "GOTIFY_OIDC_REDIRECTURL"
EnvOIDCAutoRegister = "GOTIFY_OIDC_AUTOREGISTER"
EnvOIDCLinkByUsername = "GOTIFY_OIDC_LINK_BY_USERNAME"
EnvLocalAuthEnabled = "GOTIFY_LOCALAUTH_ENABLED"
EnvOIDCScopes = "GOTIFY_OIDC_SCOPES"
EnvOIDCIDPName = "GOTIFY_OIDC_IDP_NAME"
EnvOIDCAutoRedirect = "GOTIFY_OIDC_AUTO_REDIRECT"
EnvOIDCPrompt = "GOTIFY_OIDC_PROMPT"
EnvNoColor = "NOCOLOR"
)
6 changes: 5 additions & 1 deletion config/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,11 @@ func parseList(target *[]string, env string) error {
if err != nil {
return err
}
if !ok || raw == "" {
if !ok {
return nil
}
if raw == "" {
*target = []string{}
return nil
}
reader := csv.NewReader(strings.NewReader(raw))
Expand Down
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
21 changes: 19 additions & 2 deletions gotify-server.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,10 @@
# Example: .+\.example\.com,otherdomain\.com
# GOTIFY_SERVER_STREAM_ALLOWEDORIGINS=

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

# Enable OpenID Connect Single Sign-On, allowing users to authenticate via an
# external identity provider (e.g. Authelia, Dex, Keycloak). The provider must
# support PKCE (https://oauth.net/2/pkce/); IdPs without PKCE support are
Expand Down Expand Up @@ -224,9 +228,22 @@
# Type: text-list
# GOTIFY_OIDC_SCOPES=openid,profile,email

# Enable authentication via username and password.
# Automatically redirect to the OIDC identity provider instead of showing the
# login page. 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_LOCALAUTH_ENABLED=true
# GOTIFY_OIDC_AUTO_REDIRECT=false

# Value of the `prompt` parameter sent to the OIDC provider for authentication.
# The value is passed to the IdP (commas are replaced with space).
# Set to empty to disable sending the parameter.
#
# See: https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
# See: https://openid.net/specs/openid-connect-prompt-create-1_0.html#OpenID.Core
# Type: text-list
# Example: consent,login
# GOTIFY_OIDC_PROMPT=login

# Name of the OIDC identity provider displayed in UI.
# Type: text
Expand Down
6 changes: 6 additions & 0 deletions model/gotifyinfo.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,10 @@ type GotifyInfo struct {
// required: true
// example: OIDC
OIDCIDPName string `json:"oidcIdpName"`
// If the WebUI should automatically redirect to the OIDC identity
// provider instead of showing the login page.
//
Comment thread
jmattheis marked this conversation as resolved.
// required: true
// example: false
OIDCAutoRedirect bool `json:"oidcAutoRedirect"`
}
13 changes: 7 additions & 6 deletions router/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
userChangeNotifier.OnUserDeleted(pluginManager.RemoveUser)
userChangeNotifier.OnUserAdded(pluginManager.InitializeForUserID)

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

if conf.OIDC.Enabled {
oidcHandler := api.NewOIDC(conf, db, userChangeNotifier)
Expand Down Expand Up @@ -192,11 +192,12 @@ func Create(db *database.GormDatabase, vInfo *model.VersionInfo, conf *config.Co
// $ref: "#/definitions/GotifyInfo"
g.GET("gotifyinfo", func(ctx *gin.Context) {
ctx.JSON(200, &model.GotifyInfo{
Version: vInfo.Version,
Oidc: conf.OIDC.Enabled,
Register: conf.Registration,
LocalAuth: conf.LocalAuthEnabled,
OIDCIDPName: conf.OIDC.IDPName,
Version: vInfo.Version,
Oidc: conf.OIDC.Enabled,
Register: conf.Registration,
LocalAuth: conf.LocalAuthEnabled,
OIDCIDPName: conf.OIDC.IDPName,
OIDCAutoRedirect: conf.OIDC.AutoRedirect,
})
})

Expand Down
3 changes: 1 addition & 2 deletions router/router_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,7 @@ func (s *IntegrationSuite) TestVersionInfo() {

func (s *IntegrationSuite) TestGotifyInfo() {
req := s.newRequest("GET", "gotifyinfo", "")

doRequestAndExpect(s.T(), req, 200, `{"version":"1.0.0", "oidc":false, "register":false, "localAuth":true, "oidcIdpName":"Company XYZ SSO"}`)
doRequestAndExpect(s.T(), req, 200, `{"version":"1.0.0", "oidc":false, "register":false, "localAuth":true, "oidcIdpName":"Company XYZ SSO", "oidcAutoRedirect":false}`)
}

func (s *IntegrationSuite) TestHeaderInProd() {
Expand Down
Loading
Loading