diff --git a/backend/__tests__/integration/helpers/mockOidcProvider.js b/backend/__tests__/integration/helpers/mockOidcProvider.js index 9c83ce94..b84b07cd 100644 --- a/backend/__tests__/integration/helpers/mockOidcProvider.js +++ b/backend/__tests__/integration/helpers/mockOidcProvider.js @@ -34,6 +34,7 @@ class MockOidcProvider { // Test hooks: this.tamperNonce = false; // sign the ID token with a WRONG nonce this.emailViaUserinfoOnly = false; // omit email from the ID token; serve it on /userinfo + this.advertiseEndSession = true; // include end_session_endpoint in discovery (#798 phase 3) this.accessTokens = new Map(); // access_token -> user (for /userinfo) this.server = null; this.issuer = null; @@ -85,6 +86,7 @@ class MockOidcProvider { token_endpoint: `${this.issuer}/token`, userinfo_endpoint: `${this.issuer}/userinfo`, jwks_uri: `${this.issuer}/jwks`, + ...(this.advertiseEndSession ? { end_session_endpoint: `${this.issuer}/logout` } : {}), response_types_supported: ['code'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], diff --git a/backend/__tests__/integration/oidcLogout.test.js b/backend/__tests__/integration/oidcLogout.test.js new file mode 100644 index 00000000..f03ca407 --- /dev/null +++ b/backend/__tests__/integration/oidcLogout.test.js @@ -0,0 +1,272 @@ +/** + * OIDC logout-to-IdP integration tests (#798 phase 3). + * + * Same full-stack shape as oidcSso.test.js: real routes over a mock + * in-process IdP, genuine discovery/JWKS/PKCE via openid-client. Pins: + * + * - the SSO callback stores the raw ID token in the oidc_id_token cookie + * - /logout with that cookie + oidc_logout_from_idp=true returns the + * IdP end-session URL (id_token_hint, post_logout_redirect_uri, + * client_id) and clears the cookie + * - feature off → no ssoLogoutUrl even for an SSO session + * - no oidc_id_token cookie (local-password session) → no ssoLogoutUrl + * even with the feature on — local sessions never bounce to the IdP + * - IdP without an end_session_endpoint → no ssoLogoutUrl, logout still 200 + * - settings surface: GET exposes the flag + post_logout_redirect_uri, + * PUT persists the flag + */ + +const request = require('supertest'); +const express = require('express'); +const cookieParser = require('cookie-parser'); +const bcrypt = require('bcrypt'); + +const { bootCrmDb } = require('./helpers/crmDb'); +const { MockOidcProvider } = require('./helpers/mockOidcProvider'); + +describe('OIDC logout-to-IdP (#798 phase 3)', () => { + let db; + let cleanup; + let app; + let idp; + let oidcService; + + beforeAll(async () => { + process.env.JWT_SECRET = process.env.JWT_SECRET || 'oidc-logout-test-secret'; + process.env.FRONTEND_URL = 'http://localhost:5199'; + ({ db, cleanup } = await bootCrmDb()); + + idp = new MockOidcProvider(); + const issuer = await idp.start(); + + oidcService = require('../../src/services/oidcService'); + await oidcService.saveOidcSettings({ + oidc_enabled: true, + oidc_issuer_url: issuer, + oidc_client_id: idp.clientId, + oidc_client_secret: idp.clientSecret, + oidc_autoprovision: true, + oidc_default_role: 'viewer', + oidc_logout_from_idp: true, + }); + + const authRouter = require('../../src/routes/auth'); + app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use('/api/auth', authRouter); + }, 120000); + + afterAll(async () => { + if (idp) await idp.stop(); + if (cleanup) await cleanup(); + }); + + /** Drive login → IdP → callback like a browser; returns the callback response. */ + async function ssoRoundTrip() { + const loginRes = await request(app).get('/api/auth/admin/sso/login').expect(302); + const stateCookie = (loginRes.headers['set-cookie'] || []) + .find((c) => c.startsWith('oidc_state=')) + .split(';')[0]; + + const idpRes = await fetch(loginRes.headers.location, { redirect: 'manual' }); + expect(idpRes.status).toBe(302); + const back = new URL(idpRes.headers.get('location')); + + return request(app) + .get(`${back.pathname}?${back.searchParams.toString()}`) + .set('Cookie', stateCookie) + .expect(302); + } + + /** + * The oidc_id_token cookie pair ("oidc_id_token=") from a callback + * response. The callback carries TWO Set-Cookie headers for this name — + * establishAdminSession clears any stale marker, then the callback sets + * the fresh one — and browsers apply them in order, so the LAST wins. + */ + function idTokenCookie(res) { + const cookies = (res.headers['set-cookie'] || []).filter((c) => c.startsWith('oidc_id_token=')); + const last = cookies[cookies.length - 1]; + return last ? last.split(';')[0] : null; + } + + it('stores the raw ID token in the oidc_id_token cookie on SSO login', async () => { + idp.setNextUser({ sub: 'logout-sub-1', email: 'logout@example.com', email_verified: true }); + const res = await ssoRoundTrip(); + expect(res.headers.location).toBe('http://localhost:5199/admin/dashboard'); + + const cookie = idTokenCookie(res); + expect(cookie).toBeTruthy(); + // Raw JWT, HttpOnly, scoped to /api/auth. + const raw = decodeURIComponent(cookie.replace('oidc_id_token=', '')); + expect(raw.split('.')).toHaveLength(3); + const setCookies = (res.headers['set-cookie'] || []).filter((c) => c.startsWith('oidc_id_token=')); + const full = setCookies[setCookies.length - 1]; + expect(full).toMatch(/HttpOnly/i); + expect(full).toMatch(/Path=\/api\/auth/i); + }); + + it('returns the IdP end-session URL on logout and clears the cookie', async () => { + idp.setNextUser({ sub: 'logout-sub-2', email: 'logout2@example.com', email_verified: true }); + const cbRes = await ssoRoundTrip(); + const cookie = idTokenCookie(cbRes); + const rawIdToken = decodeURIComponent(cookie.replace('oidc_id_token=', '')); + + const res = await request(app) + .post('/api/auth/logout') + .set('Cookie', cookie) + .expect(200); + + expect(res.body.ssoLogoutUrl).toBeTruthy(); + const url = new URL(res.body.ssoLogoutUrl); + expect(url.href.startsWith(`${idp.issuer}/logout`)).toBe(true); + expect(url.searchParams.get('id_token_hint')).toBe(rawIdToken); + expect(url.searchParams.get('post_logout_redirect_uri')).toBe('http://localhost:5199/admin/login'); + expect(url.searchParams.get('client_id')).toBe(idp.clientId); + + // Cookie must be cleared so a later local-password logout in the same + // browser doesn't bounce to the IdP again. + const cleared = (res.headers['set-cookie'] || []).find((c) => c.startsWith('oidc_id_token=')); + expect(cleared).toBeTruthy(); + expect(cleared).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i); + }); + + it('omits ssoLogoutUrl when the feature is disabled', async () => { + idp.setNextUser({ sub: 'logout-sub-3', email: 'logout3@example.com', email_verified: true }); + const cbRes = await ssoRoundTrip(); + const cookie = idTokenCookie(cbRes); + + await oidcService.saveOidcSettings({ oidc_logout_from_idp: false }); + try { + const res = await request(app) + .post('/api/auth/logout') + .set('Cookie', cookie) + .expect(200); + expect(res.body.ssoLogoutUrl).toBeUndefined(); + } finally { + await oidcService.saveOidcSettings({ oidc_logout_from_idp: true }); + } + }); + + it('omits ssoLogoutUrl without an oidc_id_token cookie (local-password session)', async () => { + const res = await request(app).post('/api/auth/logout').expect(200); + expect(res.body.ssoLogoutUrl).toBeUndefined(); + }); + + it('omits ssoLogoutUrl when the IdP advertises no end_session_endpoint', async () => { + // Separate provider whose discovery document lacks end_session_endpoint; + // repointing the settings invalidates the discovery cache. + const bareIdp = new MockOidcProvider(); + bareIdp.advertiseEndSession = false; + const bareIssuer = await bareIdp.start(); + try { + await oidcService.saveOidcSettings({ + oidc_issuer_url: bareIssuer, + oidc_client_id: bareIdp.clientId, + oidc_client_secret: bareIdp.clientSecret, + }); + + bareIdp.setNextUser({ sub: 'logout-sub-4', email: 'logout4@example.com', email_verified: true }); + const cbRes = await ssoRoundTrip(); + const cookie = idTokenCookie(cbRes); + expect(cookie).toBeTruthy(); + + const res = await request(app) + .post('/api/auth/logout') + .set('Cookie', cookie) + .expect(200); + expect(res.body.ssoLogoutUrl).toBeUndefined(); + } finally { + await bareIdp.stop(); + await oidcService.saveOidcSettings({ + oidc_issuer_url: idp.issuer, + oidc_client_id: idp.clientId, + oidc_client_secret: idp.clientSecret, + }); + } + }); + + it('stores a bare marker for oversized ID tokens; logout still round-trips, without a hint', async () => { + idp.setNextUser({ + sub: 'logout-sub-5', + email: 'logout5@example.com', + email_verified: true, + // ~9KB of group claims — far past the 4KB cookie limit. + groups: Array.from({ length: 300 }, (_, i) => `group-${String(i).padStart(4, '0')}-xxxxxxxxxxxxxxxx`), + }); + const cbRes = await ssoRoundTrip(); + const cookie = idTokenCookie(cbRes); + expect(cookie).toBeTruthy(); + expect(decodeURIComponent(cookie.replace('oidc_id_token=', ''))).toBe('sso'); + + const res = await request(app) + .post('/api/auth/logout') + .set('Cookie', cookie) + .expect(200); + expect(res.body.ssoLogoutUrl).toBeTruthy(); + const url = new URL(res.body.ssoLogoutUrl); + expect(url.searchParams.get('id_token_hint')).toBeNull(); + expect(url.searchParams.get('client_id')).toBe(idp.clientId); + }); + + it('a fresh local-password login clears a stale SSO marker', async () => { + const role = await db('roles').where({ name: 'admin' }).first(); + await db('admin_users').insert({ + username: 'stale-marker-admin', + email: 'stale-marker@example.com', + password_hash: await bcrypt.hash('StaleMarker123!', 4), + role_id: role.id, + is_active: 1, + must_change_password: 0, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }); + + // Stale marker from a dead SSO session rides along on the login request. + const res = await request(app) + .post('/api/auth/admin/login') + .set('Cookie', 'oidc_id_token=stale.jwt.value') + .send({ username: 'stale-marker-admin', password: 'StaleMarker123!' }) + .expect(200); + + const cleared = (res.headers['set-cookie'] || []).find((c) => c.startsWith('oidc_id_token=')); + expect(cleared).toBeTruthy(); + expect(cleared).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i); + }); + + it('skips the round-trip when the stored hint was issued by a DIFFERENT issuer (config changed)', async () => { + // Fake-but-well-formed JWT from another IdP — payload is all that matters, + // buildEndSessionUrl decodes without verification for routing only. + const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + const foreignToken = `${b64({ alg: 'none' })}.${b64({ iss: 'http://other-idp.example', aud: idp.clientId })}.sig`; + + const res = await request(app) + .post('/api/auth/logout') + .set('Cookie', `oidc_id_token=${foreignToken}`) + .expect(200); + expect(res.body.ssoLogoutUrl).toBeUndefined(); + }); + + it('drops only the hint when the issuer matches but the client changed', async () => { + const b64 = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url'); + const oldClientToken = `${b64({ alg: 'none' })}.${b64({ iss: idp.issuer, aud: 'previous-client-id' })}.sig`; + + const res = await request(app) + .post('/api/auth/logout') + .set('Cookie', `oidc_id_token=${oldClientToken}`) + .expect(200); + expect(res.body.ssoLogoutUrl).toBeTruthy(); + const url = new URL(res.body.ssoLogoutUrl); + expect(url.searchParams.get('id_token_hint')).toBeNull(); + expect(url.searchParams.get('client_id')).toBe(idp.clientId); + }); + + it('exposes the flag and post_logout_redirect_uri via getOidcConfig/getPostLogoutRedirectUri', async () => { + // Settings-route auth chains are covered in oidcSso.test.js; here the + // service surface the routes read from is pinned directly. + const cfg = await oidcService.getOidcConfig(); + expect(cfg.logoutFromIdp).toBe(true); + expect(await oidcService.getPostLogoutRedirectUri()).toBe('http://localhost:5199/admin/login'); + }); +}); diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 3de13821..e467d938 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -430,6 +430,7 @@ router.get('/sso', adminAuth, requirePermission('settings.view'), async (req, re // than failing the whole settings read; the login route refuses to start // the flow in that state anyway (OIDC_BAD_CONFIG). const redirectUri = await oidcService.getRedirectUri().catch(() => ''); + const postLogoutRedirectUri = await oidcService.getPostLogoutRedirectUri().catch(() => ''); res.json({ oidc_enabled: cfg.enabled, oidc_issuer_url: cfg.issuerUrl || '', @@ -444,7 +445,9 @@ router.get('/sso', adminAuth, requirePermission('settings.view'), async (req, re oidc_role_mappings: cfg.roleMappings, oidc_require_mapped_role: cfg.requireMappedRole, oidc_disable_local_login: cfg.disableLocalLogin, + oidc_logout_from_idp: cfg.logoutFromIdp, redirect_uri: redirectUri, + post_logout_redirect_uri: postLogoutRedirectUri, }); } catch (error) { logger.error('Failed to read SSO settings', { error: error.message }); @@ -466,6 +469,7 @@ router.put('/sso', adminAuth, requirePermission('settings.edit'), [ body('oidc_role_mappings').optional().isObject(), body('oidc_require_mapped_role').optional().isBoolean(), body('oidc_disable_local_login').optional().isBoolean(), + body('oidc_logout_from_idp').optional().isBoolean(), ], async (req, res) => { try { const errors = validationResult(req); diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index f6425720..3936a1f7 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -25,6 +25,8 @@ const { clearGalleryAuthCookies, getAdminTokenFromRequest, getGalleryTokenFromRequest, + buildCookieOptionsWithExpiry, + buildClearCookieOptions, } = require('../utils/tokenUtils'); const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService'); const { getClientIp } = require('../utils/requestIp'); @@ -76,6 +78,13 @@ async function establishAdminSession(res, admin, ipAddress, userAgent, lockoutKe setAdminAuthCookie(res, token); + // A fresh login supersedes any SSO marker a previous session left behind + // (#798 phase 3): sessions can die without /logout (deactivation, expiry, + // restore), and a stale marker would bounce a subsequent local-password + // session to the IdP on logout. The SSO callback re-sets the marker for + // its own session right after this returns. + res.clearCookie(OIDC_ID_TOKEN_COOKIE, oidcIdTokenClearOptions()); + return { id: admin.id, username: admin.username, @@ -335,7 +344,22 @@ router.post('/logout', async (req, res) => { clearGalleryAuthCookies(res); } - res.json({ message: 'Logged out successfully' }); + // RP-initiated logout (#798 phase 3): when this browser session came in + // via SSO (marked by the oidc_id_token cookie set by the callback) and + // the feature is enabled, hand the frontend the IdP's end-session URL to + // navigate to after the local logout. Never blocks the local logout — + // buildEndSessionUrl returns null on any failure. + let ssoLogoutUrl = null; + const oidcIdToken = req.cookies?.[OIDC_ID_TOKEN_COOKIE]; + if (oidcIdToken) { + res.clearCookie(OIDC_ID_TOKEN_COOKIE, oidcIdTokenClearOptions()); + // The bare 'sso' marker (oversized ID token at login) still triggers + // the round-trip, just without an id_token_hint. + const hint = oidcIdToken.split('.').length === 3 ? oidcIdToken : undefined; + ssoLogoutUrl = await require('../services/oidcService').buildEndSessionUrl(hint); + } + + res.json({ message: 'Logged out successfully', ...(ssoLogoutUrl ? { ssoLogoutUrl } : {}) }); } catch (error) { errorResponse(res, error, 500, 'Logout failed'); } @@ -945,6 +969,27 @@ function oidcStateCookieOptions(req) { }; } +// Raw ID token of the SSO session, kept for RP-initiated logout (#798 +// phase 3): /logout sends it to the IdP as id_token_hint so the IdP ends +// its session without a confirmation prompt. Its presence is also the +// marker that THIS browser session came in via SSO — local-password +// sessions must never be bounced to the IdP on logout. Path covers both +// the callback (which sets it) and /api/auth/logout (which consumes it). +// Options derive from the shared cookie policy (COOKIE_SAMESITE / +// COOKIE_DOMAIN / secure resolution) — in split-origin deployments the +// admin session runs on SameSite=None, and a hardcoded Lax here would +// mean the cookie never reaches the cross-site /logout XHR, silently +// disabling logout-to-IdP. The default maxAge matches the 24h admin JWT. +const OIDC_ID_TOKEN_COOKIE = 'oidc_id_token'; + +function oidcIdTokenCookieOptions(res) { + return { ...buildCookieOptionsWithExpiry(res), path: '/api/auth' }; +} + +function oidcIdTokenClearOptions() { + return { ...buildClearCookieOptions(), path: '/api/auth' }; +} + // Kick off the IdP round-trip. 404 when SSO is off so the endpoint is // invisible on non-SSO installs. router.get('/admin/sso/login', async (req, res) => { @@ -999,7 +1044,7 @@ router.get('/admin/sso/callback', async (req, res) => { const callbackUrl = new URL(await oidcService.getRedirectUri()); callbackUrl.search = req.originalUrl.split('?')[1] || ''; - const claims = await oidcService.handleCallback(callbackUrl.href, { + const { claims, idToken } = await oidcService.handleCallback(callbackUrl.href, { state: stash.s, nonce: stash.n, codeVerifier: stash.cv, @@ -1018,6 +1063,16 @@ router.get('/admin/sso/callback', async (req, res) => { const userAgent = req.headers['user-agent'] || ''; await establishAdminSession(res, admin, ipAddress, userAgent, admin.username); + // Cookie limit is 4KB — an oversized ID token (huge group lists) can't + // be stored, but the SSO-session marker must survive or logout-to-IdP + // silently turns off for exactly those users. Store the bare marker + // 'sso' instead; logout then sends the end-session request without an + // id_token_hint (costs one IdP confirmation click). + if (idToken) { + const cookieValue = idToken.length <= 3900 ? idToken : 'sso'; + res.cookie(OIDC_ID_TOKEN_COOKIE, cookieValue, oidcIdTokenCookieOptions(res)); + } + await logActivity('admin_sso_login', { provider: 'oidc' }, null, { type: 'admin', id: admin.id, name: admin.username, }); diff --git a/backend/src/services/oidcService.js b/backend/src/services/oidcService.js index 696c13c3..2d770895 100644 --- a/backend/src/services/oidcService.js +++ b/backend/src/services/oidcService.js @@ -89,7 +89,7 @@ function decryptSecret(stored) { */ async function getOidcConfig() { const [enabled, issuerUrl, clientId, encSecret, autoprovision, defaultRole, buttonLabel, scopes, - roleMappingEnabled, rolesClaim, roleMappings, requireMappedRole, disableLocalLogin] = + roleMappingEnabled, rolesClaim, roleMappings, requireMappedRole, disableLocalLogin, logoutFromIdp] = await Promise.all([ getAppSetting('oidc_enabled'), getAppSetting('oidc_issuer_url'), @@ -104,6 +104,7 @@ async function getOidcConfig() { getAppSetting('oidc_role_mappings'), getAppSetting('oidc_require_mapped_role'), getAppSetting('oidc_disable_local_login'), + getAppSetting('oidc_logout_from_idp'), ]); let clientSecret = null; @@ -134,6 +135,7 @@ async function getOidcConfig() { ? roleMappings : {}, requireMappedRole: requireMappedRole === true, disableLocalLogin: disableLocalLogin === true, + logoutFromIdp: logoutFromIdp === true, }; } @@ -179,7 +181,7 @@ async function isLocalLoginDisabled() { // Short-TTL cache of the flag for the UNAUTHENTICATED public-settings // endpoint, which every new client hits — without it each request pays the -// full 13-key config read. The login route keeps using the uncached check +// full 14-key config read. The login route keeps using the uncached check // (enforcement must be exact); a UI that lags a settings flip by ≤10s only // shows a form whose submit the API answers authoritatively. Invalidated on // every settings save in this worker. @@ -295,7 +297,8 @@ async function buildAuthorizationRequest() { /** * Exchange the authorization code and validate the ID token (issuer, * audience, signature, nonce, state — all enforced by openid-client). - * Returns the ID token claims. + * Returns the ID token claims plus the raw ID token — the callback route + * keeps the latter for RP-initiated logout (`id_token_hint`, #798 phase 3). */ async function handleCallback(currentUrl, { state, nonce, codeVerifier }) { const cfg = await getOidcConfig(); @@ -339,7 +342,77 @@ async function handleCallback(currentUrl, { state, nonce, codeVerifier }) { } } - return claims; + return { claims, idToken: tokenSet.id_token || null }; +} + +/** + * The URL guests land on after the IdP finishes its logout. Exposed in the + * SSO settings so the admin can register it at the IdP (Keycloak: "Valid + * post logout redirect URIs") — unregistered URIs make the IdP show an + * error instead of returning. Empty when no public base URL is configured. + */ +async function getPostLogoutRedirectUri() { + const { getFrontendBaseUrl } = require('../utils/frontendUrl'); + const base = ((await getFrontendBaseUrl().catch(() => '')) || '').replace(/\/$/, ''); + return base ? `${base}/admin/login` : ''; +} + +/** + * RP-initiated logout URL (#798 phase 3). Returns null whenever the IdP + * round-trip should not happen — feature off, SSO unconfigured, discovery + * failing, or the IdP advertising no end_session_endpoint — so the caller + * treats null as "finish the local logout normally". A logout must never + * fail because the IdP is unreachable. + */ +/** Decode a JWT payload WITHOUT verification — only for routing decisions + * on claims we minted no trust in (which IdP/client issued this token). */ +function decodeJwtPayload(token) { + try { + return JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8')); + } catch (_) { + return null; + } +} + +async function buildEndSessionUrl(idTokenHint) { + try { + const cfg = await getOidcConfig(); + if (!cfg.enabled || !cfg.logoutFromIdp || !isConfigured(cfg)) return null; + const { client, issuerMetadata } = await getClient(cfg); + if (!issuerMetadata.end_session_endpoint) return null; + + // The hint comes from a cookie minted at login — the OIDC config may + // have changed since. A token from a DIFFERENT issuer means the session + // belongs to another IdP entirely: skip the round-trip (ending the new + // IdP's session would be wrong, and providers that validate the hint + // would strand the user on an error page). Same issuer but a different + // client (aud): the hint is unusable, but the browser's IdP session is + // real — round-trip without the hint. + let hint = idTokenHint; + if (hint) { + const payload = decodeJwtPayload(hint); + if (!payload || payload.iss !== issuerMetadata.issuer) return null; + const audMatches = Array.isArray(payload.aud) + ? payload.aud.includes(cfg.clientId) + : payload.aud === cfg.clientId; + if (!audMatches) hint = undefined; + } + + const postLogoutRedirectUri = await getPostLogoutRedirectUri(); + return client.endSessionUrl({ + // Without the hint most IdPs (Keycloak included) fall back to a + // "do you really want to log out?" confirmation page — still correct, + // just one extra click. client_id keeps the request valid either way. + ...(hint ? { id_token_hint: hint } : {}), + ...(postLogoutRedirectUri ? { post_logout_redirect_uri: postLogoutRedirectUri } : {}), + client_id: cfg.clientId, + }); + } catch (err) { + logger.warn('OIDC end-session URL could not be built — finishing local logout only', { + error: err.message, + }); + return null; + } } /** @@ -636,6 +709,7 @@ async function saveOidcSettings(input) { } if (input.oidc_require_mapped_role !== undefined) put('oidc_require_mapped_role', input.oidc_require_mapped_role === true, 'boolean'); if (input.oidc_disable_local_login !== undefined) put('oidc_disable_local_login', input.oidc_disable_local_login === true, 'boolean'); + if (input.oidc_logout_from_idp !== undefined) put('oidc_logout_from_idp', input.oidc_logout_from_idp === true, 'boolean'); if (typeof input.oidc_client_secret === 'string' && input.oidc_client_secret.length > 0) { put('oidc_client_secret', encryptSecret(input.oidc_client_secret), 'string'); } @@ -654,8 +728,10 @@ module.exports = { extractRolesFromClaims, resolveMappedRole, getRedirectUri, + getPostLogoutRedirectUri, buildAuthorizationRequest, handleCallback, + buildEndSessionUrl, resolveAdminFromClaims, saveOidcSettings, invalidateDiscoveryCache, diff --git a/backend/src/utils/tokenUtils.js b/backend/src/utils/tokenUtils.js index 2b12acb2..a059aa96 100644 --- a/backend/src/utils/tokenUtils.js +++ b/backend/src/utils/tokenUtils.js @@ -230,6 +230,8 @@ function getGuestTokenFromRequest(req, slug) { module.exports = { ADMIN_COOKIE_NAME, + buildCookieOptionsWithExpiry, + buildClearCookieOptions, GALLERY_COOKIE_NAME, GALLERY_COOKIE_PREFIX, GUEST_COOKIE_PREFIX, diff --git a/frontend/src/features/settings/tabs/SsoTab.tsx b/frontend/src/features/settings/tabs/SsoTab.tsx index 57eba2e7..3aa8a535 100644 --- a/frontend/src/features/settings/tabs/SsoTab.tsx +++ b/frontend/src/features/settings/tabs/SsoTab.tsx @@ -107,6 +107,7 @@ export const SsoTab: React.FC = () => { // (rightly) rejects an explicit true while SSO is off, and the promise // is that disabling SSO restores password login. oidc_disable_local_login: form.oidc_enabled ? form.oidc_disable_local_login : false, + oidc_logout_from_idp: form.oidc_enabled ? form.oidc_logout_from_idp : false, }; if (newSecret.trim()) payload.oidc_client_secret = newSecret.trim(); saveMutation.mutate(payload); @@ -424,6 +425,35 @@ export const SsoTab: React.FC = () => { {t('settings.sso.policy.breakGlassHint', 'Locked out because the IdP is down or misconfigured? Set the environment variable OIDC_BREAK_GLASS=true on the backend and restart — password login comes back immediately.')} )} + + {/* Logout-to-IdP (#798 phase 3) */} + + + {form.oidc_logout_from_idp && form.post_logout_redirect_uri && ( +
+

+ {t('settings.sso.policy.postLogoutRedirectUri', 'Post-logout redirect URI (register this on your IdP client, e.g. Keycloak "Valid post logout redirect URIs")')} +

+ + {form.post_logout_redirect_uri} + +
+ )} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 8a156d98..e5a00719 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -2158,7 +2158,10 @@ "title": "Anmelde-Richtlinie", "disableLocalLogin": "Lokale Passwort-Anmeldung deaktivieren", "disableLocalLoginHint": "Die Anmeldeseite zeigt nur noch den SSO-Button und die API lehnt Passwort-Anmeldungen ab. Nur möglich, solange SSO aktiviert ist; wird SSO deaktiviert, ist die Passwort-Anmeldung automatisch wieder möglich.", - "breakGlassHint": "Ausgesperrt, weil der IdP down oder falsch konfiguriert ist? Setzen Sie die Umgebungsvariable OIDC_BREAK_GLASS=true am Backend und starten Sie neu — die Passwort-Anmeldung ist sofort wieder möglich." + "breakGlassHint": "Ausgesperrt, weil der IdP down oder falsch konfiguriert ist? Setzen Sie die Umgebungsvariable OIDC_BREAK_GLASS=true am Backend und starten Sie neu — die Passwort-Anmeldung ist sofort wieder möglich.", + "logoutFromIdp": "Auch beim Identity Provider abmelden", + "logoutFromIdpHint": "Die Abmeldung von PicPeak beendet auch die IdP-Sitzung (RP-initiated Logout). Gilt nur für Sitzungen, die per SSO angemeldet wurden; ohne diese Option bleibt die IdP-Sitzung bestehen und der nächste SSO-Klick meldet direkt wieder an.", + "postLogoutRedirectUri": "Post-Logout-Redirect-URI (beim IdP-Client registrieren, z. B. Keycloak „Valid post logout redirect URIs“)" } } }, diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index e1acf7e2..3e74ac56 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -1705,7 +1705,10 @@ "title": "Login policy", "disableLocalLogin": "Disable local password login", "disableLocalLoginHint": "The login page shows only the SSO button and the API refuses password logins. Only possible while SSO is enabled; turning SSO off restores password login automatically.", - "breakGlassHint": "Locked out because the IdP is down or misconfigured? Set the environment variable OIDC_BREAK_GLASS=true on the backend and restart — password login comes back immediately." + "breakGlassHint": "Locked out because the IdP is down or misconfigured? Set the environment variable OIDC_BREAK_GLASS=true on the backend and restart — password login comes back immediately.", + "logoutFromIdp": "Also sign out of the identity provider", + "logoutFromIdpHint": "Logging out of PicPeak also ends the IdP session (RP-initiated logout). Only applies to sessions that signed in via SSO; without this, logging out of PicPeak leaves the IdP session alive and the next SSO click signs straight back in.", + "postLogoutRedirectUri": "Post-logout redirect URI (register this on your IdP client, e.g. Keycloak \"Valid post logout redirect URIs\")" } } }, diff --git a/frontend/src/services/auth.service.ts b/frontend/src/services/auth.service.ts index ff081149..768fbce5 100644 --- a/frontend/src/services/auth.service.ts +++ b/frontend/src/services/auth.service.ts @@ -35,10 +35,14 @@ export const authService = { async adminLogout() { try { - await api.post('/auth/logout'); + const response = await api.post('/auth/logout'); + // RP-initiated logout (#798 phase 3): for SSO sessions with + // logout-to-IdP enabled, the backend hands back the IdP's end-session + // URL — navigate there so the IdP session ends too; the IdP returns + // to /admin/login afterwards. + window.location.href = response.data?.ssoLogoutUrl || '/admin/login'; } catch (err) { // Ignore logout errors; fallback to redirect - } finally { window.location.href = '/admin/login'; } }, diff --git a/frontend/src/services/sso.service.ts b/frontend/src/services/sso.service.ts index 564f5455..daddee88 100644 --- a/frontend/src/services/sso.service.ts +++ b/frontend/src/services/sso.service.ts @@ -21,7 +21,10 @@ export interface SsoSettings { oidc_role_mappings: Record; oidc_require_mapped_role: boolean; oidc_disable_local_login: boolean; + oidc_logout_from_idp: boolean; redirect_uri: string; + /** Register this at the IdP (e.g. Keycloak "Valid post logout redirect URIs"). */ + post_logout_redirect_uri: string; } export interface UpdateSsoSettings { @@ -39,6 +42,7 @@ export interface UpdateSsoSettings { oidc_role_mappings?: Record; oidc_require_mapped_role?: boolean; oidc_disable_local_login?: boolean; + oidc_logout_from_idp?: boolean; } export interface SsoTestResult {