back channel-logout 관련 코드 추가 sever.js
This commit is contained in:
135
server.js
135
server.js
@@ -5,6 +5,7 @@ import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import session from 'express-session';
|
||||
import { createPublicKey, createVerify } from 'crypto';
|
||||
import { getSigningKey, getPrivateKeyPem } from './src/utils/jwks.js';
|
||||
|
||||
dotenv.config();
|
||||
@@ -37,10 +38,14 @@ const getDbConnectionSummary = () => ({
|
||||
database: dbConfig.database || '(missing)'
|
||||
});
|
||||
|
||||
const sessionIdsBySid = new Map();
|
||||
const sessionIdsBySub = new Map();
|
||||
|
||||
const app = express();
|
||||
app.set('trust proxy', 1);
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(session({
|
||||
secret: SESSION_SECRET_VALUE,
|
||||
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 ---
|
||||
const CATEGORY_TABLE_MAP = {
|
||||
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 appendCookies = (currentCookies, response) => {
|
||||
@@ -591,6 +696,7 @@ app.get('/api/auth/session', (req, res) => {
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
unregisterSessionIdentity(req.sessionID, req.session.user);
|
||||
req.session.destroy(() => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
@@ -620,6 +726,7 @@ app.post('/api/auth/headless/login', async (req, res) => {
|
||||
tokenType: loginResult.tokens.token_type
|
||||
}
|
||||
};
|
||||
registerSessionIdentity(req.sessionID, req.session.user);
|
||||
|
||||
await saveSession(req);
|
||||
|
||||
@@ -715,6 +822,7 @@ app.post('/api/auth/headless/phone/poll', async (req, res) => {
|
||||
tokenType: result.tokens.token_type
|
||||
}
|
||||
};
|
||||
registerSessionIdentity(req.sessionID, req.session.user);
|
||||
|
||||
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) => {
|
||||
if (req.session.user) {
|
||||
return res.redirect('/');
|
||||
|
||||
Reference in New Issue
Block a user