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