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