diff --git a/server.js b/server.js index a261aae..66ee67b 100644 --- a/server.js +++ b/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('/');