feat(auth): migrate public authentication presentation

This commit is contained in:
cesnimda
2026-10-11 20:50:14 +02:00
parent 27c54146ad
commit 8ad720da5d
18 changed files with 7595 additions and 127 deletions
+125
View File
@@ -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/);
});