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 config from "../config.ts";
10import {
11 ADMIN_USERNAME,
12 CHALLENGE_TTL_MS,
13 LOGIN_MAX_ATTEMPTS,
14 LOGIN_RATE_WINDOW_MS,
15 MIN_PASSWORD_LENGTH,
16 REGISTRATION_MAX_ATTEMPTS,
17 REGISTRATION_RATE_WINDOW_MS,
18 SESSION_DURATION_MS,
19 SESSION_DURATION_SECONDS,
20 SESSION_ID_BYTES,
21 VALID_USERNAME_RE,
22 WEBAUTHN_RP_NAME,
23} from "../constants.ts";
24import { db } from "../db/index.ts";
25import { checkRateLimit, getClientIp } from "../lib/rateLimiter.ts";
26import { redirect } from "../lib/redirect.ts";
27import { resolveSession } from "../middleware/session.ts";
28import { Login } from "../views/auth/Login.tsx";
29import { Register } from "../views/auth/Register.tsx";
30import { html } from "../views/render.tsx";
31
32// WebAuthn's expected origin and RP ID are pinned to the configured public
33// origin (BASE_URL), never derived from the client's Origin header — otherwise
34// the server-side origin check in verification validates the value against
35// itself and becomes a no-op. The browser must be on this origin for passkeys
36// to work, which is the intended production posture (set BASE_URL).
37const PUBLIC_RP_ID = new URL(config.PUBLIC_ORIGIN).hostname;
38function rpFromRequest(_request: Request): { origin: string; rpId: string } {
39 return { origin: config.PUBLIC_ORIGIN, rpId: PUBLIC_RP_ID };
40}
41
42function randomHex(bytes: number): string {
43 const arr = new Uint8Array(bytes);
44 crypto.getRandomValues(arr);
45 return Array.from(arr)
46 .map((b) => b.toString(16).padStart(2, "0"))
47 .join("");
48}
49
50async function createSession(userId: number): Promise<string> {
51 const id = randomHex(SESSION_ID_BYTES);
52 const now = new Date();
53 const expires = new Date(now.getTime() + SESSION_DURATION_MS);
54 await db
55 .insertInto("sessions")
56 .values({
57 id,
58 user_id: userId,
59 expires_at: expires.toISOString(),
60 created_at: now.toISOString(),
61 })
62 .execute();
63 return id;
64}
65
66function sessionCookie(id: string): string {
67 const secure = config.PUBLIC_HTTPS ? "; Secure" : "";
68 return `session=${id}; Path=/; HttpOnly; SameSite=Lax${secure}; Max-Age=${SESSION_DURATION_SECONDS}`;
69}
70
71function clearCookie(): string {
72 const secure = config.PUBLIC_HTTPS ? "; Secure" : "";
73 return `session=; Path=/; HttpOnly; SameSite=Lax${secure}; Max-Age=0`;
74}
75
76// In-memory challenge store (fine for single-process)
77type ChallengeEntry = {
78 challenge: string;
79 timeoutId: ReturnType<typeof setTimeout>;
80};
81const pendingChallenges = new Map<string, ChallengeEntry>();
82
83function setChallenge(key: string, challenge: string): void {
84 const existing = pendingChallenges.get(key);
85 if (existing) clearTimeout(existing.timeoutId);
86 const timeoutId = setTimeout(
87 () => pendingChallenges.delete(key),
88 CHALLENGE_TTL_MS,
89 );
90 pendingChallenges.set(key, { challenge, timeoutId });
91}
92
93function deleteChallenge(key: string): void {
94 const entry = pendingChallenges.get(key);
95 if (entry) clearTimeout(entry.timeoutId);
96 pendingChallenges.delete(key);
97}
98
99export const authRoutes = new Elysia()
100 .guard({
101 cookie: t.Cookie({ session: t.Optional(t.String()) }),
102 })
103 .get("/login", () => {
104 return html(<Login />);
105 })
106
107 .post(
108 "/login",
109 async ({ body, request, server }) => {
110 const ip = getClientIp(request, server);
111 if (
112 !checkRateLimit(
113 ip,
114 "login",
115 LOGIN_MAX_ATTEMPTS,
116 LOGIN_RATE_WINDOW_MS,
117 )
118 ) {
119 return html(
120 <Login error="Too many login attempts. Please try again later." />,
121 );
122 }
123 const { username, password } = body;
124 const user = await db
125 .selectFrom("users")
126 .selectAll()
127 .where("username", "=", username)
128 .executeTakeFirst();
129
130 if (!user || !user.password_hash) {
131 return html(<Login error="Invalid username or password" />);
132 }
133
134 const valid = await argon2.verify(user.password_hash, password);
135 if (!valid) {
136 return html(<Login error="Invalid username or password" />);
137 }
138
139 if (user.is_pending) {
140 return html(
141 <Login error="Your account is awaiting approval." />,
142 );
143 }
144
145 const sessionId = await createSession(user.id);
146 return redirect("/", sessionCookie(sessionId));
147 },
148 {
149 body: t.Object({ username: t.String(), password: t.String() }),
150 },
151 )
152
153 .get("/register", () => {
154 if (config.REGISTRATION_TYPE === "disabled")
155 return new Response("Registration is disabled", { status: 403 });
156 return html(<Register question={config.REGISTER_QUESTION} />);
157 })
158
159 .post(
160 "/register",
161 async ({ body, request, server }) => {
162 if (config.REGISTRATION_TYPE === "disabled")
163 return new Response("Registration is disabled", {
164 status: 403,
165 });
166 const ip = getClientIp(request, server);
167 if (
168 !checkRateLimit(
169 ip,
170 "register",
171 REGISTRATION_MAX_ATTEMPTS,
172 REGISTRATION_RATE_WINDOW_MS,
173 )
174 ) {
175 return html(
176 <Register
177 error="Too many registration attempts. Please try again later."
178 question={config.REGISTER_QUESTION}
179 />,
180 );
181 }
182 const { username, password, password2, application } = body;
183
184 if (!VALID_USERNAME_RE.test(username)) {
185 return html(
186 <Register
187 error="Username may only contain letters, numbers, hyphens, and underscores"
188 question={config.REGISTER_QUESTION}
189 />,
190 );
191 }
192 if (username === ADMIN_USERNAME) {
193 return html(
194 <Register
195 error="That username is reserved"
196 question={config.REGISTER_QUESTION}
197 />,
198 );
199 }
200
201 if (!password?.trim()) {
202 return html(
203 <Register
204 error="Password is required (use the passkey button for passwordless registration)"
205 question={config.REGISTER_QUESTION}
206 />,
207 );
208 }
209 if (password !== password2) {
210 return html(
211 <Register
212 error="Passwords do not match"
213 question={config.REGISTER_QUESTION}
214 />,
215 );
216 }
217 if (password.length < MIN_PASSWORD_LENGTH) {
218 return html(
219 <Register
220 error="Password must be at least 8 characters"
221 question={config.REGISTER_QUESTION}
222 />,
223 );
224 }
225
226 const hash = await argon2.hash(password);
227 const now = new Date().toISOString();
228 const isPending = config.REGISTRATION_TYPE === "queue" ? 1 : 0;
229 let result: { id: number };
230 try {
231 result = await db
232 .insertInto("users")
233 .values({
234 username,
235 password_hash: hash,
236 created_at: now,
237 is_pending: isPending,
238 register_application: application ?? null,
239 })
240 .returning("id")
241 .executeTakeFirstOrThrow();
242 } catch (err) {
243 if (
244 err instanceof Error &&
245 err.message.includes(
246 "UNIQUE constraint failed: users.username",
247 )
248 ) {
249 return html(
250 <Register
251 error="Username already taken"
252 question={config.REGISTER_QUESTION}
253 />,
254 );
255 }
256 throw err;
257 }
258
259 if (config.REGISTRATION_TYPE === "queue") {
260 return html(
261 <Register
262 pending={true}
263 question={config.REGISTER_QUESTION}
264 />,
265 );
266 }
267
268 const sessionId = await createSession(result.id);
269 return redirect("/", sessionCookie(sessionId));
270 },
271 {
272 body: t.Object({
273 username: t.String({ maxLength: config.MAX_USERNAME_BYTES }),
274 password: t.Optional(
275 t.String({ maxLength: config.MAX_PASSWORD_BYTES }),
276 ),
277 password2: t.Optional(
278 t.String({ maxLength: config.MAX_PASSWORD_BYTES }),
279 ),
280 application: t.Optional(
281 t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
282 ),
283 }),
284 },
285 )
286
287 .post("/logout", async ({ cookie }) => {
288 const sessionId = cookie.session.value;
289 if (sessionId) {
290 await db
291 .deleteFrom("sessions")
292 .where("id", "=", sessionId)
293 .execute();
294 }
295 return redirect("/", clearCookie());
296 })
297
298 // Create account (passkey-only path, called before passkey registration)
299 .post(
300 "/auth/passkey/create-user",
301 async ({ body, request, server }) => {
302 if (config.REGISTRATION_TYPE === "disabled") {
303 return new Response(
304 JSON.stringify({ error: "Registration is disabled" }),
305 { status: 400 },
306 );
307 }
308 // Shares the "register" bucket with the password path so this
309 // endpoint can't be used to sidestep that limiter (both create a
310 // real users row).
311 const ip = getClientIp(request, server);
312 if (
313 !checkRateLimit(
314 ip,
315 "register",
316 REGISTRATION_MAX_ATTEMPTS,
317 REGISTRATION_RATE_WINDOW_MS,
318 )
319 ) {
320 return new Response(
321 JSON.stringify({
322 error: "Too many registration attempts. Please try again later.",
323 }),
324 {
325 status: 429,
326 headers: { "Content-Type": "application/json" },
327 },
328 );
329 }
330 const { username, application } = body;
331 if (!username || !VALID_USERNAME_RE.test(username)) {
332 return new Response(
333 JSON.stringify({ error: "Invalid username" }),
334 {
335 status: 400,
336 },
337 );
338 }
339 if (username === ADMIN_USERNAME) {
340 return new Response(
341 JSON.stringify({ error: "That username is reserved" }),
342 { status: 400 },
343 );
344 }
345 const now = new Date().toISOString();
346 const isPending = config.REGISTRATION_TYPE === "queue" ? 1 : 0;
347 let result: { id: number };
348 try {
349 result = await db
350 .insertInto("users")
351 .values({
352 username,
353 password_hash: null,
354 created_at: now,
355 is_pending: isPending,
356 register_application: application ?? null,
357 })
358 .returning("id")
359 .executeTakeFirstOrThrow();
360 } catch (err) {
361 if (
362 err instanceof Error &&
363 err.message.includes(
364 "UNIQUE constraint failed: users.username",
365 )
366 ) {
367 return new Response(
368 JSON.stringify({ error: "Username already taken" }),
369 { status: 400 },
370 );
371 }
372 throw err;
373 }
374
375 if (config.REGISTRATION_TYPE === "queue") {
376 return new Response(
377 JSON.stringify({ ok: true, pending: true }),
378 { headers: { "Content-Type": "application/json" } },
379 );
380 }
381
382 const sessionId = await createSession(result.id);
383 return new Response(JSON.stringify({ ok: true }), {
384 headers: {
385 "Content-Type": "application/json",
386 "Set-Cookie": sessionCookie(sessionId),
387 },
388 });
389 },
390 {
391 body: t.Object({
392 username: t.String({ maxLength: config.MAX_USERNAME_BYTES }),
393 application: t.Optional(
394 t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
395 ),
396 }),
397 },
398 )
399
400 // Passkey registration
401 .post("/auth/passkey/register/options", async ({ cookie, request }) => {
402 const user = await resolveSession(cookie.session.value);
403 if (!user)
404 return new Response(
405 JSON.stringify({ error: "Not authenticated" }),
406 {
407 status: 401,
408 },
409 );
410
411 const { rpId } = rpFromRequest(request);
412 const passkeys = await db
413 .selectFrom("passkeys")
414 .select(["credential_id"])
415 .where("user_id", "=", user.id)
416 .execute();
417
418 const options = await generateRegistrationOptions({
419 rpName: WEBAUTHN_RP_NAME,
420 rpID: rpId,
421 userID: Buffer.from(String(user.id)),
422 userName: user.username,
423 attestationType: "none",
424 excludeCredentials: passkeys.map((p) => ({
425 id: p.credential_id,
426 type: "public-key" as const,
427 })),
428 });
429
430 setChallenge(`${user.username}:reg`, options.challenge);
431 return new Response(JSON.stringify(options), {
432 headers: { "Content-Type": "application/json" },
433 });
434 })
435
436 .post(
437 "/auth/passkey/register/verify",
438 async ({ body, cookie, request }) => {
439 const user = await resolveSession(cookie.session.value);
440 if (!user)
441 return new Response(
442 JSON.stringify({ error: "Not authenticated" }),
443 {
444 status: 401,
445 },
446 );
447
448 const challenge = pendingChallenges.get(
449 `${user.username}:reg`,
450 )?.challenge;
451 if (!challenge)
452 return new Response(JSON.stringify({ error: "No challenge" }), {
453 status: 400,
454 });
455
456 const { origin, rpId } = rpFromRequest(request);
457 try {
458 const verification = await verifyRegistrationResponse({
459 response: body as Parameters<
460 typeof verifyRegistrationResponse
461 >[0]["response"],
462 expectedChallenge: challenge,
463 expectedOrigin: origin,
464 expectedRPID: rpId,
465 });
466
467 if (!verification.verified || !verification.registrationInfo) {
468 return new Response(
469 JSON.stringify({ error: "Verification failed" }),
470 { status: 400 },
471 );
472 }
473
474 const { credential } = verification.registrationInfo;
475 const now = new Date().toISOString();
476 await db
477 .insertInto("passkeys")
478 .values({
479 user_id: user.id,
480 credential_id: credential.id,
481 public_key: Buffer.from(
482 new Uint8Array(credential.publicKey),
483 ).toString("base64"),
484 counter: credential.counter,
485 created_at: now,
486 })
487 .execute();
488
489 deleteChallenge(`${user.username}:reg`);
490 return new Response(JSON.stringify({ ok: true }), {
491 headers: { "Content-Type": "application/json" },
492 });
493 } catch (e) {
494 return new Response(
495 JSON.stringify({
496 error:
497 e instanceof Error
498 ? e.message
499 : "Verification failed",
500 }),
501 { status: 400 },
502 );
503 }
504 },
505 {
506 body: t.Any(),
507 },
508 )
509
510 // Passkey login
511 .post("/auth/passkey/login/options", async ({ request }) => {
512 const { rpId } = rpFromRequest(request);
513 const options = await generateAuthenticationOptions({
514 rpID: rpId,
515 });
516 setChallenge(`login:${options.challenge}`, options.challenge);
517 return new Response(JSON.stringify(options), {
518 headers: { "Content-Type": "application/json" },
519 });
520 })
521
522 .post(
523 "/auth/passkey/login/verify",
524 async ({ body, request }) => {
525 const reqBody = body as { id?: string };
526 const credentialId = reqBody?.id;
527 if (!credentialId) {
528 return new Response(
529 JSON.stringify({ error: "Missing credential" }),
530 {
531 status: 400,
532 },
533 );
534 }
535
536 const passkey = await db
537 .selectFrom("passkeys")
538 .innerJoin("users", "users.id", "passkeys.user_id")
539 .selectAll("passkeys")
540 .select("users.username")
541 .where("passkeys.credential_id", "=", credentialId)
542 .executeTakeFirst();
543
544 if (!passkey) {
545 return new Response(
546 JSON.stringify({ error: "Unknown credential" }),
547 {
548 status: 400,
549 },
550 );
551 }
552
553 // Look up the challenge by decoding it from the signed clientDataJSON.
554 // This ensures each verify request finds its own challenge rather than
555 // an arbitrary "first login:" entry, which would fail under concurrency.
556 const reqBodyTyped = body as {
557 response?: { clientDataJSON?: string };
558 };
559 const clientDataJSON = reqBodyTyped?.response?.clientDataJSON;
560 let challengeKey: string | undefined;
561 let challenge: string | undefined;
562 if (clientDataJSON) {
563 try {
564 const cd = JSON.parse(
565 Buffer.from(clientDataJSON, "base64url").toString(),
566 ) as { challenge?: string };
567 if (cd.challenge) {
568 challengeKey = `login:${cd.challenge}`;
569 challenge =
570 pendingChallenges.get(challengeKey)?.challenge;
571 }
572 } catch {
573 // malformed clientDataJSON — handled by the check below
574 }
575 }
576 if (!challenge) {
577 return new Response(JSON.stringify({ error: "No challenge" }), {
578 status: 400,
579 });
580 }
581
582 const { origin, rpId } = rpFromRequest(request);
583 try {
584 const verification = await verifyAuthenticationResponse({
585 response: body as Parameters<
586 typeof verifyAuthenticationResponse
587 >[0]["response"],
588 expectedChallenge: challenge,
589 expectedOrigin: origin,
590 expectedRPID: rpId,
591 credential: {
592 id: passkey.credential_id,
593 publicKey: Buffer.from(passkey.public_key, "base64"),
594 counter: passkey.counter,
595 },
596 });
597
598 if (!verification.verified) {
599 return new Response(
600 JSON.stringify({ error: "Verification failed" }),
601 { status: 401 },
602 );
603 }
604
605 // Atomically advance the counter using the expected old value as
606 // a guard. If another concurrent request already updated it, 0
607 // rows are affected and we reject — this preserves WebAuthn's
608 // monotonic-counter replay-attack protection.
609 const updated = await db
610 .updateTable("passkeys")
611 .set({
612 counter: verification.authenticationInfo.newCounter,
613 })
614 .where("id", "=", passkey.id)
615 .where("counter", "=", passkey.counter)
616 .executeTakeFirst();
617
618 if (!updated || updated.numUpdatedRows === 0n) {
619 return new Response(
620 JSON.stringify({ error: "Credential replay detected" }),
621 { status: 401 },
622 );
623 }
624
625 deleteChallenge(challengeKey!);
626 const sessionId = await createSession(passkey.user_id);
627 return new Response(JSON.stringify({ ok: true }), {
628 headers: {
629 "Content-Type": "application/json",
630 "Set-Cookie": sessionCookie(sessionId),
631 },
632 });
633 } catch (e) {
634 return new Response(
635 JSON.stringify({
636 error:
637 e instanceof Error
638 ? e.message
639 : "Verification failed",
640 }),
641 { status: 400 },
642 );
643 }
644 },
645 {
646 body: t.Any(),
647 },
648 );
649