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

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

View file

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

View file

@ -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", () => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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": [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()},
}

View file

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

View file

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

View file

@ -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"`

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