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