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(t.String()),
277 }),
278 },
279 )
280
281 .post("/logout", async ({ cookie }) => {
282 const sessionId = cookie.session.value;
283 if (sessionId) {
284 await db
285 .deleteFrom("sessions")
286 .where("id", "=", sessionId)
287 .execute();
288 }
289 return redirect("/", clearCookie());
290 })
291
292 // Create account (passkey-only path, called before passkey registration)
293 .post(
294 "/auth/passkey/create-user",
295 async ({ body }) => {
296 if (config.REGISTRATION_TYPE === "disabled") {
297 return new Response(
298 JSON.stringify({ error: "Registration is disabled" }),
299 { status: 400 },
300 );
301 }
302 const { username, application } = body;
303 if (!username || !VALID_USERNAME_RE.test(username)) {
304 return new Response(
305 JSON.stringify({ error: "Invalid username" }),
306 {
307 status: 400,
308 },
309 );
310 }
311 if (username === ADMIN_USERNAME) {
312 return new Response(
313 JSON.stringify({ error: "That username is reserved" }),
314 { status: 400 },
315 );
316 }
317 const now = new Date().toISOString();
318 const isPending = config.REGISTRATION_TYPE === "queue" ? 1 : 0;
319 let result: { id: number };
320 try {
321 result = await db
322 .insertInto("users")
323 .values({
324 username,
325 password_hash: null,
326 created_at: now,
327 is_pending: isPending,
328 register_application: application ?? null,
329 })
330 .returning("id")
331 .executeTakeFirstOrThrow();
332 } catch (err) {
333 if (
334 err instanceof Error &&
335 err.message.includes(
336 "UNIQUE constraint failed: users.username",
337 )
338 ) {
339 return new Response(
340 JSON.stringify({ error: "Username already taken" }),
341 { status: 400 },
342 );
343 }
344 throw err;
345 }
346
347 if (config.REGISTRATION_TYPE === "queue") {
348 return new Response(
349 JSON.stringify({ ok: true, pending: true }),
350 { headers: { "Content-Type": "application/json" } },
351 );
352 }
353
354 const sessionId = await createSession(result.id);
355 return new Response(JSON.stringify({ ok: true }), {
356 headers: {
357 "Content-Type": "application/json",
358 "Set-Cookie": sessionCookie(sessionId),
359 },
360 });
361 },
362 {
363 body: t.Object({
364 username: t.String(),
365 application: t.Optional(t.String()),
366 }),
367 },
368 )
369
370 // Passkey registration
371 .post("/auth/passkey/register/options", async ({ cookie, request }) => {
372 const user = await resolveSession(cookie.session.value);
373 if (!user)
374 return new Response(
375 JSON.stringify({ error: "Not authenticated" }),
376 {
377 status: 401,
378 },
379 );
380
381 const { rpId } = rpFromRequest(request);
382 const passkeys = await db
383 .selectFrom("passkeys")
384 .select(["credential_id"])
385 .where("user_id", "=", user.id)
386 .execute();
387
388 const options = await generateRegistrationOptions({
389 rpName: WEBAUTHN_RP_NAME,
390 rpID: rpId,
391 userID: Buffer.from(String(user.id)),
392 userName: user.username,
393 attestationType: "none",
394 excludeCredentials: passkeys.map((p) => ({
395 id: p.credential_id,
396 type: "public-key" as const,
397 })),
398 });
399
400 setChallenge(`${user.username}:reg`, options.challenge);
401 return new Response(JSON.stringify(options), {
402 headers: { "Content-Type": "application/json" },
403 });
404 })
405
406 .post(
407 "/auth/passkey/register/verify",
408 async ({ body, cookie, request }) => {
409 const user = await resolveSession(cookie.session.value);
410 if (!user)
411 return new Response(
412 JSON.stringify({ error: "Not authenticated" }),
413 {
414 status: 401,
415 },
416 );
417
418 const challenge = pendingChallenges.get(
419 `${user.username}:reg`,
420 )?.challenge;
421 if (!challenge)
422 return new Response(JSON.stringify({ error: "No challenge" }), {
423 status: 400,
424 });
425
426 const { origin, rpId } = rpFromRequest(request);
427 try {
428 const verification = await verifyRegistrationResponse({
429 response: body as Parameters<
430 typeof verifyRegistrationResponse
431 >[0]["response"],
432 expectedChallenge: challenge,
433 expectedOrigin: origin,
434 expectedRPID: rpId,
435 });
436
437 if (!verification.verified || !verification.registrationInfo) {
438 return new Response(
439 JSON.stringify({ error: "Verification failed" }),
440 { status: 400 },
441 );
442 }
443
444 const { credential } = verification.registrationInfo;
445 const now = new Date().toISOString();
446 await db
447 .insertInto("passkeys")
448 .values({
449 user_id: user.id,
450 credential_id: credential.id,
451 public_key: Buffer.from(
452 new Uint8Array(credential.publicKey),
453 ).toString("base64"),
454 counter: credential.counter,
455 created_at: now,
456 })
457 .execute();
458
459 deleteChallenge(`${user.username}:reg`);
460 return new Response(JSON.stringify({ ok: true }), {
461 headers: { "Content-Type": "application/json" },
462 });
463 } catch (e) {
464 return new Response(
465 JSON.stringify({
466 error:
467 e instanceof Error
468 ? e.message
469 : "Verification failed",
470 }),
471 { status: 400 },
472 );
473 }
474 },
475 {
476 body: t.Any(),
477 },
478 )
479
480 // Passkey login
481 .post("/auth/passkey/login/options", async ({ request }) => {
482 const { rpId } = rpFromRequest(request);
483 const options = await generateAuthenticationOptions({
484 rpID: rpId,
485 });
486 setChallenge(`login:${options.challenge}`, options.challenge);
487 return new Response(JSON.stringify(options), {
488 headers: { "Content-Type": "application/json" },
489 });
490 })
491
492 .post(
493 "/auth/passkey/login/verify",
494 async ({ body, request }) => {
495 const reqBody = body as { id?: string };
496 const credentialId = reqBody?.id;
497 if (!credentialId) {
498 return new Response(
499 JSON.stringify({ error: "Missing credential" }),
500 {
501 status: 400,
502 },
503 );
504 }
505
506 const passkey = await db
507 .selectFrom("passkeys")
508 .innerJoin("users", "users.id", "passkeys.user_id")
509 .selectAll("passkeys")
510 .select("users.username")
511 .where("passkeys.credential_id", "=", credentialId)
512 .executeTakeFirst();
513
514 if (!passkey) {
515 return new Response(
516 JSON.stringify({ error: "Unknown credential" }),
517 {
518 status: 400,
519 },
520 );
521 }
522
523 // Look up the challenge by decoding it from the signed clientDataJSON.
524 // This ensures each verify request finds its own challenge rather than
525 // an arbitrary "first login:" entry, which would fail under concurrency.
526 const reqBodyTyped = body as {
527 response?: { clientDataJSON?: string };
528 };
529 const clientDataJSON = reqBodyTyped?.response?.clientDataJSON;
530 let challengeKey: string | undefined;
531 let challenge: string | undefined;
532 if (clientDataJSON) {
533 try {
534 const cd = JSON.parse(
535 Buffer.from(clientDataJSON, "base64url").toString(),
536 ) as { challenge?: string };
537 if (cd.challenge) {
538 challengeKey = `login:${cd.challenge}`;
539 challenge =
540 pendingChallenges.get(challengeKey)?.challenge;
541 }
542 } catch {
543 // malformed clientDataJSON — handled by the check below
544 }
545 }
546 if (!challenge) {
547 return new Response(JSON.stringify({ error: "No challenge" }), {
548 status: 400,
549 });
550 }
551
552 const { origin, rpId } = rpFromRequest(request);
553 try {
554 const verification = await verifyAuthenticationResponse({
555 response: body as Parameters<
556 typeof verifyAuthenticationResponse
557 >[0]["response"],
558 expectedChallenge: challenge,
559 expectedOrigin: origin,
560 expectedRPID: rpId,
561 credential: {
562 id: passkey.credential_id,
563 publicKey: Buffer.from(passkey.public_key, "base64"),
564 counter: passkey.counter,
565 },
566 });
567
568 if (!verification.verified) {
569 return new Response(
570 JSON.stringify({ error: "Verification failed" }),
571 { status: 401 },
572 );
573 }
574
575 // Atomically advance the counter using the expected old value as
576 // a guard. If another concurrent request already updated it, 0
577 // rows are affected and we reject — this preserves WebAuthn's
578 // monotonic-counter replay-attack protection.
579 const updated = await db
580 .updateTable("passkeys")
581 .set({
582 counter: verification.authenticationInfo.newCounter,
583 })
584 .where("id", "=", passkey.id)
585 .where("counter", "=", passkey.counter)
586 .executeTakeFirst();
587
588 if (!updated || updated.numUpdatedRows === 0n) {
589 return new Response(
590 JSON.stringify({ error: "Credential replay detected" }),
591 { status: 401 },
592 );
593 }
594
595 deleteChallenge(challengeKey!);
596 const sessionId = await createSession(passkey.user_id);
597 return new Response(JSON.stringify({ ok: true }), {
598 headers: {
599 "Content-Type": "application/json",
600 "Set-Cookie": sessionCookie(sessionId),
601 },
602 });
603 } catch (e) {
604 return new Response(
605 JSON.stringify({
606 error:
607 e instanceof Error
608 ? e.message
609 : "Verification failed",
610 }),
611 { status: 400 },
612 );
613 }
614 },
615 {
616 body: t.Any(),
617 },
618 );
619