import assert from 'node:assert/strict'; import { mkdtempSync, writeFileSync } from 'node:fs'; import http from 'node:http'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { test } from 'node:test'; import { handleInternalWebmailSso, isSameOriginRequest, loadWebmailSsoConfig } from '../src/webmail-sso.js'; const secret = 'ab'.repeat(32); const audience = 'https://mail.us.ss5.xyz'; const ticket = `mht_${'a'.repeat(43)}`; const credential = `mhw_${'b'.repeat(43)}`; test('Webmail SSO configuration requires HTTPS and a strong file-backed secret', () => { const secretFile = writeSecret(secret); const config = loadWebmailSsoConfig({ WEBMAIL_SSO_URL: `${audience}/?_task=mail`, WEBMAIL_SSO_SECRET_FILE: secretFile, WEBMAIL_SSO_TICKET_TTL_SECONDS: '45', WEBMAIL_SSO_CREDENTIAL_TTL_SECONDS: '3600' }); assert.equal(config.enabled, true); assert.equal(config.url, `${audience}/`); assert.equal(config.audience, audience); assert.equal(config.ticketTtlSeconds, 45); assert.equal(config.credentialTtlSeconds, 3600); assert.throws( () => loadWebmailSsoConfig({ WEBMAIL_SSO_URL: audience }), /configured together/ ); assert.throws( () => loadWebmailSsoConfig({ WEBMAIL_SSO_URL: 'http://webmail.example.com/', WEBMAIL_SSO_SECRET_FILE: secretFile }), /must use HTTPS/ ); assert.throws( () => loadWebmailSsoConfig({ WEBMAIL_SSO_URL: audience, WEBMAIL_SSO_SECRET_FILE: writeSecret('short') }), /64-512 hexadecimal/ ); assert.throws( () => loadWebmailSsoConfig({ WEBMAIL_SSO_URL: audience, WEBMAIL_SSO_SECRET_FILE: writeSecret('z'.repeat(64)) }), /64-512 hexadecimal/ ); }); test('Webmail ticket creation origin check accepts only the configured or proxied application origin', () => { const request = { headers: { origin: 'https://mail-send.ss5.xyz', host: 'app:3000', 'x-forwarded-host': 'mail-send.ss5.xyz', 'x-forwarded-proto': 'https' }, socket: { encrypted: false } }; assert.equal(isSameOriginRequest(request, 'https://mail-send.ss5.xyz/', { trustProxy: true }), true); assert.equal(isSameOriginRequest({ ...request, headers: { ...request.headers, origin: 'https://evil.example' } }, 'https://mail-send.ss5.xyz/', { trustProxy: true }), false); assert.equal(isSameOriginRequest({ ...request, headers: { ...request.headers, origin: '' } }, 'https://mail-send.ss5.xyz/', { trustProxy: true }), false); }); test('Webmail internal exchange is authenticated, audience-bound, single purpose, and private', async () => { const exchanges = []; const revocations = []; const config = loadWebmailSsoConfig({ WEBMAIL_SSO_URL: `${audience}/`, WEBMAIL_SSO_SECRET_FILE: writeSecret(secret) }); const server = http.createServer((req, res) => { const url = new URL(req.url || '/', 'http://mailhub.internal'); void handleInternalWebmailSso(req, res, url, config, { exchangeWebmailLoginTicket(value, options) { exchanges.push({ value, options }); if (exchanges.length > 1) return null; return { mailbox: { address: 'shared@example.com' }, credential, expiresAt: '2030-01-01T00:00:00.000Z' }; }, revokeWebmailCredential(value, options) { revocations.push({ value, options }); return true; } }); }); await listen(server); try { const unauthorized = await request(server, '/internal/webmail-sso/exchange', { authorization: 'Bearer wrong-secret', body: { ticket, audience } }); assert.equal(unauthorized.status, 401); assert.equal(exchanges.length, 0); const wrongAudience = await request(server, '/internal/webmail-sso/exchange', { body: { ticket, audience: 'https://other.example' } }); assert.equal(wrongAudience.status, 400); assert.equal(exchanges.length, 0); const exchanged = await request(server, '/internal/webmail-sso/exchange', { body: { ticket, audience } }); assert.equal(exchanged.status, 200); assert.equal(exchanged.headers['cache-control'], 'no-store'); assert.deepEqual(exchanged.json, { username: 'shared@example.com', credential, expiresAt: '2030-01-01T00:00:00.000Z' }); assert.deepEqual(exchanges, [{ value: ticket, options: { audience } }]); const replayed = await request(server, '/internal/webmail-sso/exchange', { body: { ticket, audience } }); assert.equal(replayed.status, 400); assert.equal(JSON.stringify(replayed.json).includes(ticket), false); const revoked = await request(server, '/internal/webmail-sso/revoke', { body: { credential, audience } }); assert.equal(revoked.status, 200); assert.deepEqual(revocations, [{ value: credential, options: { audience } }]); } finally { await close(server); } }); function writeSecret(value) { const directory = mkdtempSync(path.join(tmpdir(), 'mailhub-webmail-sso-')); const file = path.join(directory, 'secret'); writeFileSync(file, `${value}\n`, { mode: 0o600 }); return file; } function listen(server) { server.listen(0, '127.0.0.1'); return new Promise((resolve, reject) => { server.once('listening', resolve); server.once('error', reject); }); } function close(server) { return new Promise((resolve, reject) => { server.close((error) => error ? reject(error) : resolve()); }); } function request(server, requestPath, { authorization = `Bearer ${secret}`, body = {} } = {}) { const rawBody = JSON.stringify(body); return new Promise((resolve, reject) => { const req = http.request({ host: '127.0.0.1', port: server.address().port, path: requestPath, method: 'POST', headers: { Authorization: authorization, 'Content-Type': 'application/json', 'Content-Length': String(Buffer.byteLength(rawBody)) } }, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => { const raw = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, headers: res.headers, json: raw ? JSON.parse(raw) : null }); }); }); req.once('error', reject); req.end(rawBody); }); }