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}