jevstrudel.git / worker / src / auth.ts
auth.tsannotatedauth.tssource317 lines · 13.2 KB · raw

Accounts, signed in with passkeys (WebAuthn): no passwords, no email, no third party. The ceremonies are verified by @simplewebauthn/server, which runs on Workers (Web Crypto only, no Node built-ins).

POST /jev/auth/register/options { displayName }: options for navigator.credentials.create() for a new account; signed in, and with no body fields, a further passkey for your own account POST /jev/auth/register/verify the browser's RegistrationResponseJSON; a new account is signed in (Set-Cookie) POST /jev/auth/login/options options for navigator.credentials.get(), for any passkey this site has (discoverable credentials) POST /jev/auth/login/verify the browser's AuthenticationResponseJSON; signs in (Set-Cookie) POST /jev/auth/logout ends this browser's session GET /jev/auth/me { user: { id, displayName } | null, budget: { used, limit, resetsAt } | null }

Every challenge is issued here, kept in D1 for CHALLENGE_TTL_MS, and taken back exactly once by its verify (accounts-store.ts); a verify whose challenge was not issued, is used, has expired, or was issued for another ceremony is refused before any signature is checked. The relying party is the host the request came to: its RP ID the hostname (localhost in dev, the workers.dev host in production), its origin the request's own.

Sign-in is a passkey with user verification (the device's PIN, face or fingerprint): a passkey is the only factor, so it must be both.

27import {
28  generateAuthenticationOptions,
29  generateRegistrationOptions,
30  verifyAuthenticationResponse,
31  verifyRegistrationResponse,
32  type AuthenticationResponseJSON,
33  type RegistrationResponseJSON,
34} from '@simplewebauthn/server';
35import { d1Accounts, type Accounts, type Purpose, type User } from './accounts-store';
36import type { Balance } from './budget-counter';
37import type { Env } from './env';
38import {
39  base64urlBytes,
40  clearedCookie,
41  fromThisSite,
42  newChallenge,
43  newSessionToken,
44  newUserId,
45  SESSION_TTL_MS,
46  sessionCookie,
47  sessionToken,
48  userIdBytes,
49} from './session';
50import config from '../wrangler.json';
52export const AUTH_PREFIX = '/jev/auth/';
53export const RP_NAME = 'jevstrudel';
54export const CHALLENGE_TTL_MS = 5 * 60 * 1000;
55const MAX_BYTES = 16 * 1024; // a registration response with its attestation is a few KB

EdDSA, ES256, RS256: what passkeys use, all in Web Crypto on Workers.

57export const ALGORITHMS = [-8, -7, -257];
58const MAX_TRANSPORTS = 8;
59export const DISPLAY_NAME_MAX = 40;

Per visitor, set in wrangler.json: a ceremony is two requests, and a person signs in a few times a day. Apart from the relay's and the votes'.

63export const AUTH_LIMIT = config.ratelimits.find((r) => r.name === 'AUTH_LIMIT')!.simple;
65const json = (status: number, body: unknown, headers: Record<string, string> = {}) =>
66  Response.json(body, { status, headers: { 'Cache-Control': 'no-store', ...headers } });
67const fail = (status: number, error: string, headers: Record<string, string> = {}) => json(status, { error }, headers);
68
69const isObject = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null && !Array.isArray(x);

The display name as kept: trimmed, 1 to 40 characters, no control characters.

72export function displayName(value: unknown): string | null {
73  if (typeof value !== 'string') return null;
74  const name = value.normalize('NFC').trim().replace(/\s+/g, ' ');
75  if (!name || [...name].length > DISPLAY_NAME_MAX || /\p{Cc}|\p{Cf}/u.test(name)) return null;
76  return name;
77}

The relying party for this request: its host's name and origin.

80export function relyingParty(request: Request) {
81  const url = new URL(request.url);
82  return { rpID: url.hostname, origin: url.origin };
83}

The signed-in user, or null (no cookie, an unknown one, or expired, or a request from another origin, whose cookie is never taken as a session: session.ts, fromThisSite).

88export async function signedIn(request: Request, accounts: Accounts): Promise<User | null> {
89  if (!fromThisSite(request)) return null;
90  const token = sessionToken(request);
91  return token ? accounts.sessionUser(token) : null;
92}
94async function budgetOf(env: Env, user: User): Promise<Balance | null> {
95  try {
96    return await env.BUDGET.get(env.BUDGET.idFromName(user.id)).balance();
97  } catch (e) {
98    console.error({ event: 'jev.budget', op: 'balance', error: (e as Error).message });
99    return null;
100  }
101}

The challenge a response's clientDataJSON carries, if it is shaped like one we issue.

104function challengeOf(response: unknown): string | null {
105  if (!isObject(response) || !isObject(response.response)) return null;
106  const clientData = response.response.clientDataJSON;
107  if (typeof clientData !== 'string' || clientData.length > 4096) return null;
108  try {
109    const parsed = JSON.parse(new TextDecoder().decode(base64urlBytes(clientData))) as { challenge?: unknown };
110    return typeof parsed.challenge === 'string' && /^[A-Za-z0-9_-]{43}$/.test(parsed.challenge)
111      ? parsed.challenge
112      : null;
113  } catch {
114    return null;
115  }
116}
118const credentialIdOk = (id: unknown): id is string =>
119  typeof id === 'string' && id.length >= 16 && id.length <= 1366 && /^[A-Za-z0-9_-]+$/.test(id);
120
121const transportsOf = (response: unknown): string[] => {
122  const t = isObject(response) && isObject(response.response) ? response.response.transports : undefined;
123  return Array.isArray(t)
124    ? t.filter((x): x is string => typeof x === 'string' && /^[a-z-]{1,32}$/.test(x)).slice(0, MAX_TRANSPORTS)
125    : [];
126};
127
128export async function auth(request: Request, env: Env, accounts: Accounts = d1Accounts(env.DB)): Promise<Response> {
129  const { pathname } = new URL(request.url);
130  const route = pathname.slice(AUTH_PREFIX.length);
131  const method = route === 'me' ? 'GET' : 'POST';
132  const known = ['me', 'register/options', 'register/verify', 'login/options', 'login/verify', 'logout'];
133  if (!known.includes(route)) return fail(404, 'not found');
134  if (request.method !== method) return fail(405, `${method} only`, { Allow: method });
135
136  const rp = relyingParty(request);
137  // Defence in depth beside SameSite=Lax: a POST from another site's page is refused.
138  const from = request.headers.get('Origin');
139  if (method === 'POST' && from !== null && from !== rp.origin) return fail(403, 'cross-origin request');
140
141  if (route === 'me') {
142    const user = await signedIn(request, accounts);
143    return json(200, { user, budget: user ? await budgetOf(env, user) : null });
144  }
145
146  const visitor = request.headers.get('CF-Connecting-IP') ?? 'local';
147  const { success } = await env.AUTH_LIMIT.limit({ key: visitor });
148  if (!success) return fail(429, 'too many sign-in attempts; try again in a minute', { 'Retry-After': String(AUTH_LIMIT.period) });
149
150  if (route === 'logout') {
151    const token = sessionToken(request);
152    if (token) await accounts.deleteSession(token);
153    return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store', 'Set-Cookie': clearedCookie() } });
154  }
155
156  const raw = await request.arrayBuffer();
157  if (raw.byteLength > MAX_BYTES) return fail(413, 'request too large');
158  let body: unknown = {};
159  if (raw.byteLength) {
160    try {
161      body = JSON.parse(new TextDecoder().decode(raw));
162    } catch {
163      return fail(400, 'body must be JSON');
164    }
165  }
166  if (!isObject(body)) return fail(400, 'body must be a JSON object');
167
168  switch (route) {
169    case 'register/options':
170      return registerOptions(request, body, rp, accounts);
171    case 'login/options':
172      return loginOptions(rp, accounts);
173    case 'register/verify':
174      return registerVerify(request, body, rp, accounts);
175    default:
176      return loginVerify(body, rp, accounts);
177  }
178}
179
180type RP = ReturnType<typeof relyingParty>;
181
182async function registerOptions(request: Request, body: Record<string, unknown>, rp: RP, accounts: Accounts) {
183  const current = await signedIn(request, accounts);
184  let purpose: Purpose;
185  let user: User;
186  if (current) {
187    if (body.displayName !== undefined) return fail(400, 'already signed in: sign out to make another account');
188    purpose = 'add';
189    user = current;
190  } else {
191    const name = displayName(body.displayName);
192    if (!name) return fail(400, `a display name is 1 to ${DISPLAY_NAME_MAX} characters`);
193    purpose = 'register';
194    user = { id: newUserId(), displayName: name };
195  }
196  const options = await generateRegistrationOptions({
197    rpName: RP_NAME,
198    rpID: rp.rpID,
199    userName: user.displayName,
200    userDisplayName: user.displayName,
201    userID: userIdBytes(user.id),
202    challenge: newChallenge(),
203    timeout: CHALLENGE_TTL_MS,
204    attestationType: 'none',
205    excludeCredentials: purpose === 'add' ? await accounts.credentialIds(user.id) : [],
206    authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
207    supportedAlgorithmIDs: ALGORITHMS,
208  });
209  await accounts.putChallenge(
210    {
211      challenge: options.challenge,
212      purpose,
213      userId: user.id,
214      displayName: purpose === 'register' ? user.displayName : null,
215    },
216    CHALLENGE_TTL_MS,
217  );
218  return json(200, options);
219}
220
221async function loginOptions(rp: RP, accounts: Accounts) {
222  const options = await generateAuthenticationOptions({
223    rpID: rp.rpID,
224    challenge: newChallenge(),
225    timeout: CHALLENGE_TTL_MS,
226    userVerification: 'required',
227  });
228  await accounts.putChallenge(
229    { challenge: options.challenge, purpose: 'login', userId: null, displayName: null },
230    CHALLENGE_TTL_MS,
231  );
232  return json(200, options);
233}
234
235const signIn = (user: User, token: string) =>
236  json(200, { user }, { 'Set-Cookie': sessionCookie(token) });
237
238async function registerVerify(request: Request, body: Record<string, unknown>, rp: RP, accounts: Accounts) {
239  const challenge = challengeOf(body);
240  const issued = challenge && (await accounts.takeChallenge(challenge));
241  if (!issued || (issued.purpose !== 'register' && issued.purpose !== 'add')) {
242    return fail(400, 'this sign-up has expired or was already used; start again');
243  }
244  let verification;
245  try {
246    verification = await verifyRegistrationResponse({
247      response: body as unknown as RegistrationResponseJSON,
248      expectedChallenge: issued.challenge,
249      expectedOrigin: rp.origin,
250      expectedRPID: rp.rpID,
251      requireUserVerification: true,
252      supportedAlgorithmIDs: ALGORITHMS,
253    });
254  } catch (e) {
255    return fail(400, `the passkey could not be verified: ${(e as Error).message}`);
256  }
257  if (!verification.verified) return fail(400, 'the passkey could not be verified');
258  const { credential } = verification.registrationInfo;
259  if (!credentialIdOk(credential.id)) return fail(400, 'the passkey id is malformed');
260  const stored = {
261    id: credential.id,
262    publicKey: new Uint8Array(credential.publicKey),
263    signCount: credential.counter,
264    transports: transportsOf(body),
265  };
266  if (await accounts.credential(credential.id)) return fail(409, 'this passkey is already registered');
267
268  if (issued.purpose === 'add') {
269    // a further passkey only for the account that asked, still signed in
270    const current = await signedIn(request, accounts);
271    if (!current || current.id !== issued.userId) return fail(403, 'sign in again to add a passkey');
272    await accounts.addCredential({ ...stored, userId: current.id });
273    return json(200, { user: current });
274  }
275  const user = { id: issued.userId!, displayName: issued.displayName! };
276  const token = newSessionToken();
277  await accounts.createUser(user, stored, token, SESSION_TTL_MS);
278  return signIn(user, token);
279}
280
281async function loginVerify(body: Record<string, unknown>, rp: RP, accounts: Accounts) {
282  const challenge = challengeOf(body);
283  const issued = challenge && (await accounts.takeChallenge(challenge));
284  if (!issued || issued.purpose !== 'login') return fail(400, 'this sign-in has expired or was already used; start again');
285  if (!credentialIdOk(body.id)) return fail(400, 'the passkey id is malformed');
286  const credential = await accounts.credential(body.id);
287  if (!credential) return fail(400, 'this passkey is not registered here');
288  const handle = isObject(body.response) ? body.response.userHandle : undefined;
289  if (handle !== undefined && handle !== null && handle !== credential.userId) {
290    return fail(400, 'the passkey belongs to another account');
291  }
292  let verification;
293  try {
294    verification = await verifyAuthenticationResponse({
295      response: body as unknown as AuthenticationResponseJSON,
296      expectedChallenge: issued.challenge,
297      expectedOrigin: rp.origin,
298      expectedRPID: rp.rpID,
299      credential: {
300        id: credential.id,
301        publicKey: credential.publicKey,
302        counter: credential.signCount,
303        transports: credential.transports,
304      },
305      requireUserVerification: true,
306    });
307  } catch (e) {
308    return fail(400, `the passkey could not be verified: ${(e as Error).message}`);
309  }
310  if (!verification.verified) return fail(400, 'the passkey could not be verified');
311  const user = await accounts.user(credential.userId);
312  if (!user) return fail(400, 'this passkey is not registered here');
313  await accounts.setSignCount(credential.id, verification.authenticationInfo.newCounter);
314  const token = newSessionToken();
315  await accounts.createSession(user.id, token, SESSION_TTL_MS);
316  return signIn(user, token);
317}