auth.tsx
Raw
1import {
2 generateAuthenticationOptions,
3 generateRegistrationOptions,
4 verifyAuthenticationResponse,
5 verifyRegistrationResponse,
6} from "@simplewebauthn/server";
7import * as argon2 from "argon2";
8import { Elysia, t } from "elysia";
9import { REGISTRATION_DISABLED } from "../config.ts";
10import {
11 ADMIN_USERNAME,
12 CHALLENGE_TTL_MS,
13 VALID_USERNAME_RE,
14 WEBAUTHN_RP_NAME,
15} from "../constants.ts";
16import { db } from "../db/index.ts";
17import { checkRateLimit, getClientIp } from "../lib/rateLimiter.ts";
18import { redirect } from "../lib/redirect.ts";
19import { resolveSession } from "../middleware/session.ts";
20import { Login } from "../views/auth/Login.tsx";
21import { Register } from "../views/auth/Register.tsx";
22import { html } from "../views/render.tsx";
23
24function rpFromRequest(request: Request): { origin: string; rpId: string } {
25 const origin = request.headers.get("origin");
26 if (!origin) throw new Error("Missing Origin header");
27 return { origin, rpId: new URL(origin).hostname };
28}
29
30function randomHex(bytes: number): string {
31 const arr = new Uint8Array(bytes);
32 crypto.getRandomValues(arr);
33 return Array.from(arr)
34 .map((b) => b.toString(16).padStart(2, "0"))
35 .join("");
36}
37
38async function createSession(userId: number): Promise<string> {
39 const id = randomHex(32);
40 const now = new Date();
41 const expires = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // 30 days
42 await db
43 .insertInto("sessions")
44 .values({
45 id,
46 user_id: userId,
47 expires_at: expires.toISOString(),
48 created_at: now.toISOString(),
49 })
50 .execute();
51 return id;
52}
53
54function sessionCookie(id: string): string {
55 return `session=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 60 * 60}`;
56}
57
58function clearCookie(): string {
59 return "session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
60}
61
62// In-memory challenge store (fine for single-process)
63type ChallengeEntry = {
64 challenge: string;
65 timeoutId: ReturnType<typeof setTimeout>;
66};
67const pendingChallenges = new Map<string, ChallengeEntry>();
68
69function setChallenge(key: string, challenge: string): void {
70 const existing = pendingChallenges.get(key);
71 if (existing) clearTimeout(existing.timeoutId);
72 const timeoutId = setTimeout(
73 () => pendingChallenges.delete(key),
74 CHALLENGE_TTL_MS,
75 );
76 pendingChallenges.set(key, { challenge, timeoutId });
77}
78
79function deleteChallenge(key: string): void {
80 const entry = pendingChallenges.get(key);
81 if (entry) clearTimeout(entry.timeoutId);
82 pendingChallenges.delete(key);
83}
84
85export const authRoutes = new Elysia()
86 .guard({
87 cookie: t.Cookie({ session: t.Optional(t.String()) }),
88 })
89 .get("/login", () => {
90 return html(<Login />);
91 })
92
93 .post(
94 "/login",
95 async ({ body, request, server }) => {
96 const ip = getClientIp(request, server);
97 if (!checkRateLimit(ip, 10, 60_000)) {
98 return html(
99 <Login error="Too many login attempts. Please try again later." />,
100 );
101 }
102 const { username, password } = body;
103 const user = await db
104 .selectFrom("users")
105 .selectAll()
106 .where("username", "=", username)
107 .executeTakeFirst();
108
109 if (!user || !user.password_hash) {
110 return html(<Login error="Invalid username or password" />);
111 }
112
113 const valid = await argon2.verify(user.password_hash, password);
114 if (!valid) {
115 return html(<Login error="Invalid username or password" />);
116 }
117
118 const sessionId = await createSession(user.id);
119 return redirect("/", sessionCookie(sessionId));
120 },
121 {
122 body: t.Object({ username: t.String(), password: t.String() }),
123 },
124 )
125
126 .get("/register", () => {
127 if (REGISTRATION_DISABLED)
128 return new Response("Registration is disabled", { status: 403 });
129 return html(<Register />);
130 })
131
132 .post(
133 "/register",
134 async ({ body, request, server }) => {
135 if (REGISTRATION_DISABLED)
136 return new Response("Registration is disabled", {
137 status: 403,
138 });
139 const ip = getClientIp(request, server);
140 if (!checkRateLimit(ip, 3, 60 * 60_000)) {
141 return html(
142 <Register error="Too many registration attempts. Please try again later." />,
143 );
144 }
145 const { username, password, password2 } = body;
146
147 if (!VALID_USERNAME_RE.test(username)) {
148 return html(
149 <Register error="Username may only contain letters, numbers, hyphens, and underscores" />,
150 );
151 }
152 if (username === ADMIN_USERNAME) {
153 return html(<Register error="That username is reserved" />);
154 }
155
156 if (!password?.trim()) {
157 return html(
158 <Register error="Password is required (use the passkey button for passwordless registration)" />,
159 );
160 }
161 if (password !== password2) {
162 return html(<Register error="Passwords do not match" />);
163 }
164 if (password.length < 8) {
165 return html(
166 <Register error="Password must be at least 8 characters" />,
167 );
168 }
169
170 const hash = await argon2.hash(password);
171 const now = new Date().toISOString();
172 let result: { id: number };
173 try {
174 result = await db
175 .insertInto("users")
176 .values({
177 username,
178 password_hash: hash,
179 created_at: now,
180 })
181 .returning("id")
182 .executeTakeFirstOrThrow();
183 } catch (err) {
184 if (
185 err instanceof Error &&
186 err.message.includes(
187 "UNIQUE constraint failed: users.username",
188 )
189 ) {
190 return html(<Register error="Username already taken" />);
191 }
192 throw err;
193 }
194
195 const sessionId = await createSession(result.id);
196 return redirect("/", sessionCookie(sessionId));
197 },
198 {
199 body: t.Object({
200 username: t.String(),
201 password: t.Optional(t.String()),
202 password2: t.Optional(t.String()),
203 }),
204 },
205 )
206
207 .post("/logout", async ({ cookie }) => {
208 const sessionId = cookie.session.value;
209 if (sessionId) {
210 await db
211 .deleteFrom("sessions")
212 .where("id", "=", sessionId)
213 .execute();
214 }
215 return redirect("/", clearCookie());
216 })
217
218 // Create account (passkey-only path, called before passkey registration)
219 .post(
220 "/auth/passkey/create-user",
221 async ({ body }) => {
222 const { username } = body;
223 if (!username || !VALID_USERNAME_RE.test(username)) {
224 return new Response(
225 JSON.stringify({ error: "Invalid username" }),
226 {
227 status: 400,
228 },
229 );
230 }
231 if (username === ADMIN_USERNAME) {
232 return new Response(
233 JSON.stringify({ error: "That username is reserved" }),
234 { status: 400 },
235 );
236 }
237 const now = new Date().toISOString();
238 let result: { id: number };
239 try {
240 result = await db
241 .insertInto("users")
242 .values({
243 username,
244 password_hash: null,
245 created_at: now,
246 })
247 .returning("id")
248 .executeTakeFirstOrThrow();
249 } catch (err) {
250 if (
251 err instanceof Error &&
252 err.message.includes(
253 "UNIQUE constraint failed: users.username",
254 )
255 ) {
256 return new Response(
257 JSON.stringify({ error: "Username already taken" }),
258 { status: 400 },
259 );
260 }
261 throw err;
262 }
263
264 const sessionId = await createSession(result.id);
265 return new Response(JSON.stringify({ ok: true }), {
266 headers: {
267 "Content-Type": "application/json",
268 "Set-Cookie": sessionCookie(sessionId),
269 },
270 });
271 },
272 {
273 body: t.Object({ username: t.String() }),
274 },
275 )
276
277 // Passkey registration
278 .post("/auth/passkey/register/options", async ({ cookie, request }) => {
279 const user = await resolveSession(cookie.session.value);
280 if (!user)
281 return new Response(
282 JSON.stringify({ error: "Not authenticated" }),
283 {
284 status: 401,
285 },
286 );
287
288 const { rpId } = rpFromRequest(request);
289 const passkeys = await db
290 .selectFrom("passkeys")
291 .select(["credential_id"])
292 .where("user_id", "=", user.id)
293 .execute();
294
295 const options = await generateRegistrationOptions({
296 rpName: WEBAUTHN_RP_NAME,
297 rpID: rpId,
298 userID: Buffer.from(String(user.id)),
299 userName: user.username,
300 attestationType: "none",
301 excludeCredentials: passkeys.map((p) => ({
302 id: p.credential_id,
303 type: "public-key" as const,
304 })),
305 });
306
307 setChallenge(`${user.username}:reg`, options.challenge);
308 return new Response(JSON.stringify(options), {
309 headers: { "Content-Type": "application/json" },
310 });
311 })
312
313 .post(
314 "/auth/passkey/register/verify",
315 async ({ body, cookie, request }) => {
316 const user = await resolveSession(cookie.session.value);
317 if (!user)
318 return new Response(
319 JSON.stringify({ error: "Not authenticated" }),
320 {
321 status: 401,
322 },
323 );
324
325 const challenge = pendingChallenges.get(
326 `${user.username}:reg`,
327 )?.challenge;
328 if (!challenge)
329 return new Response(JSON.stringify({ error: "No challenge" }), {
330 status: 400,
331 });
332
333 const { origin, rpId } = rpFromRequest(request);
334 try {
335 const verification = await verifyRegistrationResponse({
336 response: body as Parameters<
337 typeof verifyRegistrationResponse
338 >[0]["response"],
339 expectedChallenge: challenge,
340 expectedOrigin: origin,
341 expectedRPID: rpId,
342 });
343
344 if (!verification.verified || !verification.registrationInfo) {
345 return new Response(
346 JSON.stringify({ error: "Verification failed" }),
347 { status: 400 },
348 );
349 }
350
351 const { credential } = verification.registrationInfo;
352 const now = new Date().toISOString();
353 await db
354 .insertInto("passkeys")
355 .values({
356 user_id: user.id,
357 credential_id: credential.id,
358 public_key: Buffer.from(
359 new Uint8Array(credential.publicKey),
360 ).toString("base64"),
361 counter: credential.counter,
362 created_at: now,
363 })
364 .execute();
365
366 deleteChallenge(`${user.username}:reg`);
367 return new Response(JSON.stringify({ ok: true }), {
368 headers: { "Content-Type": "application/json" },
369 });
370 } catch (e) {
371 return new Response(JSON.stringify({ error: String(e) }), {
372 status: 400,
373 });
374 }
375 },
376 {
377 body: t.Any(),
378 },
379 )
380
381 // Passkey login
382 .post("/auth/passkey/login/options", async ({ request }) => {
383 const { rpId } = rpFromRequest(request);
384 const options = await generateAuthenticationOptions({
385 rpID: rpId,
386 });
387 setChallenge(`login:${options.challenge}`, options.challenge);
388 return new Response(JSON.stringify(options), {
389 headers: { "Content-Type": "application/json" },
390 });
391 })
392
393 .post(
394 "/auth/passkey/login/verify",
395 async ({ body, request }) => {
396 const reqBody = body as { id?: string };
397 const credentialId = reqBody?.id;
398 if (!credentialId) {
399 return new Response(
400 JSON.stringify({ error: "Missing credential" }),
401 {
402 status: 400,
403 },
404 );
405 }
406
407 const passkey = await db
408 .selectFrom("passkeys")
409 .innerJoin("users", "users.id", "passkeys.user_id")
410 .selectAll("passkeys")
411 .select("users.username")
412 .where("passkeys.credential_id", "=", credentialId)
413 .executeTakeFirst();
414
415 if (!passkey) {
416 return new Response(
417 JSON.stringify({ error: "Unknown credential" }),
418 {
419 status: 400,
420 },
421 );
422 }
423
424 // Look up the challenge by decoding it from the signed clientDataJSON.
425 // This ensures each verify request finds its own challenge rather than
426 // an arbitrary "first login:" entry, which would fail under concurrency.
427 const reqBodyTyped = body as {
428 response?: { clientDataJSON?: string };
429 };
430 const clientDataJSON = reqBodyTyped?.response?.clientDataJSON;
431 let challengeKey: string | undefined;
432 let challenge: string | undefined;
433 if (clientDataJSON) {
434 try {
435 const cd = JSON.parse(
436 Buffer.from(clientDataJSON, "base64url").toString(),
437 ) as { challenge?: string };
438 if (cd.challenge) {
439 challengeKey = `login:${cd.challenge}`;
440 challenge =
441 pendingChallenges.get(challengeKey)?.challenge;
442 }
443 } catch {
444 // malformed clientDataJSON — handled by the check below
445 }
446 }
447 if (!challenge) {
448 return new Response(JSON.stringify({ error: "No challenge" }), {
449 status: 400,
450 });
451 }
452
453 const { origin, rpId } = rpFromRequest(request);
454 try {
455 const verification = await verifyAuthenticationResponse({
456 response: body as Parameters<
457 typeof verifyAuthenticationResponse
458 >[0]["response"],
459 expectedChallenge: challenge,
460 expectedOrigin: origin,
461 expectedRPID: rpId,
462 credential: {
463 id: passkey.credential_id,
464 publicKey: Buffer.from(passkey.public_key, "base64"),
465 counter: passkey.counter,
466 },
467 });
468
469 if (!verification.verified) {
470 return new Response(
471 JSON.stringify({ error: "Verification failed" }),
472 { status: 401 },
473 );
474 }
475
476 // Atomically advance the counter using the expected old value as
477 // a guard. If another concurrent request already updated it, 0
478 // rows are affected and we reject — this preserves WebAuthn's
479 // monotonic-counter replay-attack protection.
480 const updated = await db
481 .updateTable("passkeys")
482 .set({
483 counter: verification.authenticationInfo.newCounter,
484 })
485 .where("id", "=", passkey.id)
486 .where("counter", "=", passkey.counter)
487 .executeTakeFirst();
488
489 if (!updated || updated.numUpdatedRows === 0n) {
490 return new Response(
491 JSON.stringify({ error: "Credential replay detected" }),
492 { status: 401 },
493 );
494 }
495
496 deleteChallenge(challengeKey!);
497 const sessionId = await createSession(passkey.user_id);
498 return new Response(JSON.stringify({ ok: true }), {
499 headers: {
500 "Content-Type": "application/json",
501 "Set-Cookie": sessionCookie(sessionId),
502 },
503 });
504 } catch (e) {
505 return new Response(JSON.stringify({ error: String(e) }), {
506 status: 400,
507 });
508 }
509 },
510 {
511 body: t.Any(),
512 },
513 );
514