* feat(auth): OIDC logout-to-IdP — phase 3 (#798) RP-initiated logout behind a new oidc_logout_from_idp setting: logging out of PicPeak also ends the IdP session. The SSO callback stores the raw ID token in an HttpOnly cookie (also the marker that the session came in via SSO — local-password sessions never bounce to the IdP); /logout builds the end_session URL from discovery metadata with id_token_hint + post_logout_redirect_uri + client_id and returns it as ssoLogoutUrl for the frontend to navigate to. Any failure (no end_session_endpoint, IdP unreachable, feature off) degrades to the plain local logout. Settings surface exposes the toggle plus the computed post-logout redirect URI to register at the IdP. Session timeouts deliberately stay local-only. 6 integration tests over the mock IdP; live-verified against Keycloak 26 (logout ends the Keycloak session, no confirmation prompt). * fix(auth): harden the SSO logout marker cookie (#798 phase 3) Codex review round 1: - Derive the oidc_id_token cookie options from the shared cookie policy (COOKIE_SAMESITE / COOKIE_DOMAIN / secure resolution) — hardcoded Lax meant split-origin deployments running on SameSite=None never sent the marker to the cross-site /logout XHR, silently disabling logout-to-IdP. - Oversized ID tokens (>3.9KB) now store a bare 'sso' marker instead of no cookie, so the claimed client_id-only end-session fallback actually happens; /logout only passes the value as id_token_hint when it is a real JWT. - establishAdminSession clears any stale marker on every fresh login — sessions can die without /logout (deactivation, expiry, restore), and a surviving marker would bounce a later local-password session to the IdP. The SSO callback re-sets the marker for its own session. Tests: oversized-token marker + hint-less end-session URL, stale-marker cleared on local login; helper updated for the clear+set cookie pair. * fix(auth): validate the logout hint against the current OIDC config (#798 phase 3) Codex review round 2: an ID token stored at login can outlive an issuer/client config change; sending it to the newly configured IdP as id_token_hint strands the user on the IdP's error page (providers validate iss/aud on the hint). buildEndSessionUrl now decodes the hint (no verification — routing only): different issuer → skip the round-trip entirely (the session belongs to another IdP); same issuer but changed client → keep the round-trip, drop the unusable hint. Two tests pin both paths. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
c5dc790e28
commit
219d07b04a
@@ -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'],
|
||||
|
||||
@@ -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=<jwt>") 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: '[email protected]', 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: '[email protected]', 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: '[email protected]', 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: '[email protected]', 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: '[email protected]',
|
||||
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: '[email protected]',
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -230,6 +230,8 @@ function getGuestTokenFromRequest(req, slug) {
|
||||
|
||||
module.exports = {
|
||||
ADMIN_COOKIE_NAME,
|
||||
buildCookieOptionsWithExpiry,
|
||||
buildClearCookieOptions,
|
||||
GALLERY_COOKIE_NAME,
|
||||
GALLERY_COOKIE_PREFIX,
|
||||
GUEST_COOKIE_PREFIX,
|
||||
|
||||
Reference in New Issue
Block a user