const { createRemoteJWKSet, jwtVerify } = require('jose'); const BACKCHANNEL_LOGOUT_EVENT_URI = 'http://schemas.openid.net/event/backchannel-logout'; const LOGOUT_TOKEN_REPLAY_TTL_MS = 10 * 60 * 1000; function createBackchannelLogoutManager(options) { const { issuerUrl, clientId, jwksUrl, sessionStore, logger = console, } = options; if (!issuerUrl) { throw new Error('issuerUrl is required'); } if (!clientId) { throw new Error('clientId is required'); } if (!jwksUrl) { throw new Error('jwksUrl is required'); } if (!sessionStore || typeof sessionStore.destroy !== 'function') { throw new Error('sessionStore.destroy is required'); } const sidToSessionIds = new Map(); const subToSessionIds = new Map(); const sessionIdToBinding = new Map(); const processedLogoutTokens = new Map(); const jwks = createRemoteJWKSet(new URL(jwksUrl)); function addSessionBinding(map, key, sessionId) { if (!key) { return; } let existing = map.get(key); if (!existing) { existing = new Set(); map.set(key, existing); } existing.add(sessionId); } function removeSessionBindingFromMap(map, key, sessionId) { if (!key) { return; } const existing = map.get(key); if (!existing) { return; } existing.delete(sessionId); if (existing.size === 0) { map.delete(key); } } function removeSessionBinding(sessionId) { const existing = sessionIdToBinding.get(sessionId); if (!existing) { return; } removeSessionBindingFromMap(sidToSessionIds, existing.sid, sessionId); removeSessionBindingFromMap(subToSessionIds, existing.sub, sessionId); sessionIdToBinding.delete(sessionId); logger.log('[세션 매핑] 제거 완료', { sessionId, sid: existing.sid || '(없음)', sub: existing.sub || '(없음)', }); } function registerSessionBinding(sessionId, claims) { const sid = typeof claims?.sid === 'string' ? claims.sid.trim() : ''; const sub = typeof claims?.sub === 'string' ? claims.sub.trim() : ''; removeSessionBinding(sessionId); sessionIdToBinding.set(sessionId, { sid, sub }); addSessionBinding(sidToSessionIds, sid, sessionId); addSessionBinding(subToSessionIds, sub, sessionId); logger.log('[세션 매핑] 등록 완료', { sessionId, sid: sid || '(없음)', sub: sub || '(없음)', sidSessionCount: sid ? sidToSessionIds.get(sid)?.size || 0 : 0, subSessionCount: sub ? subToSessionIds.get(sub)?.size || 0 : 0, }); } function getSessionIdsForLogoutClaims(claims) { const targets = new Set(); const sid = typeof claims?.sid === 'string' ? claims.sid.trim() : ''; const sub = typeof claims?.sub === 'string' ? claims.sub.trim() : ''; if (sid && sidToSessionIds.has(sid)) { for (const sessionId of sidToSessionIds.get(sid)) { targets.add(sessionId); } } if (targets.size === 0 && sub && subToSessionIds.has(sub)) { for (const sessionId of subToSessionIds.get(sub)) { targets.add(sessionId); } } return Array.from(targets); } function destroySessionById(sessionId) { return new Promise((resolve, reject) => { sessionStore.destroy(sessionId, (err) => { if (err) { reject(err); return; } resolve(); }); }); } function cleanupProcessedLogoutTokens(now = Date.now()) { for (const [jti, expiresAt] of processedLogoutTokens.entries()) { if (expiresAt <= now) { processedLogoutTokens.delete(jti); } } } function rememberProcessedLogoutToken(jti) { cleanupProcessedLogoutTokens(); if (processedLogoutTokens.has(jti)) { return false; } processedLogoutTokens.set(jti, Date.now() + LOGOUT_TOKEN_REPLAY_TTL_MS); return true; } async function verifyLogoutToken(logoutToken) { const { payload, protectedHeader } = await jwtVerify(logoutToken, jwks, { issuer: issuerUrl, audience: clientId, }); if (payload.nonce !== undefined) { throw new Error('logout_token must not include nonce'); } if (!payload.events || typeof payload.events !== 'object') { throw new Error('logout_token is missing events claim'); } if (!(BACKCHANNEL_LOGOUT_EVENT_URI in payload.events)) { throw new Error('logout_token is missing back-channel logout event'); } const sid = typeof payload.sid === 'string' ? payload.sid.trim() : ''; const sub = typeof payload.sub === 'string' ? payload.sub.trim() : ''; if (!sid && !sub) { throw new Error('logout_token requires sid or sub'); } const jti = typeof payload.jti === 'string' ? payload.jti.trim() : ''; if (!jti) { throw new Error('logout_token is missing jti'); } if (!rememberProcessedLogoutToken(jti)) { throw new Error('logout_token replay detected'); } logger.log('[백채널 로그아웃] 토큰 검증 성공', { alg: protectedHeader.alg, kid: protectedHeader.kid || '(없음)', iss: payload.iss, aud: payload.aud, sid: sid || '(없음)', sub: sub || '(없음)', jti, }); return { sid, sub, jti, payload, }; } async function destroySessionsForLogout(claims) { const sessionIds = getSessionIdsForLogoutClaims(claims); let destroyedCount = 0; logger.log('[백채널 로그아웃] 세션 탐색 결과', { sid: claims.sid || '(없음)', sub: claims.sub || '(없음)', matchedSessionIds: sessionIds, }); for (const sessionId of sessionIds) { removeSessionBinding(sessionId); try { await destroySessionById(sessionId); destroyedCount += 1; logger.log('[백채널 로그아웃] 세션 파기 완료', { sessionId }); } catch (error) { logger.error('[백채널 로그아웃] 세션 파기 실패', { sessionId, error: error.message, }); } } return { sessionIds, destroyedCount }; } async function handleBackchannelLogout(req, res) { const logoutToken = typeof req.body.logout_token === 'string' ? req.body.logout_token.trim() : ''; logger.log('\n[백채널 로그아웃] 요청 수신', { hasLogoutToken: logoutToken !== '', contentType: req.headers['content-type'] || '(없음)', userAgent: req.headers['user-agent'] || '(없음)', }); if (!logoutToken) { logger.warn('[백채널 로그아웃] logout_token 누락'); return res.status(400).json({ error: 'logout_token is required' }); } try { const claims = await verifyLogoutToken(logoutToken); const result = await destroySessionsForLogout(claims); logger.log('[백채널 로그아웃] 처리 완료', { sid: claims.sid || '(없음)', sub: claims.sub || '(없음)', destroyedCount: result.destroyedCount, sessionIds: result.sessionIds, }); return res.status(200).json({ success: true, destroyedSessionCount: result.destroyedCount, }); } catch (error) { logger.error('[백채널 로그아웃] 검증 또는 세션 정리 실패', error); return res.status(400).json({ error: 'invalid logout token', detail: error.message, }); } } return { registerSessionBinding, removeSessionBinding, handleBackchannelLogout, }; } module.exports = { createBackchannelLogoutManager, };