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