From 88bac84c1d23e031ceb969828b72f059eecea3ef Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 6 May 2026 13:57:39 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=B1=EC=B1=84=EB=84=90=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=95=84=EC=9B=83=20=EA=B8=B0=EB=8A=A5=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.js | 266 +++------------------------------------- backchannel-logout.js | 274 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+), 252 deletions(-) create mode 100644 backchannel-logout.js diff --git a/app.js b/app.js index 37803da..d0d44a2 100644 --- a/app.js +++ b/app.js @@ -11,23 +11,14 @@ const { authorizationCodeGrant, fetchUserInfo, } = require('openid-client'); -const { createRemoteJWKSet, jwtVerify } = require('jose'); const path = require('path'); +const { createBackchannelLogoutManager } = require('./backchannel-logout'); process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; -const BACKCHANNEL_LOGOUT_EVENT_URI = - 'http://schemas.openid.net/event/backchannel-logout'; -const LOGOUT_TOKEN_REPLAY_TTL_MS = 10 * 60 * 1000; - const app = express(); const port = process.env.PORT || 3000; -const sidToSessionIds = new Map(); -const subToSessionIds = new Map(); -const sessionIdToBinding = new Map(); -const processedLogoutTokens = new Map(); - app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); @@ -67,116 +58,6 @@ function deriveBackchannelJwksUrl() { return `${deriveBaronApiBaseUrl()}/api/v1/auth/backchannel/jwks.json`; } -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); - - console.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); - - console.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(store, sessionId) { - return new Promise((resolve, reject) => { - store.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 validateBaronSession(accessToken) { if (!accessToken) { return { ok: false, reason: 'missing_access_token' }; @@ -204,7 +85,7 @@ async function validateBaronSession(accessToken) { return { ok: true, profile }; } -function destroyDemoSession(req, res) { +function destroyDemoSession(req, res, removeSessionBinding) { const sessionId = req.sessionID; console.log('[로컬 로그아웃] 현재 세션 정리 시작', { sessionId }); removeSessionBinding(sessionId); @@ -220,96 +101,20 @@ function destroyDemoSession(req, res) { }); } -async function verifyBackchannelLogoutToken({ - logoutToken, - expectedIssuer, - expectedAudience, - jwks, -}) { - const { payload, protectedHeader } = await jwtVerify(logoutToken, jwks, { - issuer: expectedIssuer, - audience: expectedAudience, - }); - - 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'); - } - - console.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(store, claims) { - const sessionIds = getSessionIdsForLogoutClaims(claims); - let destroyedCount = 0; - - console.log('[백채널 로그아웃] 세션 탐색 결과', { - sid: claims.sid || '(없음)', - sub: claims.sub || '(없음)', - matchedSessionIds: sessionIds, - }); - - for (const sessionId of sessionIds) { - removeSessionBinding(sessionId); - try { - await destroySessionById(store, sessionId); - destroyedCount += 1; - console.log('[백채널 로그아웃] 세션 파기 완료', { sessionId }); - } catch (error) { - console.error('[백채널 로그아웃] 세션 파기 실패', { - sessionId, - error: error.message, - }); - } - } - - return { sessionIds, destroyedCount }; -} - async function setupOIDC() { const issuerUrl = process.env.OIDC_ISSUER_URL || 'http://localhost:5000/oidc'; const clientId = process.env.OIDC_CLIENT_ID || 'demo-client'; const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://localhost:3000/callback'; const backchannelJwksUrl = deriveBackchannelJwksUrl(); - const backchannelJwks = createRemoteJWKSet(new URL(backchannelJwksUrl)); const sessionValidationEnabled = String(process.env.BARON_SESSION_VALIDATION_ENABLED || 'true').toLowerCase() !== 'false'; + const backchannelLogoutManager = createBackchannelLogoutManager({ + issuerUrl, + clientId, + jwksUrl: backchannelJwksUrl, + sessionStore, + logger: console, + }); console.log(`OIDC Issuer 조회: ${issuerUrl}`); console.log(`백채널 로그아웃 JWKS 주소: ${backchannelJwksUrl}`); @@ -354,11 +159,11 @@ async function setupOIDC() { path: req.path, reason: validation.reason, }); - await destroyDemoSession(req, res); + await destroyDemoSession(req, res, backchannelLogoutManager.removeSessionBinding); return res.redirect('/'); } catch (error) { console.error('[세션 검증] Baron 세션 확인 실패로 로컬 세션을 정리합니다.', error); - await destroyDemoSession(req, res); + await destroyDemoSession(req, res, backchannelLogoutManager.removeSessionBinding); return res.redirect('/'); } }); @@ -443,7 +248,7 @@ async function setupOIDC() { ).catch(() => tokenClaims); req.session.user = { tokenset, userinfo }; - registerSessionBinding(req.sessionID, tokenClaims); + backchannelLogoutManager.registerSessionBinding(req.sessionID, tokenClaims); delete req.session.state; delete req.session.code_verifier; @@ -465,50 +270,7 @@ async function setupOIDC() { } }); - app.post('/backchannel-logout', async (req, res) => { - const logoutToken = typeof req.body.logout_token === 'string' - ? req.body.logout_token.trim() - : ''; - - console.log('\n[백채널 로그아웃] 요청 수신', { - hasLogoutToken: logoutToken !== '', - contentType: req.headers['content-type'] || '(없음)', - userAgent: req.headers['user-agent'] || '(없음)', - }); - - if (!logoutToken) { - console.warn('[백채널 로그아웃] logout_token 누락'); - return res.status(400).json({ error: 'logout_token is required' }); - } - - try { - const claims = await verifyBackchannelLogoutToken({ - logoutToken, - expectedIssuer: issuerUrl, - expectedAudience: clientId, - jwks: backchannelJwks, - }); - - const result = await destroySessionsForLogout(sessionStore, claims); - console.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) { - console.error('[백채널 로그아웃] 검증 또는 세션 정리 실패', error); - return res.status(400).json({ - error: 'invalid logout token', - detail: error.message, - }); - } - }); + app.post('/backchannel-logout', backchannelLogoutManager.handleBackchannelLogout); app.get('/profile', (req, res) => { if (!req.session.user) { @@ -520,7 +282,7 @@ async function setupOIDC() { }); app.get('/logout', (req, res) => { - destroyDemoSession(req, res).then(() => { + destroyDemoSession(req, res, backchannelLogoutManager.removeSessionBinding).then(() => { res.redirect('/'); }); }); diff --git a/backchannel-logout.js b/backchannel-logout.js new file mode 100644 index 0000000..54c18ba --- /dev/null +++ b/backchannel-logout.js @@ -0,0 +1,274 @@ +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, +};