jevstrudel.git / worker / src / auth.ts
auth.tsannotatedauth.tssource317 lines · 13.2 KB · raw
1// Accounts, signed in with passkeys (WebAuthn): no passwords, no email, no
2// third party. The ceremonies are verified by @simplewebauthn/server, which
3// runs on Workers (Web Crypto only, no Node built-ins).
4//
5//   POST /jev/auth/register/options  { displayName }: options for
6//        navigator.credentials.create() for a new account; signed in, and
7//        with no body fields, a further passkey for your own account
8//   POST /jev/auth/register/verify   the browser's RegistrationResponseJSON;
9//        a new account is signed in (Set-Cookie)
10//   POST /jev/auth/login/options     options for navigator.credentials.get(),
11//        for any passkey this site has (discoverable credentials)
12//   POST /jev/auth/login/verify      the browser's AuthenticationResponseJSON;
13//        signs in (Set-Cookie)
14//   POST /jev/auth/logout            ends this browser's session
15//   GET  /jev/auth/me                { user: { id, displayName } | null,
16//        budget: { used, limit, resetsAt } | null }
17//
18// Every challenge is issued here, kept in D1 for CHALLENGE_TTL_MS, and
19// taken back exactly once by its verify (accounts-store.ts); a verify whose
20// challenge was not issued, is used, has expired, or was issued for another
21// ceremony is refused before any signature is checked. The relying party is
22// the host the request came to: its RP ID the hostname (localhost in dev,
23// the workers.dev host in production), its origin the request's own.
24//
25// Sign-in is a passkey with user verification (the device's PIN, face or
26// 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';
51
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
56// 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;
60
61// Per visitor, set in wrangler.json: a ceremony is two requests, and a
62// 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;
64
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);
70
71// 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}
78
79// 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}
84
85// The signed-in user, or null (no cookie, an unknown one, or expired, or a
86// request from another origin, whose cookie is never taken as a session:
87// 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}
93
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}
102
103// 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}
117
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}