백채널 로그아웃 기능 모듈화
This commit is contained in:
274
backchannel-logout.js
Normal file
274
backchannel-logout.js
Normal file
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user