first commit
This commit is contained in:
470
app.js
Normal file
470
app.js
Normal file
@@ -0,0 +1,470 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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('[Session Binding] Registered', {
|
||||
sessionId,
|
||||
sid: sid || '(none)',
|
||||
sub: sub || '(none)',
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
removeSessionBinding(sessionId);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
req.session.destroy(() => {
|
||||
if (res) {
|
||||
res.clearCookie('baron.demo.sid');
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function verifyBackchannelLogoutToken({
|
||||
logoutToken,
|
||||
expectedIssuer,
|
||||
expectedAudience,
|
||||
jwks,
|
||||
}) {
|
||||
const { payload } = 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');
|
||||
}
|
||||
|
||||
return {
|
||||
sid,
|
||||
sub,
|
||||
jti,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
async function destroySessionsForLogout(store, claims) {
|
||||
const sessionIds = getSessionIdsForLogoutClaims(claims);
|
||||
let destroyedCount = 0;
|
||||
|
||||
for (const sessionId of sessionIds) {
|
||||
removeSessionBinding(sessionId);
|
||||
try {
|
||||
await destroySessionById(store, sessionId);
|
||||
destroyedCount += 1;
|
||||
} catch (error) {
|
||||
console.error('[Backchannel Logout] Failed to destroy session', {
|
||||
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(`Discovering issuer: ${issuerUrl}`);
|
||||
console.log(`Back-channel logout JWKS: ${backchannelJwksUrl}`);
|
||||
const issuer = await discovery(new URL(issuerUrl), clientId);
|
||||
issuer.token_endpoint_auth_method = 'none';
|
||||
|
||||
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('[Session Validation] Baron session is no longer valid', {
|
||||
path: req.path,
|
||||
reason: validation.reason,
|
||||
});
|
||||
await destroyDemoSession(req, res);
|
||||
return res.redirect('/');
|
||||
} catch (error) {
|
||||
console.error('[Session Validation] Failed to validate Baron session', 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[Login Start] Session: ${req.sessionID}`);
|
||||
|
||||
if (!req.session.state) {
|
||||
req.session.code_verifier = randomPKCECodeVerifier();
|
||||
req.session.state = randomState();
|
||||
req.session.nonce = randomNonce();
|
||||
console.log(`[Login] New state generated: ${req.session.state}`);
|
||||
} else {
|
||||
console.log(`[Login] Re-using existing state: ${req.session.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,
|
||||
});
|
||||
|
||||
res.redirect(url.href);
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/callback', async (req, res) => {
|
||||
console.log(`\n[Callback Start] Session: ${req.sessionID}`);
|
||||
console.log(`[Callback Info] State from URL: ${req.query.state}`);
|
||||
console.log(`[Callback Info] State in Session: ${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('[Callback Success] Token exchanged');
|
||||
|
||||
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;
|
||||
|
||||
req.session.save(() => res.redirect('/profile'));
|
||||
} catch (err) {
|
||||
console.error('[Callback 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()
|
||||
: '';
|
||||
|
||||
if (!logoutToken) {
|
||||
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('[Backchannel Logout] Processed', {
|
||||
sid: claims.sid || '(none)',
|
||||
sub: claims.sub || '(none)',
|
||||
destroyedCount: result.destroyedCount,
|
||||
sessionIds: result.sessionIds,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
destroyedSessionCount: result.destroyedCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Backchannel Logout] Verification failed', error);
|
||||
return res.status(400).json({
|
||||
error: 'invalid logout token',
|
||||
detail: error.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/profile', (req, res) => {
|
||||
if (!req.session.user) {
|
||||
return res.redirect('/');
|
||||
}
|
||||
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(`Demo app listening at http://localhost:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
setupOIDC().catch((err) => {
|
||||
console.error('OIDC setup failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user