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