back channel-logout 관련 코드 추가 sever.js
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 11s
ITAM Docker Build Check / docker-build-check (push) Successful in 14s

This commit is contained in:
2026-07-01 14:31:03 +09:00
parent 1f849cd1c5
commit 054ba146bf

135
server.js
View File

@@ -5,6 +5,7 @@ import dotenv from 'dotenv';
import fs from 'fs'; import fs from 'fs';
import crypto from 'crypto'; import crypto from 'crypto';
import session from 'express-session'; import session from 'express-session';
import { createPublicKey, createVerify } from 'crypto';
import { getSigningKey, getPrivateKeyPem } from './src/utils/jwks.js'; import { getSigningKey, getPrivateKeyPem } from './src/utils/jwks.js';
dotenv.config(); dotenv.config();
@@ -37,10 +38,14 @@ const getDbConnectionSummary = () => ({
database: dbConfig.database || '(missing)' database: dbConfig.database || '(missing)'
}); });
const sessionIdsBySid = new Map();
const sessionIdsBySub = new Map();
const app = express(); const app = express();
app.set('trust proxy', 1); app.set('trust proxy', 1);
app.use(cors()); app.use(cors());
app.use(express.json({ limit: '50mb' })); app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: false }));
app.use(session({ app.use(session({
secret: SESSION_SECRET_VALUE, secret: SESSION_SECRET_VALUE,
proxy: true, proxy: true,
@@ -130,6 +135,45 @@ const saveSession = (req) => new Promise((resolve, reject) => {
}); });
}); });
const addSessionIndex = (index, key, sessionId) => {
if (!key || !sessionId) return;
const existing = index.get(key) || new Set();
existing.add(sessionId);
index.set(key, existing);
};
const removeSessionIndex = (index, key, sessionId) => {
if (!key || !sessionId) return;
const existing = index.get(key);
if (!existing) return;
existing.delete(sessionId);
if (existing.size === 0) {
index.delete(key);
}
};
const unregisterSessionIdentity = (sessionId, user) => {
if (!sessionId || !user?.profile) return;
removeSessionIndex(sessionIdsBySid, user.profile.sid, sessionId);
removeSessionIndex(sessionIdsBySub, user.profile.sub, sessionId);
};
const registerSessionIdentity = (sessionId, user) => {
if (!sessionId || !user?.profile) return;
addSessionIndex(sessionIdsBySid, user.profile.sid, sessionId);
addSessionIndex(sessionIdsBySub, user.profile.sub, sessionId);
};
const destroySessionById = (store, sessionId) => new Promise((resolve, reject) => {
store.destroy(sessionId, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
// --- Global Constants --- // --- Global Constants ---
const CATEGORY_TABLE_MAP = { const CATEGORY_TABLE_MAP = {
pc: 'asset_core', pc: 'asset_core',
@@ -179,6 +223,67 @@ const parseJsonSafely = async (response) => {
} }
}; };
const decodeJwtSegment = (segment) => JSON.parse(Buffer.from(segment, 'base64url').toString('utf8'));
const verifyJwtWithJwk = (token, jwk) => {
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT format');
}
const [encodedHeader, encodedPayload, encodedSignature] = parts;
const signingInput = `${encodedHeader}.${encodedPayload}`;
const verifier = createVerify('RSA-SHA256');
verifier.update(signingInput);
verifier.end();
const publicKey = createPublicKey({ key: jwk, format: 'jwk' });
return verifier.verify(publicKey, Buffer.from(encodedSignature, 'base64url'));
};
const verifyBackchannelLogoutToken = async (logoutToken) => {
const [encodedHeader, encodedPayload] = logoutToken.split('.');
if (!encodedHeader || !encodedPayload) {
throw new Error('logout_token is missing JWT segments');
}
const header = decodeJwtSegment(encodedHeader);
const payload = decodeJwtSegment(encodedPayload);
const discovery = await fetchDiscoveryDocument();
const jwksResponse = await fetch(discovery.jwks_uri);
if (!jwksResponse.ok) {
throw new Error(`Failed to load issuer JWKS: ${jwksResponse.status}`);
}
const issuerKeySet = await jwksResponse.json();
const jwk = issuerKeySet?.keys?.find((candidate) => candidate.kid === header.kid) || issuerKeySet?.keys?.[0];
if (!jwk) {
throw new Error('Issuer JWKS did not contain a signing key');
}
if (!verifyJwtWithJwk(logoutToken, jwk)) {
throw new Error('logout_token signature verification failed');
}
const audience = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (payload.iss !== ISSUER) {
throw new Error('logout_token issuer mismatch');
}
if (!audience.includes(CLIENT_ID)) {
throw new Error('logout_token audience mismatch');
}
if (!payload.events?.['http://schemas.openid.net/event/backchannel-logout']) {
throw new Error('logout_token missing backchannel logout event');
}
if (payload.nonce) {
throw new Error('logout_token must not contain nonce');
}
if (!payload.sid && !payload.sub) {
throw new Error('logout_token must contain sid or sub');
}
return payload;
};
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const appendCookies = (currentCookies, response) => { const appendCookies = (currentCookies, response) => {
@@ -591,6 +696,7 @@ app.get('/api/auth/session', (req, res) => {
}); });
app.post('/api/auth/logout', (req, res) => { app.post('/api/auth/logout', (req, res) => {
unregisterSessionIdentity(req.sessionID, req.session.user);
req.session.destroy(() => { req.session.destroy(() => {
res.json({ success: true }); res.json({ success: true });
}); });
@@ -620,6 +726,7 @@ app.post('/api/auth/headless/login', async (req, res) => {
tokenType: loginResult.tokens.token_type tokenType: loginResult.tokens.token_type
} }
}; };
registerSessionIdentity(req.sessionID, req.session.user);
await saveSession(req); await saveSession(req);
@@ -715,6 +822,7 @@ app.post('/api/auth/headless/phone/poll', async (req, res) => {
tokenType: result.tokens.token_type tokenType: result.tokens.token_type
} }
}; };
registerSessionIdentity(req.sessionID, req.session.user);
await saveSession(req); await saveSession(req);
@@ -725,6 +833,33 @@ app.post('/api/auth/headless/phone/poll', async (req, res) => {
} }
}); });
app.post('/api/auth/backchannel-logout', async (req, res) => {
const logoutToken = req.body?.logout_token;
if (!logoutToken) {
return res.status(400).json({ error: 'logout_token is required' });
}
try {
const payload = await verifyBackchannelLogoutToken(logoutToken);
const targetSessionIds = new Set([
...(payload.sid ? Array.from(sessionIdsBySid.get(payload.sid) || []) : []),
...(payload.sub ? Array.from(sessionIdsBySub.get(payload.sub) || []) : [])
]);
for (const sessionId of targetSessionIds) {
removeSessionIndex(sessionIdsBySid, payload.sid, sessionId);
removeSessionIndex(sessionIdsBySub, payload.sub, sessionId);
await destroySessionById(req.sessionStore, sessionId);
}
return res.status(200).json({ success: true, destroyedSessions: targetSessionIds.size });
} catch (error) {
console.error('Back-channel logout failed:', error);
return res.status(400).json({ error: error.message || 'Back-channel logout failed' });
}
});
app.get('/callback', (req, res) => { app.get('/callback', (req, res) => {
if (req.session.user) { if (req.session.user) {
return res.redirect('/'); return res.redirect('/');