Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 228 additions & 1 deletion src/lib/server/identity/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it, onTestFinished, vi } from 'vitest';
import { createInstance } from '../instance.ts';
import { createFileTestDb } from '../test-db.ts';
import { type Admission, createAuth, isPlaceholderSecret, MIN_PASSWORD_LENGTH } from './auth.ts';
import {
type Admission,
CLIENT_IP_HEADER,
createAuth,
isNameTooLong,
isPlaceholderSecret,
MAX_NAME_LENGTH,
MIN_PASSWORD_LENGTH
} from './auth.ts';
import { createRegistration } from './registration.ts';
import { createFirstAdmin } from './setup.ts';

const secret = 'test-secret-test-secret-test-secret-0123';
const body = { email: 'ada@example.com', password: 'correct horse battery', name: 'Ada' };
Expand Down Expand Up @@ -313,3 +324,219 @@ describe('createAuth', () => {
}
});
});

describe('the name cap', () => {
const NAME_TOO_LONG = 'The name is too long.';
// Better Auth's limiter keeps its counters for the whole process (20 sign-ups per address per ten
// minutes), so each request here comes from an address of its own.
let caller = 0;
const signUp = (payload: Record<string, unknown>) => {
caller += 1;
return new Request('http://localhost:3000/api/auth/sign-up/email', {
method: 'POST',
headers: {
'content-type': 'application/json',
origin: 'http://localhost:3000',
[CLIENT_IP_HEADER]: `10.${caller >> 8}.${caller & 255}.1`
},
body: JSON.stringify(payload)
});
};
const emoji = '\u{1F600}'; // one code point, two UTF-16 units

describe('isNameTooLong counts Unicode code points on the trimmed name', () => {
it('is 100', () => {
expect(MAX_NAME_LENGTH).toBe(100);
});

it.each([
['', false],
['Ada', false],
['a'.repeat(100), false],
['a'.repeat(101), true],
// Two UTF-16 units each, one code point each: 100 fit, 101 do not.
[emoji.repeat(100), false],
[emoji.repeat(101), true],
[`${'a'.repeat(99)}${emoji}`, false],
[`${'a'.repeat(100)}${emoji}`, true],
// Lone surrogates are one code point per unit.
['\ud800'.repeat(100), false],
['\ud800'.repeat(101), true],
// Trimmed first, with the same trim the forms use (Unicode whitespace included).
[` ${'a'.repeat(100)} `, false],
[`\u00a0\u3000${'a'.repeat(100)}\ufeff\n`, false],
[`${' '.repeat(5000)}x${' '.repeat(5000)}`, false],
[`${'a'.repeat(50)} ${'b'.repeat(50)} c`, true],
['x'.repeat(512 * 1024), true]
])('%j.length=%#', (name, tooLong) => {
expect(isNameTooLong(name)).toBe(tooLong);
});
});

async function http(auth: Awaited<ReturnType<typeof setup>>['auth'], name: string) {
return auth.handler(signUp({ ...body, name }));
}

it('refuses a direct POST /api/auth/sign-up/email with a longer name, with a fixed error, storing nothing', async () => {
const { auth, db } = await setup();

const res = await http(auth, 'x'.repeat(MAX_NAME_LENGTH + 1));

expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ code: 'NAME_TOO_LONG', message: NAME_TOO_LONG });
expect(await db.user.count()).toBe(0);
expect(await db.account.count()).toBe(0);
expect(await db.session.count()).toBe(0);
expect(await db.learner.count()).toBe(0);
});

it('refuses the server-side call too, and any size of name, without echoing it', async () => {
const { auth, db } = await setup();
const huge = `${'x'.repeat(512 * 1024)}@secret-marker`;

const refusal = await auth.api.signUpEmail({ body: { ...body, name: huge } }).then(
() => null,
(error: unknown) => error
);

expect(refusal).toMatchObject({ statusCode: 400, body: { code: 'NAME_TOO_LONG' } });
expect(JSON.stringify((refusal as { body: unknown }).body)).not.toContain('secret-marker');
expect(await db.user.count()).toBe(0);
});

it.each([
['100 characters', 'a'.repeat(100)],
['100 emoji', emoji.repeat(100)],
['100 characters with spaces around them', ` ${'a'.repeat(100)} `]
])('accepts %s, and stores the trimmed name', async (_label, name) => {
const { auth, db } = await setup();

const res = await http(auth, name);

expect(res.status).toBe(200);
expect((await db.user.findFirstOrThrow()).name).toBe(name.trim());
});

it('refuses 101 emoji', async () => {
const { auth, db } = await setup();

const res = await http(auth, emoji.repeat(101));

expect(res.status).toBe(400);
expect(await db.user.count()).toBe(0);
});

it('cannot be got round with padding: a name that is short once trimmed is stored trimmed', async () => {
const { auth, db } = await setup();

const res = await http(auth, `${' '.repeat(300_000)}Ada${' '.repeat(300_000)}`);

expect(res.status).toBe(200);
expect((await db.user.findFirstOrThrow()).name).toBe('Ada');
});

it('answers a closed sign-up as closed, whatever the name: a too-long name is not told apart', async () => {
const { auth, db } = await setup(() => null);

const long = await http(auth, 'x'.repeat(1000));
const short = await http(auth, 'Ada');

expect(long.status).toBe(403);
expect(short.status).toBe(403);
expect(await db.user.count()).toBe(0);
});

it('makes no Learner for a refused name, not even the named Local one', async () => {
const { auth, db } = await setup(() => ({ learnerId: 'local', role: 'admin' }));

const res = await http(auth, 'x'.repeat(1000));

expect(res.status).toBe(400);
expect(await db.learner.count()).toBe(0);
expect(await db.user.count()).toBe(0);
});

it('answers a long name the same for an email that has an Account and one that has not: no probe', async () => {
const { auth, db } = await setup();
await auth.api.signUpEmail({ body });
const before = await db.user.count();
const long = (email: string) =>
auth.handler(signUp({ ...body, email, name: 'x'.repeat(MAX_NAME_LENGTH + 1) }));

const known = await long(body.email);
const unknown = await long('nobody@example.com');

expect(known.status).toBe(400);
expect(unknown.status).toBe(400);
expect(await known.json()).toEqual(await unknown.json());
expect(await db.user.count()).toBe(before);
});

it('still answers a closed sign-up first: 403 for a long name, a blank one, a known email or not', async () => {
const { auth, db } = await setup(() => null);
const post = (email: string, name: string) => auth.handler(signUp({ ...body, email, name }));

for (const name of ['x'.repeat(1000), ' ', '']) {
expect((await post(body.email, name)).status, JSON.stringify(name.slice(0, 5))).toBe(403);
}
expect(await db.user.count()).toBe(0);
});

describe('a blank name', () => {
it.each([
['empty', ''],
['spaces', ' '],
['Unicode whitespace', '\u00a0\u3000\ufeff\n\t']
])('is refused over HTTP, %s, with a fixed error, storing nothing', async (_label, name) => {
const { auth, db } = await setup();

const res = await auth.handler(signUp({ ...body, name }));

expect(res.status).toBe(400);
expect(await res.json()).toMatchObject({ code: 'NAME_REQUIRED', message: 'Enter a name.' });
expect(await db.user.count()).toBe(0);
expect(await db.learner.count()).toBe(0);
});

it('is refused by the server-side call too, storing nothing', async () => {
const { auth, db } = await setup();

await expect(auth.api.signUpEmail({ body: { ...body, name: ' ' } })).rejects.toMatchObject({
statusCode: 400,
body: { code: 'NAME_REQUIRED' }
});
expect(await db.user.count()).toBe(0);
});

it('is refused for a known email the same as for an unknown one: no probe', async () => {
const { auth } = await setup();
await auth.api.signUpEmail({ body });
const blank = (email: string) => auth.handler(signUp({ ...body, email, name: ' ' }));

const known = await blank(body.email);
const unknown = await blank('nobody@example.com');

expect(known.status).toBe(400);
expect(await known.json()).toEqual(await unknown.json());
});
});

it('refuses the first admin with a longer name, and leaves the Instance not set up', async () => {
const db = await createFileTestDb();
const instance = await createInstance(db);
const auth = createAuth({
db,
origin: 'http://localhost:3000',
secret,
admitSignUp: createRegistration({ instance })
});

await expect(
createFirstAdmin({ instance, auth, db }, { ...body, name: 'x'.repeat(101) })
).rejects.toMatchObject({ body: { code: 'NAME_TOO_LONG' } });

expect(instance.mode()).toBeNull();
expect(await db.user.count()).toBe(0);
expect(await db.learner.count()).toBe(0);
});
});
47 changes: 46 additions & 1 deletion src/lib/server/identity/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ export function isPlaceholderSecret(secret: string): boolean {
const value = secret.trim().toLowerCase();
return value === BETTER_AUTH_DEFAULT_SECRET || value.startsWith('change-me');
}

/** The longest name an Account may have: 100 characters, counted in Unicode code points on the trimmed name. */
export const MAX_NAME_LENGTH = 100;

/**
* Whether a name is over the cap. The name is trimmed first (the forms store it trimmed), then counted by
* code point, so an emoji is one character and not its two UTF-16 units. Cheap on any size of input: a code
* point is one or two units, so up to 100 units is always within the cap and over 200 never is.
*/
export function isNameTooLong(name: string): boolean {
const trimmed = name.trim();
if (trimmed.length <= MAX_NAME_LENGTH) return false;
if (trimmed.length > 2 * MAX_NAME_LENGTH) return true;
return [...trimmed].length > MAX_NAME_LENGTH;
}

export const MIN_PASSWORD_LENGTH = 12;
export const MAX_PASSWORD_LENGTH = 128;

Expand Down Expand Up @@ -102,6 +118,21 @@ const HTTP_SIGN_IN_PATHS = ['/sign-in/email'];

const signUpClosed = () => new APIError('FORBIDDEN', { message: 'Sign-up is closed' });

// Fixed messages and codes: the forms map the code to their own text, and the name is never echoed.
const nameTooLong = () =>
new APIError('BAD_REQUEST', { message: 'The name is too long.', code: 'NAME_TOO_LONG' });
const nameRequired = () =>
new APIError('BAD_REQUEST', { message: 'Enter a name.', code: 'NAME_REQUIRED' });

/**
* The refusal for a name, or null if it is fine: blank once trimmed, or over the cap. Which characters a
* name may hold is another question (a product rule) and is not asked here.
*/
function nameRefusal(name: string): APIError | null {
if (name.trim() === '') return nameRequired();
return isNameTooLong(name) ? nameTooLong() : null;
}

/** Better Auth over our database: email + password and the admin plugin, nothing else. */
export function createAuth({
db,
Expand Down Expand Up @@ -168,6 +199,12 @@ export function createAuth({
before: async (user, ctx) => {
const admission = ctx && (await admitSignUp({ email: user.email }));
if (!admission) throw signUpClosed();
// After the admission, so a closed sign-up answers the same whatever the name; before the
// Learner below, so a refused name leaves nothing behind. Whatever path made the Account
// (the form, POST /sign-up/email, a later one) comes through here. For a sign-up over HTTP
// the request hook below has already asked, so this is the backstop.
const refusal = nameRefusal(user.name);
if (refusal) throw refusal;
// A named Learner (the Local one) already exists or is made here and is never removed.
// A fresh one is only named here and made in `after`, once the Account row is in, so a
// failed insert (a duplicate email, a concurrent sign-up) leaves no Learner behind.
Expand All @@ -182,6 +219,9 @@ export function createAuth({
return {
data: {
...user,
// Stored trimmed, as the forms send it: the cap counts the trimmed name, so padding
// must not be kept either.
name: user.name.trim(),
learnerId,
...(admission.role ? { role: admission.role } : {})
}
Expand All @@ -200,11 +240,16 @@ export function createAuth({
}
},
// Asked first, so a closed sign-up answers 403 whether or not the email exists (otherwise the
// duplicate-email 422 would reveal which emails have Accounts). The database hook is the backstop.
// duplicate-email 422 would reveal which emails have Accounts). The name is checked next, for the same
// reason: Better Auth looks the email up before the database hook runs, so a name refused only there
// would answer 400 for an email with no Account and 422 for one that has. The database hook is the backstop.
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (ctx.path !== '/sign-up/email') return;
if (!(await admitSignUp({ email: ctx.body?.email }))) throw signUpClosed();
const name = ctx.body?.name;
const refusal = typeof name === 'string' ? nameRefusal(name) : null;
if (refusal) throw refusal;
})
},
disabledPaths: [...UNUSED_ADMIN_PATHS, ...UNUSED_CORE_PATHS, ...HTTP_SIGN_IN_PATHS],
Expand Down
14 changes: 14 additions & 0 deletions src/routes/setup/+page.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { fail, redirect } from '@sveltejs/kit';
import { isAPIError } from 'better-auth/api';
import type { MultiUserChoice } from '$lib/server/app';
import { isNameTooLong, MAX_NAME_LENGTH } from '$lib/server/identity/auth';
import { forwardSetCookies } from '$lib/server/identity/auth-handler';
import { InvalidFirstAdminError, MultiUserNotAvailableError } from '$lib/server/identity/setup';
import { InstanceAlreadySetUpError } from '$lib/server/instance';
Expand All @@ -19,6 +21,7 @@ const FIELD_MESSAGES = {
email: 'Enter a valid email address.',
password: 'Use 12 to 128 characters.'
} as const;
const NAME_TOO_LONG = `Use at most ${MAX_NAME_LENGTH} characters.`;
const ALREADY_SET_UP = 'This instance has already been set up.';
const text = (data: FormData, key: string) => {
const value = data.get(key);
Expand Down Expand Up @@ -72,10 +75,21 @@ export const actions: Actions = {
if (locals.instance.mode() !== null) {
return fail(409, adminStep(typed, { message: ALREADY_SET_UP }));
}
// Before the Instance is claimed, so a name over the cap leaves nothing to undo. Better Auth's hook
// refuses it too (`NAME_TOO_LONG`), which is what the catch below maps if one ever gets this far.
if (isNameTooLong(input.name)) {
return fail(400, adminStep(typed, { field: 'name', message: NAME_TOO_LONG }));
}
let headers: Headers;
try {
({ headers } = await locals.setup.createFirstAdmin(input));
} catch (error) {
if (isAPIError(error) && error.body?.code === 'NAME_TOO_LONG') {
return fail(400, adminStep(typed, { field: 'name', message: NAME_TOO_LONG }));
}
if (isAPIError(error) && error.body?.code === 'NAME_REQUIRED') {
return fail(400, adminStep(typed, { field: 'name', message: FIELD_MESSAGES.name }));
}
if (error instanceof InvalidFirstAdminError) {
return fail(
400,
Expand Down
Loading