Files
pkce-login-demo/app.js

537 lines
15 KiB
JavaScript

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 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`;
}
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));
const sessionValidationEnabled =
String(process.env.BARON_SESSION_VALIDATION_ENABLED || 'true').toLowerCase() !== 'false';
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);
return res.redirect('/');
} catch (error) {
console.error('[세션 검증] Baron 세션 확인 실패로 로컬 세션을 정리합니다.', error);
await destroyDemoSession(req, res);
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 };
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(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) => {
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);
});