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