백채널 로그아웃 기능 모듈화
This commit is contained in:
266
app.js
266
app.js
@@ -11,23 +11,14 @@ const {
|
|||||||
authorizationCodeGrant,
|
authorizationCodeGrant,
|
||||||
fetchUserInfo,
|
fetchUserInfo,
|
||||||
} = require('openid-client');
|
} = require('openid-client');
|
||||||
const { createRemoteJWKSet, jwtVerify } = require('jose');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { createBackchannelLogoutManager } = require('./backchannel-logout');
|
||||||
|
|
||||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
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 app = express();
|
||||||
const port = process.env.PORT || 3000;
|
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('view engine', 'ejs');
|
||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
|
|
||||||
@@ -67,116 +58,6 @@ function deriveBackchannelJwksUrl() {
|
|||||||
return `${deriveBaronApiBaseUrl()}/api/v1/auth/backchannel/jwks.json`;
|
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) {
|
async function validateBaronSession(accessToken) {
|
||||||
if (!accessToken) {
|
if (!accessToken) {
|
||||||
return { ok: false, reason: 'missing_access_token' };
|
return { ok: false, reason: 'missing_access_token' };
|
||||||
@@ -204,7 +85,7 @@ async function validateBaronSession(accessToken) {
|
|||||||
return { ok: true, profile };
|
return { ok: true, profile };
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroyDemoSession(req, res) {
|
function destroyDemoSession(req, res, removeSessionBinding) {
|
||||||
const sessionId = req.sessionID;
|
const sessionId = req.sessionID;
|
||||||
console.log('[로컬 로그아웃] 현재 세션 정리 시작', { sessionId });
|
console.log('[로컬 로그아웃] 현재 세션 정리 시작', { sessionId });
|
||||||
removeSessionBinding(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() {
|
async function setupOIDC() {
|
||||||
const issuerUrl = process.env.OIDC_ISSUER_URL || 'http://localhost:5000/oidc';
|
const issuerUrl = process.env.OIDC_ISSUER_URL || 'http://localhost:5000/oidc';
|
||||||
const clientId = process.env.OIDC_CLIENT_ID || 'demo-client';
|
const clientId = process.env.OIDC_CLIENT_ID || 'demo-client';
|
||||||
const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://localhost:3000/callback';
|
const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://localhost:3000/callback';
|
||||||
const backchannelJwksUrl = deriveBackchannelJwksUrl();
|
const backchannelJwksUrl = deriveBackchannelJwksUrl();
|
||||||
const backchannelJwks = createRemoteJWKSet(new URL(backchannelJwksUrl));
|
|
||||||
const sessionValidationEnabled =
|
const sessionValidationEnabled =
|
||||||
String(process.env.BARON_SESSION_VALIDATION_ENABLED || 'true').toLowerCase() !== 'false';
|
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(`OIDC Issuer 조회: ${issuerUrl}`);
|
||||||
console.log(`백채널 로그아웃 JWKS 주소: ${backchannelJwksUrl}`);
|
console.log(`백채널 로그아웃 JWKS 주소: ${backchannelJwksUrl}`);
|
||||||
@@ -354,11 +159,11 @@ async function setupOIDC() {
|
|||||||
path: req.path,
|
path: req.path,
|
||||||
reason: validation.reason,
|
reason: validation.reason,
|
||||||
});
|
});
|
||||||
await destroyDemoSession(req, res);
|
await destroyDemoSession(req, res, backchannelLogoutManager.removeSessionBinding);
|
||||||
return res.redirect('/');
|
return res.redirect('/');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[세션 검증] Baron 세션 확인 실패로 로컬 세션을 정리합니다.', error);
|
console.error('[세션 검증] Baron 세션 확인 실패로 로컬 세션을 정리합니다.', error);
|
||||||
await destroyDemoSession(req, res);
|
await destroyDemoSession(req, res, backchannelLogoutManager.removeSessionBinding);
|
||||||
return res.redirect('/');
|
return res.redirect('/');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -443,7 +248,7 @@ async function setupOIDC() {
|
|||||||
).catch(() => tokenClaims);
|
).catch(() => tokenClaims);
|
||||||
|
|
||||||
req.session.user = { tokenset, userinfo };
|
req.session.user = { tokenset, userinfo };
|
||||||
registerSessionBinding(req.sessionID, tokenClaims);
|
backchannelLogoutManager.registerSessionBinding(req.sessionID, tokenClaims);
|
||||||
|
|
||||||
delete req.session.state;
|
delete req.session.state;
|
||||||
delete req.session.code_verifier;
|
delete req.session.code_verifier;
|
||||||
@@ -465,50 +270,7 @@ async function setupOIDC() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/backchannel-logout', async (req, res) => {
|
app.post('/backchannel-logout', backchannelLogoutManager.handleBackchannelLogout);
|
||||||
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.get('/profile', (req, res) => {
|
app.get('/profile', (req, res) => {
|
||||||
if (!req.session.user) {
|
if (!req.session.user) {
|
||||||
@@ -520,7 +282,7 @@ async function setupOIDC() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get('/logout', (req, res) => {
|
app.get('/logout', (req, res) => {
|
||||||
destroyDemoSession(req, res).then(() => {
|
destroyDemoSession(req, res, backchannelLogoutManager.removeSessionBinding).then(() => {
|
||||||
res.redirect('/');
|
res.redirect('/');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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