diff --git a/api/oidc.go b/api/oidc.go index f532e0b10..4182236fc 100644 --- a/api/oidc.go +++ b/api/oidc.go @@ -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), } } @@ -94,6 +95,7 @@ type OIDCAPI struct { SecureCookie bool AutoRegister bool LinkByUsername bool + Prompt []string pendingSessions *decaymap.DecayMap[string, *pendingOIDCSession] } @@ -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) }) } @@ -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 @@ -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, diff --git a/api/oidc_test.go b/api/oidc_test.go index 5254260df..006177024 100644 --- a/api/oidc_test.go +++ b/api/oidc_test.go @@ -1,7 +1,11 @@ package api import ( + "context" + "encoding/json" + "net/http" "net/http/httptest" + "net/url" "strings" "testing" "time" @@ -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" ) @@ -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}) @@ -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") diff --git a/config/config.go b/config/config.go index 85ece6bba..5ff1b9ebc 100644 --- a/config/config.go +++ b/config/config.go @@ -69,6 +69,8 @@ type OIDC struct { LinkByUsername bool Scopes []string IDPName string + AutoRedirect bool + Prompt []string } type Configuration struct { @@ -119,6 +121,7 @@ func Get() (*Configuration, []FutureLog) { AutoRegister: true, Scopes: []string{"openid", "profile", "email"}, IDPName: "OIDC", + Prompt: []string{"login"}, }, } @@ -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)) diff --git a/config/config_test.go b/config/config_test.go index 4ee2728ab..ea00d170e 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -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") @@ -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) { diff --git a/config/keys.go b/config/keys.go index c6bf7167f..b922c8a63 100644 --- a/config/keys.go +++ b/config/keys.go @@ -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" @@ -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" ) diff --git a/config/parse.go b/config/parse.go index 42558b479..74511d1e4 100644 --- a/config/parse.go +++ b/config/parse.go @@ -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)) diff --git a/docs/spec.json b/docs/spec.json index b75c9e599..dfb37e488 100644 --- a/docs/spec.json +++ b/docs/spec.json @@ -2948,7 +2948,8 @@ "register", "localAuth", "oidc", - "oidcIdpName" + "oidcIdpName", + "oidcAutoRedirect" ], "properties": { "localAuth": { @@ -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", diff --git a/gotify-server.env.example b/gotify-server.env.example index e8e734460..269ec1a66 100644 --- a/gotify-server.env.example +++ b/gotify-server.env.example @@ -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 @@ -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 diff --git a/model/gotifyinfo.go b/model/gotifyinfo.go index 526276efd..4d5499fd3 100644 --- a/model/gotifyinfo.go +++ b/model/gotifyinfo.go @@ -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. + // + // required: true + // example: false + OIDCAutoRedirect bool `json:"oidcAutoRedirect"` } diff --git a/router/router.go b/router/router.go index 8a05316b5..74e770d3b 100644 --- a/router/router.go +++ b/router/router.go @@ -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) @@ -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, }) }) diff --git a/router/router_test.go b/router/router_test.go index bfc784416..25fccad4d 100644 --- a/router/router_test.go +++ b/router/router_test.go @@ -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() { diff --git a/ui/serve.go b/ui/serve.go index 503e55a39..427dc83bb 100644 --- a/ui/serve.go +++ b/ui/serve.go @@ -16,11 +16,12 @@ import ( var box embed.FS type uiConfig struct { - Register bool `json:"register"` - Version model.VersionInfo `json:"version"` - LocalAuth bool `json:"localAuth"` - OIDC bool `json:"oidc"` - OIDCIDPName string `json:"oidcIdpName"` + Register bool `json:"register"` + Version model.VersionInfo `json:"version"` + LocalAuth bool `json:"localAuth"` + OIDC bool `json:"oidc"` + OIDCIDPName string `json:"oidcIdpName"` + OIDCAutoRedirect bool `json:"oidcAutoRedirect"` } // Register registers the ui on the root path. @@ -31,13 +32,15 @@ func Register( localAuthEnabled bool, oidcEnabled bool, oidcIDPName string, + oidcAutoRedirect bool, ) { uiConfigBytes, err := json.Marshal(uiConfig{ - Version: version, - Register: register, - LocalAuth: localAuthEnabled, - OIDC: oidcEnabled, - OIDCIDPName: oidcIDPName, + Version: version, + Register: register, + LocalAuth: localAuthEnabled, + OIDC: oidcEnabled, + OIDCIDPName: oidcIDPName, + OIDCAutoRedirect: oidcAutoRedirect, }) if err != nil { panic(err) diff --git a/ui/src/config.ts b/ui/src/config.ts index be57bd213..b88baefdc 100644 --- a/ui/src/config.ts +++ b/ui/src/config.ts @@ -5,6 +5,7 @@ export interface IConfig { register: boolean; version: IVersion; oidc: boolean; + oidcAutoRedirect: boolean; localAuth: boolean; oidcIdpName: string; } @@ -20,6 +21,7 @@ const config: IConfig = { register: false, version: {commit: 'unknown', buildDate: 'unknown', version: 'unknown'}, oidc: false, + oidcAutoRedirect: false, localAuth: true, oidcIdpName: 'OIDC', ...window.config, diff --git a/ui/src/user/Login.tsx b/ui/src/user/Login.tsx index 8e6572a20..03de5bdb9 100644 --- a/ui/src/user/Login.tsx +++ b/ui/src/user/Login.tsx @@ -9,7 +9,7 @@ import * as config from '../config'; import RegistrationDialog from './Register'; import {useStores} from '../stores'; import {observer} from 'mobx-react-lite'; -import {useNavigate} from 'react-router'; +import {useNavigate, useSearchParams} from 'react-router'; const Login = observer(() => { const [username, setUsername] = React.useState(''); @@ -17,15 +17,30 @@ const Login = observer(() => { const [registerDialog, setRegisterDialog] = React.useState(false); const {currentUser} = useStores(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const localAuthEnabled = config.get('localAuth'); const oidcEnabled = config.get('oidc'); const oidcIdpName = config.get('oidcIdpName'); + const oidcAutoRedirect = + oidcEnabled && + config.get('oidcAutoRedirect') && + searchParams.get('redirect') !== 'false' && + !currentUser.connectionErrorMessage; + const oidcLoginUrl = + config.get('url') + + 'auth/oidc/login?name=' + + encodeURIComponent(currentUser.createClientName()); React.useEffect(() => { if (currentUser.loggedIn) { navigate('/'); + return; } - }, [currentUser.loggedIn]); + if (!currentUser.authenticating && oidcAutoRedirect) { + window.location.href = oidcLoginUrl; + } + }, [currentUser.loggedIn, currentUser.authenticating, oidcAutoRedirect]); + const registerButton = () => { if (localAuthEnabled && config.get('register')) return ( @@ -96,11 +111,7 @@ const Login = observer(() => {