feat(auth): migrate public authentication presentation
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
// Test-only mail capture. Never imported by the application.
|
||||
import { createServer as createSMTPServer } from "node:net";
|
||||
import { createServer as createHTTPServer } from "node:http";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const maxMailBytes = 128 * 1024;
|
||||
|
||||
export async function startLocalSMTPFixture({ smtpPort = 0, controlPort = 0 } = {}) {
|
||||
const messages = [];
|
||||
const sockets = new Set();
|
||||
const smtp = createSMTPServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
socket.on("error", () => socket.destroy());
|
||||
socket.setEncoding("latin1");
|
||||
socket.write("220 localhost test capture ESMTP\r\n");
|
||||
let buffer = "";
|
||||
let sender = "";
|
||||
let recipients = [];
|
||||
let data = null;
|
||||
let dataBytes = 0;
|
||||
const reset = () => { sender = ""; recipients = []; data = null; dataBytes = 0; };
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
if (buffer.length + dataBytes > maxMailBytes) {
|
||||
reset();
|
||||
socket.end("552 Fixture message size exceeded\r\n");
|
||||
return;
|
||||
}
|
||||
while (buffer.includes("\n")) {
|
||||
const end = buffer.indexOf("\n");
|
||||
const line = buffer.slice(0, end).replace(/\r$/, "");
|
||||
buffer = buffer.slice(end + 1);
|
||||
if (data !== null) {
|
||||
if (line === ".") {
|
||||
try {
|
||||
if (messages.length >= 100) throw new Error("Fixture mail capacity exceeded.");
|
||||
messages.push({ sequence: messages.length + 1, recipients: [...recipients], body: decodePlainMessage(data.join("\r\n") + "\r\n") });
|
||||
socket.write("250 Captured locally\r\n");
|
||||
} catch {
|
||||
socket.write("554 Unsupported fixture mail\r\n");
|
||||
}
|
||||
reset();
|
||||
} else {
|
||||
dataBytes += line.length + 2;
|
||||
data.push(line.startsWith("..") ? line.slice(1) : line);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (/^(EHLO|HELO)(\s|$)/i.test(line)) socket.write(`250-localhost\r\n250 SIZE ${maxMailBytes}\r\n`);
|
||||
else if (/^MAIL FROM:/i.test(line)) {
|
||||
reset();
|
||||
sender = /^MAIL FROM:\s*<([^>]+)>/i.exec(line)?.[1] ?? "";
|
||||
socket.write(sender ? "250 Sender accepted\r\n" : "501 Invalid sender\r\n");
|
||||
} else if (/^RCPT TO:/i.test(line)) {
|
||||
const recipient = /^RCPT TO:\s*<([^>]+)>/i.exec(line)?.[1]?.toLowerCase() ?? "";
|
||||
if (!sender) socket.write("503 Sender required\r\n");
|
||||
else if (!/^[^@\s]+@[^@\s]+\.test$/.test(recipient)) socket.write("550 Only synthetic .test recipients are accepted\r\n");
|
||||
else { recipients.push(recipient); socket.write("250 Recipient accepted\r\n"); }
|
||||
} else if (/^DATA$/i.test(line)) {
|
||||
if (!sender || !recipients.length) socket.write("503 Envelope required\r\n");
|
||||
else { data = []; dataBytes = 0; socket.write("354 End with dot on its own line\r\n"); }
|
||||
} else if (/^RSET$/i.test(line)) { reset(); socket.write("250 Reset\r\n"); }
|
||||
else if (/^NOOP$/i.test(line)) socket.write("250 OK\r\n");
|
||||
else if (/^QUIT$/i.test(line)) { socket.end("221 Closing\r\n"); break; }
|
||||
else socket.write("502 Command unavailable in local fixture\r\n");
|
||||
}
|
||||
});
|
||||
});
|
||||
const control = createHTTPServer((request, response) => {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
response.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
if (request.method !== "GET" || request.headers.origin) { response.writeHead(403).end("{}"); return; }
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname === "/health") { response.end('{"status":"local-fixture"}'); return; }
|
||||
const recipient = (url.searchParams.get("recipient") ?? "").toLowerCase();
|
||||
if (url.pathname !== "/messages" || !/^[^@\s]+@[^@\s]+\.test$/.test(recipient)) { response.writeHead(400).end("{}"); return; }
|
||||
response.end(JSON.stringify(messages.filter((message) => message.recipients.includes(recipient)).map(({ sequence, body }) => ({ sequence, body }))));
|
||||
});
|
||||
const listen = (server, port) => new Promise((accept, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(port, "127.0.0.1", () => { server.off("error", reject); accept(); });
|
||||
});
|
||||
await listen(smtp, smtpPort);
|
||||
try { await listen(control, controlPort); }
|
||||
catch (error) { smtp.close(); throw error; }
|
||||
return {
|
||||
smtpPort: smtp.address().port,
|
||||
controlPort: control.address().port,
|
||||
close: async () => {
|
||||
sockets.forEach((socket) => socket.destroy());
|
||||
control.closeAllConnections();
|
||||
await Promise.all([smtp, control].map((server) => new Promise((accept) => server.close(accept))));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function decodePlainMessage(raw) {
|
||||
const separator = /\r?\n\r?\n/.exec(raw);
|
||||
if (!separator) throw new Error("Mail has no MIME header boundary.");
|
||||
const headers = raw.slice(0, separator.index).replace(/\r?\n[ \t]+/g, " ");
|
||||
const contentType = /^content-type:\s*([^\r\n]+)/im.exec(headers)?.[1] ?? "text/plain";
|
||||
if (!/^text\/plain(?:;|$)/i.test(contentType)) throw new Error("Only text/plain fixture mail is supported.");
|
||||
const charset = /charset=["']?([^;\s"']+)/i.exec(contentType)?.[1] ?? "utf-8";
|
||||
if (!/^(utf-8|us-ascii)$/i.test(charset)) throw new Error("Unsupported fixture mail charset.");
|
||||
const encoding = /^content-transfer-encoding:\s*([^\r\n]+)/im.exec(headers)?.[1].trim().toLowerCase() ?? "7bit";
|
||||
let body = raw.slice(separator.index + separator[0].length);
|
||||
if (encoding === "base64") return Buffer.from(body.replace(/\s/g, ""), "base64").toString("utf8");
|
||||
if (encoding === "quoted-printable") {
|
||||
body = body.replace(/=\r?\n/g, "").replace(/=([\da-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
||||
} else if (encoding !== "7bit" && encoding !== "8bit") {
|
||||
throw new Error("Unsupported fixture mail transfer encoding.");
|
||||
}
|
||||
return Buffer.from(body, "latin1").toString("utf8");
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const fixture = await startLocalSMTPFixture({
|
||||
smtpPort: Number.parseInt(process.env.AUTH_RECOVERY_SMTP_PORT ?? "2527", 10),
|
||||
controlPort: Number.parseInt(process.env.AUTH_RECOVERY_MAIL_PORT ?? "5303", 10),
|
||||
});
|
||||
process.stdout.write(`Local SMTP fixture ready on 127.0.0.1:${fixture.smtpPort}; control 127.0.0.1:${fixture.controlPort}\n`);
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => { void fixture.close(); });
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { createConnection } from "node:net";
|
||||
import { decodePlainMessage, startLocalSMTPFixture } from "./localSMTPfixture.mjs";
|
||||
|
||||
async function client(port) {
|
||||
const socket = createConnection({ host: "127.0.0.1", port });
|
||||
socket.setEncoding("utf8");
|
||||
let buffer = "";
|
||||
const lines = [];
|
||||
const waiters = [];
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
while (buffer.includes("\n")) {
|
||||
const end = buffer.indexOf("\n");
|
||||
const line = buffer.slice(0, end).replace(/\r$/, "");
|
||||
buffer = buffer.slice(end + 1);
|
||||
if (waiters.length) waiters.shift()(line);
|
||||
else lines.push(line);
|
||||
}
|
||||
});
|
||||
const readLine = () => lines.length ? Promise.resolve(lines.shift()) : new Promise((resolve) => waiters.push(resolve));
|
||||
const reply = async () => {
|
||||
let line;
|
||||
do { line = await readLine(); } while (/^\d{3}-/.test(line));
|
||||
return Number.parseInt(line, 10);
|
||||
};
|
||||
assert.equal(await reply(), 220);
|
||||
return { socket, reply, command: async (line) => { socket.write(line + "\r\n"); return reply(); } };
|
||||
}
|
||||
|
||||
test("loopback SMTP captures fragmented commands and dot-stuffed MIME for the envelope recipient only", { timeout: 5000 }, async (t) => {
|
||||
const fixture = await startLocalSMTPFixture();
|
||||
t.after(() => fixture.close());
|
||||
assert.ok(fixture.smtpPort > 0);
|
||||
const smtp = await client(fixture.smtpPort);
|
||||
t.after(() => smtp.socket.destroy());
|
||||
smtp.socket.write("EH");
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
smtp.socket.write("LO fixture\r\n");
|
||||
assert.equal(await smtp.reply(), 250);
|
||||
assert.equal(await smtp.command("MAIL FROM:<sender@example.test>"), 250);
|
||||
assert.equal(await smtp.command("RCPT TO:<outside@example.com>"), 550);
|
||||
assert.equal(await smtp.command("RCPT TO:<reader@example.test>"), 250);
|
||||
assert.equal(await smtp.command("DATA"), 354);
|
||||
smtp.socket.write("Content-Type: text/plain; charset=utf-8\r\n\r\n..leading dot\r\nmessage\r\n.\r\n");
|
||||
assert.equal(await smtp.reply(), 250);
|
||||
assert.equal(await smtp.command("QUIT"), 221);
|
||||
const response = await fetch(`http://127.0.0.1:${fixture.controlPort}/messages?recipient=reader%40example.test`);
|
||||
assert.equal(response.headers.get("cache-control"), "no-store");
|
||||
assert.equal(response.headers.get("access-control-allow-origin"), null);
|
||||
assert.deepEqual(await response.json(), [{ sequence: 1, body: ".leading dot\r\nmessage\r\n" }]);
|
||||
assert.deepEqual(await (await fetch(`http://127.0.0.1:${fixture.controlPort}/messages?recipient=other%40example.test`)).json(), []);
|
||||
});
|
||||
|
||||
test("SMTP reset discards the previous envelope and rejects DATA without a recipient", { timeout: 5000 }, async (t) => {
|
||||
const fixture = await startLocalSMTPFixture();
|
||||
t.after(() => fixture.close());
|
||||
assert.ok(fixture.smtpPort > 0);
|
||||
const smtp = await client(fixture.smtpPort);
|
||||
t.after(() => smtp.socket.destroy());
|
||||
assert.equal(await smtp.command("DATA"), 503);
|
||||
assert.equal(await smtp.command("MAIL FROM:<sender@example.test>"), 250);
|
||||
assert.equal(await smtp.command("RCPT TO:<reader@example.test>"), 250);
|
||||
assert.equal(await smtp.command("RSET"), 250);
|
||||
assert.equal(await smtp.command("DATA"), 503);
|
||||
assert.equal(await smtp.command("QUIT"), 221);
|
||||
assert.deepEqual(await (await fetch(`http://127.0.0.1:${fixture.controlPort}/messages?recipient=reader%40example.test`)).json(), []);
|
||||
});
|
||||
|
||||
test("SMTP oversized messages are rejected without retaining a partial recovery body", { timeout: 5000 }, async (t) => {
|
||||
const fixture = await startLocalSMTPFixture();
|
||||
t.after(() => fixture.close());
|
||||
assert.ok(fixture.smtpPort > 0);
|
||||
const smtp = await client(fixture.smtpPort);
|
||||
t.after(() => smtp.socket.destroy());
|
||||
assert.equal(await smtp.command("MAIL FROM:<sender@example.test>"), 250);
|
||||
assert.equal(await smtp.command("RCPT TO:<reader@example.test>"), 250);
|
||||
assert.equal(await smtp.command("DATA"), 354);
|
||||
smtp.socket.write("Content-Type: text/plain\r\n\r\n" + "x".repeat(132000) + "\r\n.\r\n");
|
||||
assert.equal(await smtp.reply(), 552);
|
||||
assert.deepEqual(await (await fetch(`http://127.0.0.1:${fixture.controlPort}/messages?recipient=reader%40example.test`)).json(), []);
|
||||
});
|
||||
|
||||
test("quoted-printable SMTP mail retains folded reset URLs and UTF-8 copy", () => {
|
||||
const raw = "Subject: Verify email\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nBekreft =C3=B8\r\nhttp://localhost:3300/verify-email?userId=3Done&to=\r\nken=3DA%2BB\r\n";
|
||||
assert.equal(decodePlainMessage(raw), "Bekreft ø\r\nhttp://localhost:3300/verify-email?userId=one&token=A%2BB\r\n");
|
||||
});
|
||||
|
||||
test("base64 SMTP mail is decoded after header unfolding", () => {
|
||||
assert.equal(decodePlainMessage("Content-Type: text/plain;\r\n charset=utf-8\r\nContent-Transfer-Encoding: base64\r\n\r\nSGVsbG8sIHdvcmxkIQ==\r\n"), "Hello, world!");
|
||||
});
|
||||
|
||||
test("unsupported MIME does not silently produce a recovery URL", () => {
|
||||
assert.throws(() => decodePlainMessage("Content-Type: multipart/mixed; boundary=x\r\n\r\n--x\r\n"), /text\/plain/);
|
||||
});
|
||||
Reference in New Issue
Block a user