import crypto from 'node:crypto'; import { readFileSync } from 'node:fs'; import { exchangeWebmailLoginTicket, revokeWebmailCredential } from './db.js'; const internalPrefix = '/internal/webmail-sso/'; const bodyLimit = 8 * 1024; export function loadWebmailSsoConfig(env = process.env) { const url = normalizeWebmailUrl(env.WEBMAIL_SSO_URL); const secretFile = String(env.WEBMAIL_SSO_SECRET_FILE || '').trim(); if (!url) { return { enabled: false, url: '', audience: '', ticketTtlSeconds: 60, credentialTtlSeconds: 43_200, secretDigest: null }; } if (!secretFile) { throw new Error('Webmail SSO URL and secret file must be configured together'); } return { enabled: true, url, audience: new URL(url).origin, ticketTtlSeconds: boundedInteger(env.WEBMAIL_SSO_TICKET_TTL_SECONDS, 60, 10, 300), credentialTtlSeconds: boundedInteger(env.WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS, 43_200, 300, 86_400), secretDigest: digestSecret(readSharedSecret(secretFile)) }; } export async function handleInternalWebmailSso(req, res, url, config, dependencies = {}) { if (!url.pathname.startsWith(internalPrefix)) return false; setPrivateHeaders(res); if (!config.enabled) { sendJson(res, 503, { error: 'Webmail SSO is unavailable.' }); return true; } if (req.method !== 'POST') { res.setHeader('Allow', 'POST'); sendJson(res, 405, { error: 'Method not allowed.' }); return true; } if (!validBearerSecret(req.headers.authorization, config.secretDigest)) { sendJson(res, 401, { error: 'Unauthorized.' }); return true; } if (requestContentType(req) !== 'application/json') { sendJson(res, 415, { error: 'Unsupported media type.' }); return true; } let body; try { body = await readJson(req, bodyLimit); } catch (error) { sendJson(res, error instanceof RequestTooLargeError ? 413 : 400, { error: error instanceof RequestTooLargeError ? 'Request too large.' : 'Invalid request.' }); return true; } if (body.audience !== config.audience) { sendJson(res, 400, { error: 'Invalid request.' }); return true; } const exchange = dependencies.exchangeWebmailLoginTicket || exchangeWebmailLoginTicket; const revoke = dependencies.revokeWebmailCredential || revokeWebmailCredential; if (url.pathname === `${internalPrefix}exchange`) { const ticket = normalizeOpaqueCredential(body.ticket, 'mht_'); if (!ticket) { sendJson(res, 400, { error: 'Invalid or expired login.' }); return true; } const result = exchange(ticket, { audience: config.audience }); if (!result) { sendJson(res, 400, { error: 'Invalid or expired login.' }); return true; } sendJson(res, 200, { username: result.mailbox.address, credential: result.credential, expiresAt: result.expiresAt }); return true; } if (url.pathname === `${internalPrefix}revoke`) { const credential = normalizeOpaqueCredential(body.credential, 'mhw_'); if (!credential) { sendJson(res, 400, { error: 'Invalid request.' }); return true; } revoke(credential, { audience: config.audience }); sendJson(res, 200, { revoked: true }); return true; } sendJson(res, 404, { error: 'Not found.' }); return true; } export function isSameOriginRequest(req, appBaseUrl, { trustProxy = false } = {}) { const suppliedOrigin = normalizeOrigin(req.headers.origin); if (!suppliedOrigin) return false; const allowed = new Set(); const configuredOrigin = normalizeOrigin(appBaseUrl); if (configuredOrigin) allowed.add(configuredOrigin); const requestOrigin = externalRequestOrigin(req, trustProxy); if (requestOrigin) allowed.add(requestOrigin); return allowed.has(suppliedOrigin); } function externalRequestOrigin(req, trustProxy) { const forwardedHost = trustProxy ? firstForwardedValue(req.headers['x-forwarded-host']) : ''; const host = String(forwardedHost || req.headers.host || '').trim(); if (!host || /[\s/\\]/.test(host)) return ''; const forwardedProto = trustProxy ? firstForwardedValue(req.headers['x-forwarded-proto']) : ''; const protocol = String(forwardedProto || (req.socket?.encrypted ? 'https' : 'http')).toLowerCase(); if (!['http', 'https'].includes(protocol)) return ''; return normalizeOrigin(`${protocol}://${host}`); } function firstForwardedValue(value) { return String(value || '').split(',', 1)[0].trim(); } function normalizeWebmailUrl(value) { const raw = String(value || '').trim(); if (!raw) return ''; let parsed; try { parsed = new URL(raw); } catch { throw new Error('Webmail SSO URL is invalid'); } const localDevelopment = ['localhost', '127.0.0.1', '::1'].includes(parsed.hostname); if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && localDevelopment)) { throw new Error('Webmail SSO URL must use HTTPS'); } if (parsed.username || parsed.password) throw new Error('Webmail SSO URL must not contain credentials'); parsed.search = ''; parsed.hash = ''; return parsed.toString(); } function normalizeOrigin(value) { try { const parsed = new URL(String(value || '').trim()); if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) return ''; return parsed.origin; } catch { return ''; } } function normalizeOpaqueCredential(value, prefix) { const clean = String(value || ''); if (!clean.startsWith(prefix) || !/^[A-Za-z0-9_-]+$/.test(clean)) return ''; if (clean.length < 32 || clean.length > 256) return ''; return clean; } function readSharedSecret(filePath) { const secret = readFileSync(filePath, 'utf8').trim(); if (!/^[0-9a-fA-F]{64,512}$/.test(secret)) { throw new Error('Webmail SSO secret must contain 64-512 hexadecimal characters'); } return secret; } function validBearerSecret(header, expectedDigest) { if (!expectedDigest) return false; const match = String(header || '').match(/^Bearer\s+([^\s]+)$/i); const actualDigest = digestSecret(match?.[1] || ''); return Boolean(match) && crypto.timingSafeEqual(actualDigest, expectedDigest); } function digestSecret(value) { return crypto.createHash('sha256').update(value).digest(); } function requestContentType(req) { return String(req.headers['content-type'] || '').split(';', 1)[0].trim().toLowerCase(); } async function readJson(req, limit) { const chunks = []; let bytes = 0; for await (const chunk of req) { bytes += chunk.length; if (bytes > limit) throw new RequestTooLargeError(); chunks.push(chunk); } if (!chunks.length) throw new Error('Request body is required'); const body = JSON.parse(Buffer.concat(chunks).toString('utf8')); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('Object body is required'); return body; } function setPrivateHeaders(res) { res.setHeader('Cache-Control', 'no-store'); res.setHeader('Pragma', 'no-cache'); res.setHeader('X-Content-Type-Options', 'nosniff'); } function sendJson(res, status, payload) { const body = JSON.stringify(payload); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': String(Buffer.byteLength(body)) }); res.end(body); } function boundedInteger(value, fallback, minimum, maximum) { const parsed = Number(value ?? fallback); if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { throw new Error(`Webmail SSO TTL must be between ${minimum} and ${maximum} seconds`); } return parsed; } class RequestTooLargeError extends Error {}