first commit
This commit is contained in:
314
app.js
Normal file
314
app.js
Normal file
@@ -0,0 +1,314 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const session = require('express-session');
|
||||
const {
|
||||
discovery,
|
||||
randomNonce,
|
||||
randomState,
|
||||
buildAuthorizationUrl,
|
||||
authorizationCodeGrant,
|
||||
fetchUserInfo,
|
||||
ClientSecretBasic,
|
||||
ClientSecretPost,
|
||||
} = 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 || 4444;
|
||||
|
||||
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.server.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 createClientAuthentication(method, clientSecret) {
|
||||
switch (method) {
|
||||
case 'client_secret_basic':
|
||||
return ClientSecretBasic(clientSecret);
|
||||
case 'client_secret_post':
|
||||
return ClientSecretPost(clientSecret);
|
||||
default:
|
||||
throw new Error(`Unsupported OIDC client auth method: ${method}`);
|
||||
}
|
||||
}
|
||||
|
||||
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.server.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 clientSecret = process.env.OIDC_CLIENT_SECRET || 'demo-secret';
|
||||
const redirectUri = process.env.OIDC_REDIRECT_URI || 'http://localhost:4444/callback';
|
||||
const clientAuthMethod = process.env.OIDC_CLIENT_AUTH_METHOD || 'client_secret_basic';
|
||||
const backchannelJwksUrl = deriveBackchannelJwksUrl();
|
||||
const sessionValidationEnabled =
|
||||
String(process.env.BARON_SESSION_VALIDATION_ENABLED || 'true').toLowerCase() !== 'false';
|
||||
const clientAuthentication = createClientAuthentication(clientAuthMethod, clientSecret);
|
||||
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,
|
||||
{
|
||||
client_secret: clientSecret,
|
||||
token_endpoint_auth_method: clientAuthMethod,
|
||||
},
|
||||
clientAuthentication,
|
||||
);
|
||||
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,
|
||||
clientAuthMethod,
|
||||
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', (req, res) => {
|
||||
console.log(`\n[로그인 시작] 세션 ID: ${req.sessionID}`);
|
||||
|
||||
if (!req.session.state) {
|
||||
req.session.state = randomState();
|
||||
req.session.nonce = randomNonce();
|
||||
console.log('[로그인] 신규 state/nonce 생성 완료');
|
||||
} else {
|
||||
console.log('[로그인] 기존 state 재사용');
|
||||
}
|
||||
|
||||
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',
|
||||
nonce: req.session.nonce,
|
||||
state: req.session.state,
|
||||
response_type: 'code',
|
||||
});
|
||||
|
||||
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) {
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
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.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);
|
||||
});
|
||||
Reference in New Issue
Block a user