15.6 KB530 lines
Blame
1import type { FastifyInstance } from "fastify";
2import { z } from "zod";
3import { createHash } from "crypto";
4import {
5 generateRegistrationOptions,
6 verifyRegistrationResponse,
7 generateAuthenticationOptions,
8 verifyAuthenticationResponse,
9} from "@simplewebauthn/server";
10import type {
11 RegistrationResponseJSON,
12 AuthenticationResponseJSON,
13 AuthenticatorTransportFuture,
14} from "@simplewebauthn/server";
15
16const RP_NAME = "Grove";
17const RP_ID = process.env.RP_ID ?? "localhost";
18const ORIGIN_ENV = process.env.ORIGIN ?? "http://localhost:3000";
19const EXPECTED_ORIGINS = ORIGIN_ENV.split(",")
20 .map((origin) => origin.trim())
21 .filter(Boolean);
22const EXPECTED_ORIGIN = EXPECTED_ORIGINS.length === 1
23 ? EXPECTED_ORIGINS[0]
24 : EXPECTED_ORIGINS;
25const CHALLENGE_TTL = 5 * 60 * 1000;
26
27const registerBeginSchema = z.object({
28 username: z.string().min(2).max(39).regex(/^[a-zA-Z0-9_-]+$/),
29 display_name: z.string().optional(),
30});
31
32export async function authRoutes(app: FastifyInstance) {
33 const db = (app as any).db;
34 const challenges = (app as any).challenges as Map<
35 string,
36 { username?: string; displayName?: string; userId?: number; expiresAt: number }
37 >;
38
39 // ── Registration Step 1: Generate challenge ──────────────────────
40
41 app.post("/register/begin", async (request, reply) => {
42 const parsed = registerBeginSchema.safeParse(request.body);
43 if (!parsed.success) {
44 return reply.code(400).send({ error: parsed.error.flatten() });
45 }
46
47 const { username, display_name } = parsed.data;
48
49 const existing = db
50 .prepare("SELECT id FROM users WHERE username = ?")
51 .get(username);
52 const orgExists = db
53 .prepare("SELECT 1 FROM orgs WHERE name = ?")
54 .get(username);
55
56 if (existing || orgExists) {
57 return reply.code(409).send({ error: "Username already taken" });
58 }
59
60 const options = await generateRegistrationOptions({
61 rpName: RP_NAME,
62 rpID: RP_ID,
63 userName: username,
64 userDisplayName: display_name ?? username,
65 attestationType: "none",
66 authenticatorSelection: {
67 residentKey: "preferred",
68 userVerification: "preferred",
69 },
70 });
71
72 challenges.set(options.challenge, {
73 username,
74 displayName: display_name ?? username,
75 expiresAt: Date.now() + CHALLENGE_TTL,
76 });
77
78 return { options };
79 });
80
81 // ── Registration Step 2: Verify attestation ──────────────────────
82
83 app.post("/register/complete", async (request, reply) => {
84 const body = request.body as {
85 response: RegistrationResponseJSON;
86 challenge: string;
87 };
88
89 const stored = challenges.get(body.challenge);
90 if (!stored || stored.expiresAt < Date.now()) {
91 return reply.code(400).send({ error: "Challenge expired or invalid" });
92 }
93
94 try {
95 const verification = await verifyRegistrationResponse({
96 response: body.response,
97 expectedChallenge: body.challenge,
98 expectedOrigin: EXPECTED_ORIGIN,
99 expectedRPID: RP_ID,
100 });
101
102 if (!verification.verified || !verification.registrationInfo) {
103 return reply.code(400).send({ error: "Registration verification failed" });
104 }
105
106 const { credential, credentialDeviceType, credentialBackedUp } =
107 verification.registrationInfo;
108
109 const userResult = db
110 .prepare("INSERT INTO users (username, display_name) VALUES (?, ?)")
111 .run(stored.username, stored.displayName);
112
113 const userId = userResult.lastInsertRowid;
114
115 db.prepare(`
116 INSERT INTO credentials (id, user_id, public_key, counter, transports, device_type, backed_up)
117 VALUES (?, ?, ?, ?, ?, ?, ?)
118 `).run(
119 credential.id,
120 userId,
121 Buffer.from(credential.publicKey),
122 credential.counter,
123 JSON.stringify(credential.transports ?? []),
124 credentialDeviceType,
125 credentialBackedUp ? 1 : 0
126 );
127
128 challenges.delete(body.challenge);
129
130 const token = app.jwt.sign({
131 id: Number(userId),
132 username: stored.username!,
133 display_name: stored.displayName,
134 type: "session",
135 });
136
137 return reply.code(201).send({
138 token,
139 user: {
140 id: Number(userId),
141 username: stored.username,
142 display_name: stored.displayName,
143 },
144 });
145 } catch (err: any) {
146 return reply.code(400).send({ error: err.message });
147 }
148 });
149
150 // ── Login Step 1: Generate challenge ─────────────────────────────
151
152 app.post("/login/begin", async (request, reply) => {
153 let allowCredentials: { id: string; transports?: AuthenticatorTransportFuture[] }[] | undefined;
154
155 const body = (request.body ?? {}) as { username?: string };
156
157 if (body.username) {
158 const user = db
159 .prepare("SELECT id FROM users WHERE username = ?")
160 .get(body.username) as any;
161
162 if (!user) {
163 return reply.code(404).send({ error: "User not found" });
164 }
165
166 const creds = db
167 .prepare("SELECT id, transports FROM credentials WHERE user_id = ?")
168 .all(user.id) as any[];
169
170 allowCredentials = creds.map((c) => ({
171 id: c.id,
172 transports: JSON.parse(c.transports || "[]") as AuthenticatorTransportFuture[],
173 }));
174 }
175
176 const options = await generateAuthenticationOptions({
177 rpID: RP_ID,
178 allowCredentials,
179 });
180
181 challenges.set(options.challenge, {
182 username: body.username,
183 expiresAt: Date.now() + CHALLENGE_TTL,
184 });
185
186 return { options };
187 });
188
189 // ── Login Step 2: Verify assertion ───────────────────────────────
190
191 app.post("/login/complete", async (request, reply) => {
192 const body = request.body as {
193 response: AuthenticationResponseJSON;
194 challenge: string;
195 };
196
197 const stored = challenges.get(body.challenge);
198 if (!stored || stored.expiresAt < Date.now()) {
199 return reply.code(400).send({ error: "Challenge expired or invalid" });
200 }
201
202 const credentialId = body.response.id;
203 const credRow = db
204 .prepare(`
205 SELECT c.*, u.username, u.display_name
206 FROM credentials c
207 JOIN users u ON c.user_id = u.id
208 WHERE c.id = ?
209 `)
210 .get(credentialId) as any;
211
212 if (!credRow) {
213 return reply.code(400).send({ error: "Unknown credential" });
214 }
215
216 try {
217 const verification = await verifyAuthenticationResponse({
218 response: body.response,
219 expectedChallenge: body.challenge,
220 expectedOrigin: EXPECTED_ORIGIN,
221 expectedRPID: RP_ID,
222 credential: {
223 id: credRow.id,
224 publicKey: new Uint8Array(credRow.public_key),
225 counter: credRow.counter,
226 transports: JSON.parse(credRow.transports || "[]"),
227 },
228 });
229
230 if (!verification.verified) {
231 return reply.code(400).send({ error: "Authentication failed" });
232 }
233
234 db.prepare("UPDATE credentials SET counter = ? WHERE id = ?")
235 .run(verification.authenticationInfo.newCounter, credRow.id);
236
237 challenges.delete(body.challenge);
238
239 const token = app.jwt.sign({
240 id: credRow.user_id,
241 username: credRow.username,
242 display_name: credRow.display_name,
243 type: "session",
244 });
245
246 return {
247 token,
248 user: {
249 id: credRow.user_id,
250 username: credRow.username,
251 display_name: credRow.display_name,
252 },
253 };
254 } catch (err: any) {
255 return reply.code(400).send({ error: err.message });
256 }
257 });
258
259 // ── Device Code Flow (for headless/remote CLI auth) ──────────────
260
261 const deviceCodes = new Map<
262 string,
263 { expiresAt: number; token?: string; status: "pending" | "complete" }
264 >();
265
266 // Cleanup expired device codes
267 setInterval(() => {
268 const now = Date.now();
269 for (const [key, val] of deviceCodes) {
270 if (val.expiresAt < now) deviceCodes.delete(key);
271 }
272 }, 60 * 1000);
273
274 function generateCode(): string {
275 const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
276 let code = "";
277 for (let i = 0; i < 8; i++) {
278 if (i === 4) code += "-";
279 code += chars[Math.floor(Math.random() * chars.length)];
280 }
281 return code;
282 }
283
284 // CLI calls this to start device code flow
285 app.post("/device-code", async () => {
286 const code = generateCode();
287 deviceCodes.set(code, {
288 expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
289 status: "pending",
290 });
291 const origin = ORIGIN_ENV.split(",")[0].trim();
292 return { code, url: `${origin}/cli-auth?code=${code}`, expires_in: 600 };
293 });
294
295 // CLI polls this to check if user approved
296 app.get("/device-code/:code", async (request, reply) => {
297 const { code } = request.params as { code: string };
298 const entry = deviceCodes.get(code);
299
300 if (!entry || entry.expiresAt < Date.now()) {
301 return reply.code(404).send({ error: "Code not found or expired" });
302 }
303
304 if (entry.status === "complete" && entry.token) {
305 deviceCodes.delete(code);
306 return { status: "complete", token: entry.token };
307 }
308
309 return { status: "pending" };
310 });
311
312 // Web page calls this (authenticated) to approve the device code
313 app.post("/device-code/:code/approve", {
314 preHandler: [(app as any).authenticate],
315 handler: async (request, reply) => {
316 const { code } = request.params as { code: string };
317 const entry = deviceCodes.get(code);
318
319 if (!entry || entry.expiresAt < Date.now()) {
320 return reply.code(404).send({ error: "Code not found or expired" });
321 }
322
323 if (entry.status === "complete") {
324 return reply.code(400).send({ error: "Code already used" });
325 }
326
327 const payload = request.user as any;
328 const user = db
329 .prepare("SELECT id, username, display_name FROM users WHERE id = ?")
330 .get(payload.id) as any;
331
332 if (!user) {
333 return reply.code(404).send({ error: "User not found" });
334 }
335
336 // Create a PAT
337 const token = app.jwt.sign(
338 {
339 id: user.id,
340 username: user.username,
341 display_name: user.display_name,
342 type: "pat",
343 },
344 { expiresIn: "365d" }
345 );
346
347 const tokenHash = createHash("sha256").update(token).digest("hex");
348 const expiresAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
349
350 db.prepare(
351 "INSERT INTO api_tokens (user_id, name, token_hash, expires_at) VALUES (?, ?, ?, ?)"
352 ).run(user.id, `CLI (${new Date().toLocaleDateString()})`, tokenHash, expiresAt);
353
354 entry.status = "complete";
355 entry.token = token;
356
357 return { status: "approved" };
358 },
359 });
360
361 // ── TLS client certificates (for Sapling mTLS with Mononoke) ────
362
363 const TLS_DIR = process.env.TLS_DIR ?? "/data/grove/tls";
364
365 app.get("/tls-certs", {
366 preHandler: [(app as any).authenticate],
367 handler: async (_request, reply) => {
368 const { readFileSync, existsSync } = await import("fs");
369 const { join } = await import("path");
370
371 const caPath = join(TLS_DIR, "ca.crt");
372 const certPath = join(TLS_DIR, "client.crt");
373 const keyPath = join(TLS_DIR, "client.key");
374
375 if (!existsSync(caPath) || !existsSync(certPath) || !existsSync(keyPath)) {
376 return reply.code(503).send({ error: "TLS certificates not configured on this instance" });
377 }
378
379 return {
380 ca: readFileSync(caPath, "utf-8"),
381 cert: readFileSync(certPath, "utf-8"),
382 key: readFileSync(keyPath, "utf-8"),
383 };
384 },
385 });
386
387 // ── Get current user ─────────────────────────────────────────────
388
389 app.get("/me", {
390 preHandler: [(app as any).authenticate],
391 handler: async (request) => {
392 const payload = request.user as any;
393
394 const user = db
395 .prepare("SELECT id, username, display_name, created_at FROM users WHERE id = ?")
396 .get(payload.id);
397
398 return { user };
399 },
400 });
401
402 // ── Refresh session token ────────────────────────────────────────
403
404 app.post("/refresh", {
405 preHandler: [(app as any).authenticate],
406 handler: async (request, reply) => {
407 const payload = request.user as any;
408
409 if (payload.type !== "session") {
410 return reply.code(403).send({ error: "Only session tokens can be refreshed" });
411 }
412
413 const user = db
414 .prepare("SELECT id, username, display_name FROM users WHERE id = ?")
415 .get(payload.id) as any;
416
417 if (!user) {
418 return reply.code(401).send({ error: "User not found" });
419 }
420
421 const token = app.jwt.sign({
422 id: user.id,
423 username: user.username,
424 display_name: user.display_name,
425 type: "session",
426 });
427
428 return { token, user: { id: user.id, username: user.username, display_name: user.display_name } };
429 },
430 });
431
432 // ── Personal Access Tokens (PATs) ─────────────────────────────────
433
434 const createTokenSchema = z.object({
435 name: z.string().min(1).max(100),
436 expires_in: z.enum(["30d", "90d", "1y"]).default("1y"),
437 });
438
439 const EXPIRY_MAP: Record<string, string> = {
440 "30d": "30d",
441 "90d": "90d",
442 "1y": "365d",
443 };
444
445 app.post("/tokens", {
446 preHandler: [(app as any).authenticate],
447 handler: async (request, reply) => {
448 const parsed = createTokenSchema.safeParse(request.body);
449 if (!parsed.success) {
450 return reply.code(400).send({ error: parsed.error.flatten() });
451 }
452
453 const payload = request.user as any;
454 const { name, expires_in } = parsed.data;
455
456 const user = db
457 .prepare("SELECT id, username, display_name FROM users WHERE id = ?")
458 .get(payload.id) as any;
459
460 if (!user) {
461 return reply.code(404).send({ error: "User not found" });
462 }
463
464 const token = app.jwt.sign(
465 {
466 id: user.id,
467 username: user.username,
468 display_name: user.display_name,
469 type: "pat",
470 },
471 { expiresIn: EXPIRY_MAP[expires_in] }
472 );
473
474 const tokenHash = createHash("sha256").update(token).digest("hex");
475 const expiresAt = new Date(
476 Date.now() + parseInt(EXPIRY_MAP[expires_in]) * 24 * 60 * 60 * 1000
477 ).toISOString();
478
479 const result = db
480 .prepare(
481 "INSERT INTO api_tokens (user_id, name, token_hash, expires_at) VALUES (?, ?, ?, ?)"
482 )
483 .run(user.id, name, tokenHash, expiresAt);
484
485 return reply.code(201).send({
486 token,
487 api_token: {
488 id: result.lastInsertRowid,
489 name,
490 expires_at: expiresAt,
491 created_at: new Date().toISOString(),
492 },
493 });
494 },
495 });
496
497 app.get("/tokens", {
498 preHandler: [(app as any).authenticate],
499 handler: async (request) => {
500 const payload = request.user as any;
501
502 const tokens = db
503 .prepare(
504 "SELECT id, name, expires_at, last_used_at, created_at FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC"
505 )
506 .all(payload.id);
507
508 return { tokens };
509 },
510 });
511
512 app.delete("/tokens/:id", {
513 preHandler: [(app as any).authenticate],
514 handler: async (request, reply) => {
515 const payload = request.user as any;
516 const { id } = request.params as { id: string };
517
518 const result = db
519 .prepare("DELETE FROM api_tokens WHERE id = ? AND user_id = ?")
520 .run(id, payload.id);
521
522 if (result.changes === 0) {
523 return reply.code(404).send({ error: "Token not found" });
524 }
525
526 return reply.code(204).send();
527 },
528 });
529}
530