mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
fix(auth): refuse to send a sign-in code rather than log one
The rule was "throw when the environment says production, otherwise log the code and carry on". The deployment sets no such marker, so the branch that ran was the developer one: every recipient and every usable sign-in code printed to a retained log, the screen reporting success, and nobody receiving anything. That is what a fail-open default costs. The deployment that forgets its mail settings is exactly the deployment with no marker saying it is a real one, so it takes the lenient branch precisely when it should not. Turned around: printing a live code is asked for by name and anything else is an error, so absence of configuration is a refusal instead of an assumption. Two settings out of three is also an error now, because it means somebody is halfway through wiring a provider up and a quiet fallback would hide the missing half. Stages anyone else can reach are checked at deploy time, so a missing setting stops the deploy with the name of the variable it wanted rather than surfacing later as a person waiting for mail that never comes.
This commit is contained in:
@@ -3,38 +3,60 @@
|
||||
*
|
||||
* Deliberately not tied to one mail vendor: it posts a small JSON body to
|
||||
* whatever endpoint is configured, so swapping providers is configuration and
|
||||
* not a code change. Three settings, all optional except in production —
|
||||
* `EMAIL_SEND_URL`, `EMAIL_API_KEY`, `EMAIL_FROM`.
|
||||
* not a code change. Three settings — `EMAIL_SEND_URL`, `EMAIL_API_KEY`,
|
||||
* `EMAIL_FROM` — and a fourth, `EMAIL_DEV_LOG`, that asks for the code to be
|
||||
* printed instead of sent.
|
||||
*/
|
||||
export interface MailerConfig {
|
||||
EMAIL_SEND_URL?: string;
|
||||
EMAIL_API_KEY?: string;
|
||||
EMAIL_FROM?: string;
|
||||
NODE_ENV?: string;
|
||||
/**
|
||||
* Print the code to the log rather than sending it. `'true'` and nothing
|
||||
* else, so a variable left holding `'false'` or `'0'` cannot switch it on.
|
||||
*/
|
||||
EMAIL_DEV_LOG?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the code, or fail loudly.
|
||||
* Send the code, or refuse.
|
||||
*
|
||||
* With no mailer configured this logs the code and carries on, which is what
|
||||
* makes a local sign-in possible without a mail account. In production the
|
||||
* same situation throws instead: a signup screen that says "check your email"
|
||||
* when nothing was sent is worse than one that says it is broken, because the
|
||||
* person waits instead of telling anybody.
|
||||
* The rule is that printing a live sign-in code to a log is something you ask
|
||||
* for by name, and that anything else is an error. It reads that way round
|
||||
* because the alternative — treat an unconfigured mailer as "must be a
|
||||
* developer" — fails *open*: the deployment that forgets its mail settings is
|
||||
* exactly the deployment with no marker saying it is a real one, so it takes
|
||||
* the developer branch, logs every recipient and every usable code to a
|
||||
* retained log, and reports success while nobody receives anything.
|
||||
*
|
||||
* Configuration is also all-or-nothing. Two settings out of three is somebody
|
||||
* halfway through wiring a provider up, and quietly falling back would hide
|
||||
* the half that is missing.
|
||||
*/
|
||||
export async function sendVerificationCode(
|
||||
config: MailerConfig,
|
||||
email: string,
|
||||
code: string
|
||||
): Promise<void> {
|
||||
const configured = config.EMAIL_SEND_URL && config.EMAIL_API_KEY && config.EMAIL_FROM;
|
||||
const present = [config.EMAIL_SEND_URL, config.EMAIL_API_KEY, config.EMAIL_FROM].filter(Boolean);
|
||||
|
||||
if (!configured) {
|
||||
if (config.NODE_ENV === 'production') {
|
||||
throw new Error('Email delivery is not configured, so no sign-in code can be sent');
|
||||
if (present.length === 0) {
|
||||
if (config.EMAIL_DEV_LOG === 'true') {
|
||||
console.log(`[auth] sign-in code for ${email}: ${code}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[auth] sign-in code for ${email}: ${code}`);
|
||||
return;
|
||||
throw new Error(
|
||||
'Email delivery is not configured, so no sign-in code can be sent. ' +
|
||||
'Set EMAIL_SEND_URL, EMAIL_API_KEY and EMAIL_FROM, or set EMAIL_DEV_LOG=true ' +
|
||||
'to print codes to the log instead.'
|
||||
);
|
||||
}
|
||||
|
||||
if (present.length < 3) {
|
||||
throw new Error(
|
||||
'Email delivery is half configured: EMAIL_SEND_URL, EMAIL_API_KEY and EMAIL_FROM ' +
|
||||
'are needed together.'
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(config.EMAIL_SEND_URL!, {
|
||||
|
||||
@@ -3,16 +3,36 @@ import { describe, expect, test } from 'bun:test';
|
||||
import { sendVerificationCode } from '../src/email.js';
|
||||
|
||||
describe('sending a sign-in code', () => {
|
||||
test('with nothing configured outside production, it does not block a sign-in', async () => {
|
||||
await sendVerificationCode({ NODE_ENV: 'development' }, 'ada@example.com', '123456');
|
||||
test('printing the code to the log has to be asked for by name', async () => {
|
||||
await sendVerificationCode({ EMAIL_DEV_LOG: 'true' }, 'ada@example.com', '123456');
|
||||
});
|
||||
|
||||
test('with nothing configured in production, it says so instead of pretending', async () => {
|
||||
// The regression this holds: the previous rule was "throw only when the
|
||||
// environment says production", which meant a deployment that set no
|
||||
// marker at all — which is what the real one did — took the developer
|
||||
// branch and logged live codes. Absence is now a refusal.
|
||||
test('nothing configured and nothing asked for is a refusal, not a log', async () => {
|
||||
await expect(sendVerificationCode({}, 'ada@example.com', '123456')).rejects.toThrow(
|
||||
/not configured/
|
||||
);
|
||||
});
|
||||
|
||||
test('a variable left holding something other than true does not switch logging on', async () => {
|
||||
await expect(
|
||||
sendVerificationCode({ NODE_ENV: 'production' }, 'ada@example.com', '123456')
|
||||
sendVerificationCode({ EMAIL_DEV_LOG: 'false' }, 'ada@example.com', '123456')
|
||||
).rejects.toThrow(/not configured/);
|
||||
});
|
||||
|
||||
test('half a mailer is an error rather than a fallback', async () => {
|
||||
await expect(
|
||||
sendVerificationCode(
|
||||
{ EMAIL_SEND_URL: 'https://mail.example.com/send', EMAIL_DEV_LOG: 'true' },
|
||||
'ada@example.com',
|
||||
'123456'
|
||||
)
|
||||
).rejects.toThrow(/half configured/);
|
||||
});
|
||||
|
||||
test('a configured mailer is called with the address and the code', async () => {
|
||||
let seen: { url: string; body: any; auth: string | null } | null = null;
|
||||
const original = globalThis.fetch;
|
||||
@@ -28,7 +48,6 @@ describe('sending a sign-in code', () => {
|
||||
try {
|
||||
await sendVerificationCode(
|
||||
{
|
||||
NODE_ENV: 'production',
|
||||
EMAIL_SEND_URL: 'https://mail.example.com/send',
|
||||
EMAIL_API_KEY: 'key',
|
||||
EMAIL_FROM: 'hello@nestri.io'
|
||||
@@ -47,6 +66,32 @@ describe('sending a sign-in code', () => {
|
||||
expect(seen!.body.text).toContain('123456');
|
||||
});
|
||||
|
||||
test('a configured mailer sends even when dev logging is on', async () => {
|
||||
let called = false;
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
called = true;
|
||||
return new Response('{}', { status: 200 });
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
await sendVerificationCode(
|
||||
{
|
||||
EMAIL_SEND_URL: 'https://mail.example.com/send',
|
||||
EMAIL_API_KEY: 'key',
|
||||
EMAIL_FROM: 'hello@nestri.io',
|
||||
EMAIL_DEV_LOG: 'true'
|
||||
},
|
||||
'ada@example.com',
|
||||
'123456'
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
|
||||
expect(called).toBe(true);
|
||||
});
|
||||
|
||||
test('a refusal from the mailer is not swallowed', async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
|
||||
Reference in New Issue
Block a user