require('dotenv').config(); const express = require('express'); const session = require('express-session'); const { discovery, randomPKCECodeVerifier, randomNonce, randomState, calculatePKCECodeChallenge, buildAuthorizationUrl, authorizationCodeGrant, fetchUserInfo, } = require('openid-client'); const { createRemoteJWKSet, jwtVerify } = require('jose'); const path = require('path'); 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')); app.use(express.urlencoded({ extended: false })); const sessionMiddleware = session({ name: 'baron.demo.sid', secret: process.env.SESSION_SECRET || 'demo-session-secret', resave: true, saveUninitialized: true, cookie: { secure: false, httpOnly: true, sameSite: 'lax', maxAge: 30 * 60 * 1000, }, }); app.use(sessionMiddleware); function deriveBaronApiBaseUrl() { const explicit = (process.env.BARON_API_BASE_URL || '').trim(); if (explicit) { return explicit.replace(/\/$/, ''); } const issuerUrl = (process.env.OIDC_ISSUER_URL || 'http://localhost:5000/oidc').trim(); return issuerUrl.replace(/\/oidc\/?$/, ''); } function deriveBackchannelJwksUrl() { const explicit = (process.env.BARON_BACKCHANNEL_JWKS_URL || '').trim(); if (explicit) { return explicit; } 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' }; } const baseUrl = deriveBaronApiBaseUrl(); const response = await fetch(`${baseUrl}/api/v1/user/me`, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, }); if (!response.ok) { const detail = await response.text().catch(() => ''); return { ok: false, reason: `baron_validation_failed:${response.status}`, detail, }; } const profile = await response.json().catch(() => null); return { ok: true, profile }; } function destroyDemoSession(req, res) { const sessionId = req.sessionID; console.log('[로컬 로그아웃] 현재 세션 정리 시작', { sessionId }); removeSessionBinding(sessionId); return new Promise((resolve) => { req.session.destroy(() => { if (res) { res.clearCookie('baron.demo.sid'); } console.log('[로컬 로그아웃] 현재 세션 정리 완료', { sessionId }); resolve(); }); }); } 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)); console.log(`OIDC Issuer 조회: ${issuerUrl}`); console.log(`백채널 로그아웃 JWKS 주소: ${backchannelJwksUrl}`); const issuer = await discovery(new URL(issuerUrl), clientId); issuer.token_endpoint_auth_method = 'none'; const authorizationEndpoint = (typeof issuer.serverMetadata === 'function' ? issuer.serverMetadata()?.authorization_endpoint : undefined) || issuer.authorization_server_metadata?.authorization_endpoint || issuer.authorization_endpoint || '(확인 불가)'; console.log('[시스템] OIDC 설정 완료', { clientId, redirectUri, authorizationEndpoint, }); app.use(async (req, res, next) => { const skipPaths = new Set(['/login', '/callback', '/logout', '/backchannel-logout']); if (skipPaths.has(req.path)) { return next(); } const accessToken = req.session?.user?.tokenset?.access_token; if (!accessToken) { return next(); } try { const validation = await validateBaronSession(accessToken); if (validation.ok) { if (validation.profile) { req.session.user.userinfo = validation.profile; } return next(); } console.warn('[세션 검증] Baron 세션이 유효하지 않아 로컬 세션을 정리합니다.', { path: req.path, reason: validation.reason, }); await destroyDemoSession(req, res); return res.redirect('/'); } catch (error) { console.error('[세션 검증] Baron 세션 확인 실패로 로컬 세션을 정리합니다.', error); await destroyDemoSession(req, res); return res.redirect('/'); } }); app.get('/', (req, res) => { res.render('index', { user: req.session.user }); }); app.get('/login', async (req, res) => { console.log(`\n[로그인 시작] 세션 ID: ${req.sessionID}`); if (!req.session.state) { req.session.code_verifier = randomPKCECodeVerifier(); req.session.state = randomState(); req.session.nonce = randomNonce(); console.log('[로그인] 신규 state/nonce/code_verifier 생성 완료'); } else { console.log('[로그인] 기존 state 재사용'); } const code_challenge = await calculatePKCECodeChallenge(req.session.code_verifier); req.session.save((err) => { if (err) { return res.status(500).send('Session save failed'); } const url = buildAuthorizationUrl(issuer, { redirect_uri: redirectUri, scope: 'openid profile email', code_challenge, code_challenge_method: 'S256', nonce: req.session.nonce, state: req.session.state, }); console.log('[로그인] Baron 인증 화면으로 이동', { url: url.href }); res.redirect(url.href); }); }); app.get('/callback', async (req, res) => { console.log(`\n[콜백 시작] 세션 ID: ${req.sessionID}`); console.log('[콜백] URL state / 세션 state', { urlState: req.query.state, sessionState: req.session.state, }); if (!req.session.state || !req.session.code_verifier) { if (req.session.user) { return res.redirect('/profile'); } return res.status(400).render('error', { message: 'Session Data Missing', detail: '세션 정보가 유실되었습니다. 브라우저가 쿠키를 차단했는지 확인하세요.', }); } try { const currentUrl = new URL(req.url, `http://${req.headers.host}`); const tokenset = await authorizationCodeGrant( issuer, currentUrl, { expectedNonce: req.session.nonce, expectedState: req.session.state, pkceCodeVerifier: req.session.code_verifier, }, ); console.log('[콜백] Authorization Code -> Token 교환 성공'); const tokenClaims = tokenset.claims(); const userinfo = await fetchUserInfo( issuer, tokenset.access_token, tokenClaims.sub, ).catch(() => tokenClaims); req.session.user = { tokenset, userinfo }; registerSessionBinding(req.sessionID, tokenClaims); delete req.session.state; delete req.session.code_verifier; delete req.session.nonce; console.log('[콜백] 사용자 세션 생성 완료', { sessionId: req.sessionID, sid: tokenClaims.sid || '(없음)', sub: tokenClaims.sub || '(없음)', }); req.session.save(() => res.redirect('/profile')); } catch (err) { console.error('[콜백] 인증 처리 실패', err); res.status(500).render('error', { message: 'Authentication Failed', detail: err.message, }); } }); 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(sessionMiddleware.store, 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) => { if (!req.session.user) { console.log('[프로필] 비로그인 상태로 접근하여 루트로 이동'); return res.redirect('/'); } console.log('[프로필] 로그인 세션으로 접근', { sessionId: req.sessionID }); res.render('profile', { user: req.session.user }); }); app.get('/logout', (req, res) => { destroyDemoSession(req, res).then(() => { res.redirect('/'); }); }); app.listen(port, '0.0.0.0', () => { console.log(`[시스템] 데모 앱이 실행 중입니다. http://localhost:${port}`); }); } setupOIDC().catch((err) => { console.error('[시스템] OIDC 초기화 실패', err); process.exit(1); });