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