Files
netris-nestri/packages/auth/test/code.test.ts
T
Wanjohi 7c200ff092 fix(auth): bound sign-in code sends per mailbox and per caller
The send budget was keyed by every submitted form field, so adding a field
with a new value each time made a fresh budget, and one mailbox could be sent
codes without limit. A claim is now built only from the claim fields (email,
phone by default); anything else is dropped before it is counted and before
it reaches sendCode.

A budget per mailbox does nothing against a caller holding a list of
addresses: each send goes to a different stranger, and the deployment's
whole sending allowance goes with them. Add a second budget per requesting
address, 10 codes per 24 hours by default. The window is a day rather than an
hour because a caller that paces itself stays under any hourly figure a
person would also fit under.

The address lookup the device flow used is moved into util so both read the
caller the same way.
2026-11-05 12:17:39 +03:00

319 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, test } from 'bun:test';
import { object, string } from 'valibot';
import { CodeProvider } from '../src/provider/code.js';
import { issuer } from '../src/issuer.js';
import { MemoryStorage } from '../src/storage/memory.js';
import { createSubjects } from '../src/subject.js';
const subjects = createSubjects({ user: object({ email: string() }) });
let sent: string[] = [];
const auth = issuer({
storage: MemoryStorage(),
subjects,
allow: async () => true,
// Screens back as JSON instead of HTML, which is the whole of what it takes
// to replace the presentation layer — and is why these tests can assert on
// what the flow decided rather than on the markup it happened to produce.
renderer: { render: (screen) => Response.json(screen) },
providers: {
code: CodeProvider({
maxAttempts: 3,
maxSends: 2,
maxSendsPerAddress: 3,
sendWindow: 3600,
resendInterval: 0,
request: async (_req, _state, _form, error) => ({
kind: 'message',
tone: 'danger',
heading: error?.type ?? 'none',
body: []
}),
sendCode: async (claims, code) => {
if (!claims.email?.includes('@')) {
return { type: 'invalid_claim', key: 'email', value: claims.email ?? '' };
}
sent.push(code);
}
})
},
success: async (ctx, value) => ctx.subject('user', { email: (value as any).claims.email })
});
const ORIGIN = 'https://auth.example.com';
function jar() {
const cookies = new Map<string, string>();
return {
absorb(response: Response) {
for (const raw of response.headers.getSetCookie()) {
const [pair] = raw.split(';');
const index = pair!.indexOf('=');
cookies.set(pair!.slice(0, index), pair!.slice(index + 1));
}
},
header() {
return [...cookies].map(([name, value]) => `${name}=${value}`).join('; ');
}
};
}
async function post(
cookies: ReturnType<typeof jar>,
body: Record<string, string>,
headers: Record<string, string> = {}
) {
const response = await auth.request(`${ORIGIN}/code/authorize`, {
method: 'POST',
headers: {
...headers,
cookie: cookies.header(),
'content-type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams(body)
});
cookies.absorb(response);
return response;
}
/**
* Begin an authorization the way a client does, so success has somewhere to go.
*
* Without this there is no authorization state and a correct code produces
* tokens rather than the redirect a browser flow ends in — which would make
* "did this sign in?" a different question in the test than in the product.
*/
async function begin() {
const cookies = jar();
const url = new URL(`${ORIGIN}/authorize`);
url.searchParams.set('client_id', 'test');
url.searchParams.set('redirect_uri', 'https://client.example.com/callback');
url.searchParams.set('response_type', 'code');
url.searchParams.set('provider', 'code');
cookies.absorb(await auth.request(url.toString()));
return cookies;
}
/** Start a sign-in and ask for a code, coming back with the cookies and the code. */
async function ask(email: string) {
const cookies = await begin();
await post(cookies, { action: 'request', email });
return { cookies, code: sent.at(-1)! };
}
/** What the stub UI reported, so a test can name the error rather than a status. */
async function errorOf(response: Response) {
const screen = (await response.clone().json()) as { heading: string };
return screen.heading === 'none' ? null : screen.heading;
}
beforeEach(() => {
sent = [];
});
describe('signing in with a code', () => {
test('the right code signs you in', async () => {
const { cookies, code } = await ask('right@example.com');
const response = await post(cookies, { action: 'verify', code });
expect(response.status).toBe(302);
});
test('a wrong code is refused and says so', async () => {
const { cookies, code } = await ask('wrong@example.com');
const response = await post(cookies, { action: 'verify', code: code === '000000' ? '111111' : '000000' });
expect(response.status).toBe(200);
expect(await errorOf(response)).toBe('invalid_code');
});
});
/**
* The attack a six-digit pin invites, and what stops it.
*
* The code travels in an encrypted cookie the caller holds, and the caller is
* not necessarily the person the code was mailed to — anybody can type somebody
* else's address into the first screen. So the only thing between an attacker
* and an account is how many times they may guess, and that number has to be
* kept somewhere they cannot reach.
*/
describe('guessing the code', () => {
test('runs out of guesses long before it runs out of codes', async () => {
const { cookies, code } = await ask('budget@example.com');
const wrong = code === '000000' ? '111111' : '000000';
expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe(
'invalid_code'
);
expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe(
'invalid_code'
);
expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe(
'invalid_code'
);
// Out of budget. The next guess is refused whether or not it is right.
expect(await errorOf(await post(cookies, { action: 'verify', code: wrong }))).toBe(
'rate_limit'
);
});
test('the real code stops working once the guesses are spent', async () => {
const { cookies, code } = await ask('spent@example.com');
const wrong = code === '000000' ? '111111' : '000000';
for (let i = 0; i < 3; i++) await post(cookies, { action: 'verify', code: wrong });
const response = await post(cookies, { action: 'verify', code });
expect(response.status).toBe(200);
expect(await errorOf(response)).toBe('rate_limit');
});
// The counter would be worthless if it lived where the guesser does. This
// replays the cookie from before any guess was made, which is the cheapest
// way to wind back anything held in one.
test('replaying an earlier cookie does not hand back the spent guesses', async () => {
const { cookies, code } = await ask('replay@example.com');
const untouched = cookies.header();
const wrong = code === '000000' ? '111111' : '000000';
for (let i = 0; i < 3; i++) await post(cookies, { action: 'verify', code: wrong });
const replayed = await auth.request(`${ORIGIN}/code/authorize`, {
method: 'POST',
headers: { cookie: untouched, 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ action: 'verify', code: wrong })
});
expect(await errorOf(replayed)).toBe('rate_limit');
});
test('a code is spent when it is used, so its guesses do not carry over', async () => {
const { cookies, code } = await ask('once@example.com');
expect((await post(cookies, { action: 'verify', code })).status).toBe(302);
const again = await post(cookies, { action: 'verify', code });
expect(again.status).toBe(200);
});
});
describe('asking for codes', () => {
test('a fresh code comes with a fresh budget of guesses', async () => {
const first = await ask('fresh-a@example.com');
const wrong = '000000' === first.code ? '111111' : '000000';
for (let i = 0; i < 3; i++) await post(first.cookies, { action: 'verify', code: wrong });
expect(await errorOf(await post(first.cookies, { action: 'verify', code: wrong }))).toBe(
'rate_limit'
);
// Starting over is allowed. It costs a code sent to the mailbox being
// aimed at, which is where somebody would notice.
const second = await ask('fresh-b@example.com');
expect(second.code).not.toBe(first.code);
expect((await post(second.cookies, { action: 'verify', code: second.code })).status).toBe(302);
});
test('a mailbox cannot be sent codes forever', async () => {
const email = 'flood@example.com';
const { cookies } = await ask(email);
expect(await errorOf(await post(cookies, { action: 'resend', email }))).toBe(null);
expect(await errorOf(await post(cookies, { action: 'resend', email }))).toBe('rate_limit');
expect(sent).toHaveLength(2);
});
// The budget was once held per sign-in attempt, which bounded nothing: the
// caller decides how many attempts to start, and starting one costs a
// discarded cookie. Both of these walk around a per-attempt budget and land
// on the mailbox anyway, which is why the count lives there.
test('replaying an earlier cookie does not buy more codes', async () => {
const email = 'replay-send@example.com';
const { cookies } = await ask(email);
const untouched = cookies.header();
await post(cookies, { action: 'resend', email });
expect(sent).toHaveLength(2);
const replayed = await auth.request(`${ORIGIN}/code/authorize`, {
method: 'POST',
headers: { cookie: untouched, 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ action: 'resend', email })
});
expect(await errorOf(replayed)).toBe('rate_limit');
expect(sent).toHaveLength(2);
});
test('starting over does not buy more codes either', async () => {
const email = 'restart-send@example.com';
await ask(email);
await ask(email);
expect(sent).toHaveLength(2);
const third = await begin();
expect(await errorOf(await post(third, { action: 'request', email }))).toBe('rate_limit');
expect(sent).toHaveLength(2);
});
// Each resend used to leave the code before it live, with a budget of
// guesses of its own. Five resends meant five working codes and five times
// the chances, so asking for a new code was how you bought more tries at
// the old one.
test('a resend retires the code before it', async () => {
const email = 'retire@example.com';
const { cookies, code: first } = await ask(email);
const untouched = cookies.header();
await post(cookies, { action: 'resend', email });
expect(sent.at(-1)).not.toBe(first);
const withOldCode = await auth.request(`${ORIGIN}/code/authorize`, {
method: 'POST',
headers: { cookie: untouched, 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ action: 'verify', code: first })
});
expect(withOldCode.status).toBe(200);
expect(await errorOf(withOldCode)).toBe('rate_limit');
});
// The budget is keyed by the claim. When the claim was every field in the
// form, a field with a new value each time was a new key, and so a new
// budget for the same mailbox.
test('an extra form field does not buy more codes', async () => {
const email = 'padded@example.com';
for (let i = 0; i < 4; i++) {
const cookies = await begin();
await post(cookies, { action: 'request', email, pad: String(i) });
}
expect(sent).toHaveLength(2);
});
test('an extra form field does not reach the claims', async () => {
const cookies = await begin();
await post(cookies, { action: 'request', email: 'clean@example.com', role: 'admin' });
const response = await post(cookies, { action: 'verify', code: sent.at(-1)! });
expect(response.status).toBe(302);
});
// A list of addresses walks around a budget held per mailbox. This is the
// one that bounds what a single caller can spend.
test('one caller cannot mail a list of strangers', async () => {
const from = { 'cf-connecting-ip': '198.51.100.20' };
for (let i = 0; i < 5; i++) {
const cookies = await begin();
await post(cookies, { action: 'request', email: `stranger-${i}@example.com` }, from);
}
expect(sent).toHaveLength(3);
// Somebody else is not caught by it.
const other = await begin();
await post(
other,
{ action: 'request', email: 'neighbour@example.com' },
{ 'cf-connecting-ip': '198.51.100.21' }
);
expect(sent).toHaveLength(4);
});
test('a bad address still gets told it is a bad address', async () => {
const cookies = await begin();
const response = await post(cookies, { action: 'request', email: 'not-an-address' });
expect(await errorOf(response)).toBe('invalid_claim');
expect(sent).toHaveLength(0);
});
});