Auth: Add RP-initiated OIDC logout via PHOTOPRISM_OIDC_LOGOUT #5684 #5433

Sign-out of an OIDC session redirects the browser to the provider's
discovered end_session_endpoint (id_token_hint + absolute
post_logout_redirect_uri) when PHOTOPRISM_OIDC_LOGOUT is enabled, so the
provider SSO session ends and the next login re-prompts. Opt-in (default
off); local/LDAP logout and providers without an end-session endpoint fall
back to local logout.

Adds the shared CE /api/v1/oauth/logout route and OAuthLogoutHandler hook
plus the Portal OP end_session_endpoint advertised in the Portal discovery
document, for Portal builds to serve via the override hook.
This commit is contained in:
Michael Mayer 2026-06-24 14:27:24 +00:00
parent 3f096a592b
commit 50976f427e
21 changed files with 576 additions and 10 deletions

View file

@ -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)

View file

@ -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,

View file

@ -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) {