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 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('/');
|
||||||
|
|||||||
Reference in New Issue
Block a user