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 path = require('path'); const { createBackchannelLogoutManager } = require('./backchannel-logout'); process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'; const app = express(); const port = process.env.PORT || 3000; app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); app.use(express.urlencoded({ extended: false })); const sessionStore = new session.MemoryStore(); const sessionMiddleware = session({ store: sessionStore, 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`; } 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, removeSessionBinding) { 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 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 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}`); 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, sessionValidationEnabled, }); if (sessionValidationEnabled) { 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, backchannelLogoutManager.removeSessionBinding); return res.redirect('/'); } catch (error) { console.error('[세션 검증] Baron 세션 확인 실패로 로컬 세션을 정리합니다.', error); await destroyDemoSession(req, res, backchannelLogoutManager.removeSessionBinding); return res.redirect('/'); } }); } else { console.log('[시스템] Baron 세션 재검증 미들웨어를 비활성화했습니다. 백채널 로그아웃 테스트 전용 모드입니다.'); } 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 }; backchannelLogoutManager.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', backchannelLogoutManager.handleBackchannelLogout); 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, backchannelLogoutManager.removeSessionBinding).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); });