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';
EdDSA, ES256, RS256: what passkeys use, all in Web Crypto on Workers.
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.
The relying party for this request: its host's name and origin.
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).
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}