diff --git a/frontend/src/common/README.md b/frontend/src/common/README.md index a38b8a38a..8cbc35338 100644 --- a/frontend/src/common/README.md +++ b/frontend/src/common/README.md @@ -27,6 +27,7 @@ When integrating third-party clients such as native mobile apps that embed the P - In the standard login UI, this preference is exposed through the `Stay signed in on this device` toggle. Checked means persistent namespaced `localStorage`; unchecked means ephemeral namespaced `sessionStorage`. - The login page initializes that toggle from the current session storage mode when available, and otherwise falls back to the stored namespaced `session` preference flag. - Logout and session reset clear the current app namespace from both `localStorage` and `sessionStorage`, and also remove deprecated raw legacy auth keys from both stores, without touching other namespaced instances on the same origin. +- When `PHOTOPRISM_OIDC_LOGOUT` is enabled, `DELETE /api/v1/session` returns a `providerLogoutUri`; `logout()` passes it to `onLogout()`, which redirects the browser to the provider's end-session endpoint (RP-initiated logout) instead of the local login page, so the provider's SSO session is ended too. Without it, sign-out lands on the local/Portal login page as before. - Regression tests for session restore should exercise both preferred `sessionStorage` and a real `Config`-shaped object so namespace resolution bugs cannot silently fall back to `pp:root:`. Preferred integration contract for new native or web view clients: diff --git a/frontend/src/common/session.js b/frontend/src/common/session.js index 7aba4cbd9..bbea0086c 100644 --- a/frontend/src/common/session.js +++ b/frontend/src/common/session.js @@ -823,9 +823,12 @@ export default class Session { return this.config.loginUri; } - onLogout(noRedirect) { - // Capture the redirect target before reset() clears the auth provider. - const redirectUri = this.logoutRedirectUri(); + // onLogout resets client state and redirects to the post-sign-out landing page. + onLogout(noRedirect, providerLogoutUri) { + // Prefer the provider's RP-initiated logout URL when the backend returned one + // (PHOTOPRISM_OIDC_LOGOUT), so the browser ends the provider SSO session; otherwise + // capture the local target before reset() clears the auth provider. + const redirectUri = providerLogoutUri || this.logoutRedirectUri(); this.reset(); @@ -898,8 +901,10 @@ export default class Session { if (this.isAuthenticated()) { return $api .delete("session") - .then(() => { - return this.onLogout(noRedirect); + .then((resp) => { + // The backend returns providerLogoutUri when RP-initiated logout is enabled, + // so the browser is redirected to the provider's end-session endpoint. + return this.onLogout(noRedirect, resp?.data?.providerLogoutUri); }) .catch(() => { return this.onLogout(noRedirect); diff --git a/frontend/tests/vitest/common/session.test.js b/frontend/tests/vitest/common/session.test.js index 087f3d0b8..e8b691b35 100644 --- a/frontend/tests/vitest/common/session.test.js +++ b/frontend/tests/vitest/common/session.test.js @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import "../fixtures"; import { $config } from "app/session"; +import $api from "common/api"; import Session from "common/session"; import { buildNamespace, createNamespacedStorage } from "common/storage"; import StorageShim from "node-storage-shim"; @@ -724,6 +725,57 @@ describe("common/session", () => { expect(spy).toHaveBeenCalledWith("/library/login"); }); + + it("prefers a backend-provided provider logout URL over the local target", () => { + const rawStorage = new StorageShim(); + const namespaceKey = "ns-logout-provider-uri"; + const storage = createNamespacedStorage(rawStorage, namespaceKey); + const session = new Session(storage, clusterOidcConfig(namespaceKey, { cluster: false })); + session.provider = "oidc"; + const spy = vi.spyOn(session, "followRedirect").mockImplementation(() => {}); + + const providerLogoutUri = "https://keycloak.localssl.dev/realms/master/protocol/openid-connect/logout?id_token_hint=abc"; + session.onLogout(false, providerLogoutUri); + + expect(spy).toHaveBeenCalledWith(providerLogoutUri); + }); + }); + + describe("logout RP-initiated provider redirect", () => { + it("passes providerLogoutUri from the DELETE response to onLogout", async () => { + const storage = new StorageShim(); + const session = new Session(storage, createConfig("/library", "ns-logout-rp")); + session.setAuthToken("999900000000000000000000000000000000000000000000"); + session.applyId("a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f"); + session.user = new (session.user.constructor)({ ID: 7, Name: "x", DisplayName: "X" }); + expect(session.isAuthenticated()).toBe(true); + + const providerLogoutUri = "https://keycloak.localssl.dev/realms/master/protocol/openid-connect/logout?id_token_hint=abc"; + const deleteSpy = vi.spyOn($api, "delete").mockResolvedValue({ data: { status: "deleted", providerLogoutUri } }); + const onLogoutSpy = vi.spyOn(session, "onLogout").mockReturnValue(Promise.resolve()); + + await session.logout(); + + expect(deleteSpy).toHaveBeenCalledWith("session"); + expect(onLogoutSpy).toHaveBeenCalledWith(undefined, providerLogoutUri); + deleteSpy.mockRestore(); + }); + + it("falls back to local logout when the response carries no provider URL", async () => { + const storage = new StorageShim(); + const session = new Session(storage, createConfig("/library", "ns-logout-rp-none")); + session.setAuthToken("999900000000000000000000000000000000000000000000"); + session.applyId("a9b8ff820bf40ab451910f8bbfe401b2432446693aa539538fbd2399560a722f"); + session.user = new (session.user.constructor)({ ID: 7, Name: "x", DisplayName: "X" }); + + const deleteSpy = vi.spyOn($api, "delete").mockResolvedValue({ data: { status: "deleted" } }); + const onLogoutSpy = vi.spyOn(session, "onLogout").mockReturnValue(Promise.resolve()); + + await session.logout(); + + expect(onLogoutSpy).toHaveBeenCalledWith(undefined, undefined); + deleteSpy.mockRestore(); + }); }); describe("signOut", () => { diff --git a/internal/api/oauth_handlers.go b/internal/api/oauth_handlers.go index d8989289a..97890dc30 100644 --- a/internal/api/oauth_handlers.go +++ b/internal/api/oauth_handlers.go @@ -17,6 +17,11 @@ var OAuthAuthorizeHandler gin.HandlerFunc = defaultOAuthAuthorize // a 405 placeholder; Portal builds set it to the OIDC OP userinfo handler. var OAuthUserinfoHandler gin.HandlerFunc = defaultOAuthUserinfo +// OAuthLogoutHandler handles GET and POST /api/v1/oauth/logout (OIDC RP-initiated +// logout end-session endpoint). Defaults to a 405 placeholder; Portal builds set it +// to the OIDC OP end-session handler. +var OAuthLogoutHandler gin.HandlerFunc = defaultOAuthLogout + // OAuthAuthorizationCodeHandler handles the authorization_code grant on the // shared POST /api/v1/oauth/token endpoint. Nil on CE/Pro builds (the grant is // then reported as unsupported); Portal builds set it to the OIDC OP token diff --git a/internal/api/oauth_logout.go b/internal/api/oauth_logout.go new file mode 100644 index 000000000..78a64eded --- /dev/null +++ b/internal/api/oauth_logout.go @@ -0,0 +1,64 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/photoprism/photoprism/internal/event" + "github.com/photoprism/photoprism/internal/photoprism/get" + "github.com/photoprism/photoprism/pkg/authn" + "github.com/photoprism/photoprism/pkg/http/header" + "github.com/photoprism/photoprism/pkg/i18n" +) + +// OAuthLogout registers the OAuth2/OIDC end-session endpoint for RP-initiated logout. +// The behavior is provided by OAuthLogoutHandler, which defaults to a placeholder and is +// replaced by Portal builds with the OIDC OP end-session handler (see oauth_handlers.go +// and portal/internal/server/register.go). Both GET and POST are registered per the +// OpenID Connect RP-Initiated Logout specification. +// +// @Summary OAuth2/OIDC end-session endpoint (RP-initiated logout) +// @Id OAuthLogout +// @Tags Authentication +// @Produce json +// @Success 302 {string} string "redirect to post_logout_redirect_uri" +// @Failure 403,405 {object} i18n.Response +// @Router /api/v1/oauth/logout [get] +// @Router /api/v1/oauth/logout [post] +func OAuthLogout(router *gin.RouterGroup) { + router.GET("/oauth/logout", func(c *gin.Context) { + OAuthLogoutHandler(c) + }) + router.POST("/oauth/logout", func(c *gin.Context) { + OAuthLogoutHandler(c) + }) +} + +// defaultOAuthLogout is the placeholder end-session handler used unless a Portal build +// overrides OAuthLogoutHandler with the OIDC OP end-session handler. +func defaultOAuthLogout(c *gin.Context) { + // Prevent CDNs from caching this endpoint. + if header.IsCdn(c.Request) { + AbortNotFound(c) + return + } + + // Disable caching of responses. + c.Header(header.CacheControl, header.CacheControlNoStore) + + // Get client IP address for logs and rate limiting checks. + clientIp := ClientIP(c) + actor := "unknown client" + action := "logout" + + // Abort if running in public mode. + if get.Config().Public() { + event.AuditErr([]string{clientIp, "oauth2", actor, action, authn.ErrDisabledInPublicMode.Error()}) + Abort(c, http.StatusForbidden, i18n.ErrForbidden) + return + } + + // The OIDC OP end-session endpoint is only available on Portal builds. + c.JSON(http.StatusMethodNotAllowed, gin.H{"status": StatusFailed}) +} diff --git a/internal/api/oauth_logout_test.go b/internal/api/oauth_logout_test.go new file mode 100644 index 000000000..a62f6c784 --- /dev/null +++ b/internal/api/oauth_logout_test.go @@ -0,0 +1,59 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/photoprism/photoprism/internal/config" +) + +func TestOAuthLogout(t *testing.T) { + t.Run("PublicMode", func(t *testing.T) { + app, router, _ := NewApiTest() + + OAuthLogout(router) + + r := PerformRequest(app, http.MethodGet, "/api/v1/oauth/logout") + assert.Equal(t, http.StatusForbidden, r.Code) + }) + t.Run("DefaultNotImplemented", func(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + + OAuthLogout(router) + + r := PerformRequest(app, http.MethodGet, "/api/v1/oauth/logout") + assert.Equal(t, http.StatusMethodNotAllowed, r.Code) + }) + t.Run("PostRouteRegistered", func(t *testing.T) { + app, router, conf := NewApiTest() + conf.SetAuthMode(config.AuthModePasswd) + defer conf.SetAuthMode(config.AuthModePublic) + + OAuthLogout(router) + + r := PerformRequest(app, http.MethodPost, "/api/v1/oauth/logout") + assert.Equal(t, http.StatusMethodNotAllowed, r.Code) + }) + t.Run("OverrideHookDelegates", func(t *testing.T) { + app, router, _ := NewApiTest() + + OAuthLogout(router) + + prev := OAuthLogoutHandler + OAuthLogoutHandler = func(c *gin.Context) { c.String(http.StatusOK, "delegated:"+c.Request.Method) } + defer func() { OAuthLogoutHandler = prev }() + + get := PerformRequest(app, http.MethodGet, "/api/v1/oauth/logout") + assert.Equal(t, http.StatusOK, get.Code) + assert.Equal(t, "delegated:GET", get.Body.String()) + + post := PerformRequest(app, http.MethodPost, "/api/v1/oauth/logout") + assert.Equal(t, http.StatusOK, post.Code) + assert.Equal(t, "delegated:POST", post.Body.String()) + }) +} diff --git a/internal/api/session_delete.go b/internal/api/session_delete.go index db1a4d5b4..3c5409c56 100644 --- a/internal/api/session_delete.go +++ b/internal/api/session_delete.go @@ -2,14 +2,18 @@ package api import ( "net/http" + "strings" "github.com/photoprism/photoprism/pkg/http/header" + "github.com/photoprism/photoprism/pkg/http/scheme" "github.com/photoprism/photoprism/pkg/log/status" "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/auth/acl" + "github.com/photoprism/photoprism/internal/auth/oidc" "github.com/photoprism/photoprism/internal/auth/session" + "github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/entity" "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/photoprism/get" @@ -82,20 +86,73 @@ func DeleteSession(router *gin.RouterGroup) { event.AuditDebug([]string{clientIp, "session %s", "deleted"}, s.RefID) } - // Clear the OP session cookie on the caller's own logout (not a manager - // deleting another session by ref id), so a cluster-wide Sign-Out stops silent - // re-SSO no matter which node is hit. OIDCSessionCookieClearPath picks the path. + // Confirmation response; gains an optional providerLogoutUri below. + resp := DeleteSessionResponse(s.ID) + + // On the caller's own logout (not a manager deleting another session by ref id), + // clear the OP session cookie so a cluster-wide Sign-Out stops silent re-SSO no + // matter which node is hit, and — when PHOTOPRISM_OIDC_LOGOUT is enabled — return + // the provider's RP-initiated logout URL so the browser can end the provider session. if conf := get.Config(); !rnd.IsRefID(id) { if clearPath := OIDCSessionCookieClearPath(conf); clearPath != "" { ClearOIDCSessionCookie(c, clearPath, conf.SiteHttps()) } + + if logoutUri := oidcLogoutURL(conf, get.OIDC(), s); logoutUri != "" { + resp["providerLogoutUri"] = logoutUri + event.AuditInfo([]string{clientIp, "session %s", "oidc provider logout initiated", status.Granted}, s.RefID) + } } // Return JSON response for confirmation. - c.JSON(http.StatusOK, DeleteSessionResponse(s.ID)) + c.JSON(http.StatusOK, resp) } router.DELETE("/session", deleteSessionHandler) router.DELETE("/session/:id", deleteSessionHandler) router.DELETE("/sessions/:id", deleteSessionHandler) } + +// oidcLogoutURL returns the RP-initiated logout URL for a just-deleted OIDC session, or "" +// when RP-initiated logout is disabled, the session was not authenticated via OIDC, or the +// provider advertises no end_session_endpoint. The browser is then redirected there so the +// provider ends its own SSO session and a subsequent login re-prompts for credentials. +func oidcLogoutURL(conf *config.Config, provider *oidc.Client, s *entity.Session) string { + if conf == nil || provider == nil || s == nil { + return "" + } else if !conf.OIDCLogout() || s.IdToken == "" || !s.GetProvider().IsOIDC() { + return "" + } + + logoutUri, err := provider.EndSessionURL(s.IdToken, AbsoluteLoginURL(conf), "") + + if err != nil { + event.AuditWarn([]string{"oidc", "provider logout", status.Error(err)}) + return "" + } + + return logoutUri +} + +// AbsoluteLoginURL resolves the node's login page to an absolute URL, so it can be used +// as an OIDC post_logout_redirect_uri (which providers require to be absolute and +// registered). LoginUri() is a root-relative path; it is joined to the site origin. +func AbsoluteLoginURL(conf *config.Config) string { + path := conf.LoginUri() + + if strings.Contains(path, "://") { + return path + } + + origin := scheme.OriginURL(conf.SiteUrl()) + + if origin == "" { + return path + } + + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + + return strings.TrimRight(origin, "/") + path +} diff --git a/internal/api/session_delete_test.go b/internal/api/session_delete_test.go new file mode 100644 index 000000000..c463b7fc2 --- /dev/null +++ b/internal/api/session_delete_test.go @@ -0,0 +1,98 @@ +package api + +import ( + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v3/pkg/client/rp" + "golang.org/x/oauth2" + + "github.com/photoprism/photoprism/internal/auth/oidc" + "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/entity" + "github.com/photoprism/photoprism/pkg/authn" +) + +// fakeRelyingParty implements only the rp.RelyingParty methods EndSessionURL reads. +type fakeRelyingParty struct { + rp.RelyingParty + endSession string + clientID string +} + +func (f fakeRelyingParty) GetEndSessionEndpoint() string { return f.endSession } +func (f fakeRelyingParty) OAuthConfig() *oauth2.Config { return &oauth2.Config{ClientID: f.clientID} } + +func oidcProvider(endSession string) *oidc.Client { + return &oidc.Client{RelyingParty: fakeRelyingParty{endSession: endSession, clientID: "client-abc"}} +} + +func oidcSession(provider authn.ProviderType, idToken string) *entity.Session { + s := &entity.Session{} + s.SetProvider(provider) + s.IdToken = idToken + return s +} + +func TestOidcLogoutURL(t *testing.T) { + conf := config.NewConfig(config.CliTestContext()) + provider := oidcProvider("https://provider.example/logout") + + t.Run("Success", func(t *testing.T) { + conf.Options().OIDCLogout = true + defer func() { conf.Options().OIDCLogout = false }() + + result := oidcLogoutURL(conf, provider, oidcSession(authn.ProviderOIDC, "id-token-123")) + require.NotEmpty(t, result) + + u, err := url.Parse(result) + require.NoError(t, err) + assert.Equal(t, "id-token-123", u.Query().Get("id_token_hint")) + assert.Equal(t, "client-abc", u.Query().Get("client_id")) + // post_logout_redirect_uri must be absolute (providers reject relative paths). + plr := u.Query().Get("post_logout_redirect_uri") + assert.Equal(t, AbsoluteLoginURL(conf), plr) + assert.Contains(t, plr, "://") + }) + t.Run("FlagDisabled", func(t *testing.T) { + conf.Options().OIDCLogout = false + assert.Equal(t, "", oidcLogoutURL(conf, provider, oidcSession(authn.ProviderOIDC, "id-token-123"))) + }) + t.Run("NotOIDCSession", func(t *testing.T) { + conf.Options().OIDCLogout = true + defer func() { conf.Options().OIDCLogout = false }() + assert.Equal(t, "", oidcLogoutURL(conf, provider, oidcSession(authn.ProviderLocal, "id-token-123"))) + }) + t.Run("NoIdToken", func(t *testing.T) { + conf.Options().OIDCLogout = true + defer func() { conf.Options().OIDCLogout = false }() + assert.Equal(t, "", oidcLogoutURL(conf, provider, oidcSession(authn.ProviderOIDC, ""))) + }) + t.Run("ProviderWithoutEndSession", func(t *testing.T) { + conf.Options().OIDCLogout = true + defer func() { conf.Options().OIDCLogout = false }() + assert.Equal(t, "", oidcLogoutURL(conf, oidcProvider(""), oidcSession(authn.ProviderOIDC, "id-token-123"))) + }) + t.Run("NilProvider", func(t *testing.T) { + conf.Options().OIDCLogout = true + defer func() { conf.Options().OIDCLogout = false }() + assert.Equal(t, "", oidcLogoutURL(conf, nil, oidcSession(authn.ProviderOIDC, "id-token-123"))) + }) +} + +func TestAbsoluteLoginURL(t *testing.T) { + t.Run("RelativeLoginPathResolvedToOrigin", func(t *testing.T) { + conf := config.NewConfig(config.CliTestContext()) + result := AbsoluteLoginURL(conf) + assert.Contains(t, result, "://", "must be absolute") + assert.True(t, strings.HasSuffix(result, conf.LoginUri()), "must end with the login path") + }) + t.Run("AbsoluteLoginUriPassedThrough", func(t *testing.T) { + conf := config.NewConfig(config.CliTestContext()) + conf.Options().LoginUri = "https://sso.example.com/login" + assert.Equal(t, "https://sso.example.com/login", AbsoluteLoginURL(conf)) + }) +} diff --git a/internal/api/swagger.json b/internal/api/swagger.json index 5a6028d55..92e901457 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -730,6 +730,9 @@ "OIDCIcon": { "type": "string" }, + "OIDCLogout": { + "type": "boolean" + }, "OIDCProvider": { "type": "string" }, @@ -9402,6 +9405,68 @@ ] } }, + "/api/v1/oauth/logout": { + "get": { + "operationId": "OAuthLogout", + "produces": [ + "application/json" + ], + "responses": { + "302": { + "description": "redirect to post_logout_redirect_uri", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "405": { + "description": "Method Not Allowed", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "OAuth2/OIDC end-session endpoint (RP-initiated logout)", + "tags": [ + "Authentication" + ] + }, + "post": { + "operationId": "OAuthLogout", + "produces": [ + "application/json" + ], + "responses": { + "302": { + "description": "redirect to post_logout_redirect_uri", + "schema": { + "type": "string" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + }, + "405": { + "description": "Method Not Allowed", + "schema": { + "$ref": "#/definitions/i18n.Response" + } + } + }, + "summary": "OAuth2/OIDC end-session endpoint (RP-initiated logout)", + "tags": [ + "Authentication" + ] + } + }, "/api/v1/oauth/revoke": { "post": { "consumes": [ diff --git a/internal/auth/oidc/README.md b/internal/auth/oidc/README.md index e660171da..b67d8a0a2 100644 --- a/internal/auth/oidc/README.md +++ b/internal/auth/oidc/README.md @@ -30,6 +30,7 @@ - `oidc.go` — package doc + logger. - `client.go` — RP construction (`NewClient`), PKCE detection, auth redirect, code exchange + userinfo retrieval. +- `logout.go` — `(*Client).EndSessionURL` builds the RP-initiated logout URL (id_token_hint, post_logout_redirect_uri, client_id, state) for a browser redirect; tests in `logout_test.go`. - `http_client.go` — shared HTTP client with TLS toggle and timeouts; helpers for tests in `http_client_test.go`. - `redirect_url.go` — builds the redirect/callback URL from site config. - `register.go` — provider registration glue; tests in `register_test.go`. @@ -51,6 +52,12 @@ - Audit every provider/redirect/token error with sanitized messages; avoid logging secrets. - Prefer explicit scopes from configuration; defaults request only the minimal set. +#### RP-Initiated Logout + +- The provider's `end_session_endpoint` is captured automatically from discovery by the wrapped `zitadel/oidc` RP and read via `GetEndSessionEndpoint()`; no separate config is needed. +- `(*Client).EndSessionURL` constructs the redirect URL for the browser (it does not call the endpoint server-side, unlike `rp.EndSession`, because only the browser carries the provider's SSO cookie). It returns an empty string when the provider advertises no end-session endpoint, so callers fall back to a local-only logout. +- The behavior is gated by `PHOTOPRISM_OIDC_LOGOUT` (default off) and enforced at sign-out in `internal/api/session_delete.go`. + ### Security Group Extension for Entra ID The following features are supported by the current implementation: diff --git a/internal/auth/oidc/logout.go b/internal/auth/oidc/logout.go new file mode 100644 index 000000000..cdad546b1 --- /dev/null +++ b/internal/auth/oidc/logout.go @@ -0,0 +1,47 @@ +package oidc + +import ( + "net/url" +) + +// EndSessionURL builds an RP-initiated logout URL for redirecting the browser to the +// provider's end_session_endpoint, so the provider can end its own SSO session. It returns +// an empty string (and no error) when the provider advertises no end_session_endpoint, so +// callers can fall back to a local-only logout. +// +// The returned URL must be followed by a top-level browser navigation, not a server-side +// request: only the browser carries the provider's SSO cookie that needs clearing. This is +// why we build the URL here instead of using rp.EndSession, which performs the request +// server-side. +func (c *Client) EndSessionURL(idToken, postLogoutRedirectURI, state string) (string, error) { + endpoint := c.GetEndSessionEndpoint() + + if endpoint == "" { + return "", nil + } + + u, err := url.Parse(endpoint) + + if err != nil { + return "", err + } + + q := u.Query() + q.Set("id_token_hint", idToken) + + if clientID := c.OAuthConfig().ClientID; clientID != "" { + q.Set("client_id", clientID) + } + + if postLogoutRedirectURI != "" { + q.Set("post_logout_redirect_uri", postLogoutRedirectURI) + } + + if state != "" { + q.Set("state", state) + } + + u.RawQuery = q.Encode() + + return u.String(), nil +} diff --git a/internal/auth/oidc/logout_test.go b/internal/auth/oidc/logout_test.go new file mode 100644 index 000000000..4d98e78c2 --- /dev/null +++ b/internal/auth/oidc/logout_test.go @@ -0,0 +1,74 @@ +package oidc + +import ( + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v3/pkg/client/rp" + "golang.org/x/oauth2" +) + +// fakeRelyingParty implements only the rp.RelyingParty methods EndSessionURL reads; any +// other call panics on the nil embedded interface, which keeps the fake honest. +type fakeRelyingParty struct { + rp.RelyingParty + endSession string + clientID string +} + +func (f fakeRelyingParty) GetEndSessionEndpoint() string { return f.endSession } +func (f fakeRelyingParty) OAuthConfig() *oauth2.Config { return &oauth2.Config{ClientID: f.clientID} } + +func TestClient_EndSessionURL(t *testing.T) { + t.Run("Success", func(t *testing.T) { + c := &Client{RelyingParty: fakeRelyingParty{ + endSession: "https://provider.example/logout", + clientID: "client-abc", + }} + + result, err := c.EndSessionURL("id-token-123", "https://app.localssl.dev/library/login", "state-xyz") + require.NoError(t, err) + + u, err := url.Parse(result) + require.NoError(t, err) + assert.Equal(t, "https://provider.example/logout", u.Scheme+"://"+u.Host+u.Path) + + q := u.Query() + assert.Equal(t, "id-token-123", q.Get("id_token_hint")) + assert.Equal(t, "client-abc", q.Get("client_id")) + assert.Equal(t, "https://app.localssl.dev/library/login", q.Get("post_logout_redirect_uri")) + assert.Equal(t, "state-xyz", q.Get("state")) + }) + t.Run("NoEndSessionEndpoint", func(t *testing.T) { + c := &Client{RelyingParty: fakeRelyingParty{endSession: "", clientID: "client-abc"}} + + result, err := c.EndSessionURL("id-token-123", "https://app.localssl.dev/library/login", "") + require.NoError(t, err) + assert.Equal(t, "", result) + }) + t.Run("PreservesExistingQuery", func(t *testing.T) { + c := &Client{RelyingParty: fakeRelyingParty{ + endSession: "https://provider.example/logout?realm=master", + clientID: "client-abc", + }} + + result, err := c.EndSessionURL("id-token-123", "", "") + require.NoError(t, err) + + u, err := url.Parse(result) + require.NoError(t, err) + q := u.Query() + assert.Equal(t, "master", q.Get("realm")) + assert.Equal(t, "id-token-123", q.Get("id_token_hint")) + assert.Equal(t, "", q.Get("post_logout_redirect_uri")) + assert.Equal(t, "", q.Get("state")) + }) + t.Run("InvalidEndpoint", func(t *testing.T) { + c := &Client{RelyingParty: fakeRelyingParty{endSession: "://not a url", clientID: "client-abc"}} + + _, err := c.EndSessionURL("id-token-123", "", "") + assert.Error(t, err) + }) +} diff --git a/internal/auth/oidc/register.go b/internal/auth/oidc/register.go index 9e4c1d12f..a1457f14e 100644 --- a/internal/auth/oidc/register.go +++ b/internal/auth/oidc/register.go @@ -18,6 +18,9 @@ func ClientConfig(c *config.Config, t config.ClientType) config.Values { "icon": c.OIDCIcon(), "register": c.OIDCRegister(), "redirect": c.OIDCRedirect(), + // logout lets the UI know sign-out will redirect to the provider's end-session + // endpoint; the backend still drives the actual redirect URL. + "logout": c.OIDCLogout(), "loginUri": c.OIDCLoginUri(), // cluster lets the frontend route a cluster-OIDC sign-out to the Portal login. "cluster": c.ClusterOIDC(), diff --git a/internal/auth/oidc/register_test.go b/internal/auth/oidc/register_test.go index f7c612862..c89370f78 100644 --- a/internal/auth/oidc/register_test.go +++ b/internal/auth/oidc/register_test.go @@ -15,6 +15,8 @@ func TestClientConfig(t *testing.T) { assert.IsType(t, config.Values{}, result) assert.Equal(t, c.ClusterOIDC(), result["cluster"]) assert.Equal(t, false, result["cluster"]) + assert.Equal(t, c.OIDCLogout(), result["logout"]) + assert.Equal(t, false, result["logout"]) }) t.Run("ClusterOIDC", func(t *testing.T) { c := config.NewConfig(config.CliTestContext()) diff --git a/internal/config/config_oidc.go b/internal/config/config_oidc.go index 8bd6ac7b9..a018c7e6d 100644 --- a/internal/config/config_oidc.go +++ b/internal/config/config_oidc.go @@ -167,6 +167,12 @@ func (c *Config) OIDCRegister() bool { return c.options.OIDCRegister } +// OIDCLogout checks if signing out should also end the provider session via OpenID +// Connect RP-initiated logout (redirect to the discovered end_session_endpoint). +func (c *Config) OIDCLogout() bool { + return c.options.OIDCLogout +} + // OIDCUsername returns the preferred username claim for new users signing up via OIDC. func (c *Config) OIDCUsername() string { switch c.options.OIDCUsername { @@ -291,6 +297,7 @@ func (c *Config) OIDCReport() (rows [][]string, cols []string) { {"oidc-icon", c.OIDCIcon()}, {"oidc-redirect", fmt.Sprintf("%t", c.OIDCRedirect())}, {"oidc-register", fmt.Sprintf("%t", c.OIDCRegister())}, + {"oidc-logout", fmt.Sprintf("%t", c.OIDCLogout())}, {"oidc-username", c.OIDCUsername()}, } diff --git a/internal/config/config_oidc_test.go b/internal/config/config_oidc_test.go index a4aca6db5..aaa7d855a 100644 --- a/internal/config/config_oidc_test.go +++ b/internal/config/config_oidc_test.go @@ -263,6 +263,16 @@ func TestConfig_OIDCRegister(t *testing.T) { assert.False(t, c.OIDCRegister()) } +func TestConfig_OIDCLogout(t *testing.T) { + c := NewConfig(CliTestContext()) + + assert.False(t, c.OIDCLogout()) + + c.options.OIDCLogout = true + + assert.True(t, c.OIDCLogout()) +} + func TestConfig_OIDCRole(t *testing.T) { c := NewConfig(CliTestContext()) diff --git a/internal/config/flags.go b/internal/config/flags.go index 3062323d8..425f76097 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -112,6 +112,11 @@ var Flags = CliFlags{ Usage: "allows new users to create an account when they sign in with OpenID Connect", EnvVars: EnvVars("OIDC_REGISTER"), }}, { + Flag: &cli.BoolFlag{ + Name: "oidc-logout", + Usage: "ends the provider session on sign-out via OpenID Connect RP-initiated logout", + EnvVars: EnvVars("OIDC_LOGOUT"), + }}, { Flag: &cli.StringFlag{ Name: "oidc-username", Usage: "preferred username `CLAIM` for new OpenID Connect users (preferred_username, name, nickname, email)", diff --git a/internal/config/options.go b/internal/config/options.go index a3612f3df..f960b2986 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -44,6 +44,7 @@ type Options struct { OIDCIcon string `yaml:"OIDCIcon" json:"OIDCIcon" flag:"oidc-icon"` OIDCRedirect bool `yaml:"OIDCRedirect" json:"OIDCRedirect" flag:"oidc-redirect"` OIDCRegister bool `yaml:"OIDCRegister" json:"OIDCRegister" flag:"oidc-register"` + OIDCLogout bool `yaml:"OIDCLogout" json:"OIDCLogout" flag:"oidc-logout"` OIDCUsername string `yaml:"OIDCUsername" json:"-" flag:"oidc-username"` OIDCGroupClaim string `yaml:"OIDCGroupClaim" json:"-" flag:"oidc-group-claim" tags:"portal,pro"` OIDCGroup []string `yaml:"OIDCGroup" json:"-" flag:"oidc-group" tags:"portal,pro"` diff --git a/internal/server/routes.go b/internal/server/routes.go index 943987a5e..d6a1734b7 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -50,6 +50,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.OAuthUserinfo(APIv1) api.OAuthToken(APIv1) api.OAuthRevoke(APIv1) + api.OAuthLogout(APIv1) // OIDC Client Endpoints. api.OIDCLogin(APIv1) diff --git a/internal/server/wellknown/openid_portal.go b/internal/server/wellknown/openid_portal.go index 03e5f4f28..7fd50106d 100644 --- a/internal/server/wellknown/openid_portal.go +++ b/internal/server/wellknown/openid_portal.go @@ -37,6 +37,7 @@ func NewPortalOpenIDConfiguration(conf *config.Config) *OpenIDConfiguration { AuthorizationEndpoint: issuer + config.ApiUri + "/oauth/authorize", TokenEndpoint: issuer + config.ApiUri + "/oauth/token", UserinfoEndpoint: issuer + config.ApiUri + "/oauth/userinfo", + EndSessionEndpoint: issuer + config.ApiUri + "/oauth/logout", JwksUri: issuer + "/.well-known/jwks.json", ResponseTypesSupported: PortalOIDCResponseTypes, GrantTypesSupported: PortalOIDCGrantTypes, diff --git a/internal/server/wellknown/openid_portal_test.go b/internal/server/wellknown/openid_portal_test.go index 1785ba76d..685ea419b 100644 --- a/internal/server/wellknown/openid_portal_test.go +++ b/internal/server/wellknown/openid_portal_test.go @@ -22,6 +22,7 @@ func TestPortalOpenIDConfiguration(t *testing.T) { assert.Equal(t, "http://localhost:2342/api/v1/oauth/authorize", result.AuthorizationEndpoint) assert.Equal(t, "http://localhost:2342/api/v1/oauth/token", result.TokenEndpoint) assert.Equal(t, "http://localhost:2342/api/v1/oauth/userinfo", result.UserinfoEndpoint) + assert.Equal(t, "http://localhost:2342/api/v1/oauth/logout", result.EndSessionEndpoint) assert.Equal(t, "http://localhost:2342/.well-known/jwks.json", result.JwksUri) }) t.Run("Capabilities", func(t *testing.T) { @@ -46,13 +47,14 @@ func TestPortalOpenIDConfiguration(t *testing.T) { c.Options().SiteUrl = "http://foo:2342/foo/" result := NewPortalOpenIDConfiguration(c) - for _, u := range []string{result.Issuer, result.AuthorizationEndpoint, result.TokenEndpoint, result.UserinfoEndpoint, result.JwksUri} { + for _, u := range []string{result.Issuer, result.AuthorizationEndpoint, result.TokenEndpoint, result.UserinfoEndpoint, result.EndSessionEndpoint, result.JwksUri} { assert.Equal(t, 1, strings.Count(u, "/foo/"), "base path must appear exactly once in %s", u) assert.NotContains(t, u, "/foo/foo/", "base path must not be doubled in %s", u) } assert.Equal(t, "http://foo:2342/foo/api/v1/oauth/authorize", result.AuthorizationEndpoint) assert.Equal(t, "http://foo:2342/foo/api/v1/oauth/token", result.TokenEndpoint) assert.Equal(t, "http://foo:2342/foo/api/v1/oauth/userinfo", result.UserinfoEndpoint) + assert.Equal(t, "http://foo:2342/foo/api/v1/oauth/logout", result.EndSessionEndpoint) assert.Equal(t, "http://foo:2342/foo/.well-known/jwks.json", result.JwksUri) }) t.Run("IssuerWithTrailingSlashIsNormalized", func(t *testing.T) {