11.7 KB402 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 // ── Get current user ─────────────────────────────────────────────
260
261 app.get("/me", {
262 preHandler: [(app as any).authenticate],
263 handler: async (request) => {
264 const payload = request.user as any;
265
266 const user = db
267 .prepare("SELECT id, username, display_name, created_at FROM users WHERE id = ?")
268 .get(payload.id);
269
270 return { user };
271 },
272 });
273
274 // ── Refresh session token ────────────────────────────────────────
275
276 app.post("/refresh", {
277 preHandler: [(app as any).authenticate],
278 handler: async (request, reply) => {
279 const payload = request.user as any;
280
281 if (payload.type !== "session") {
282 return reply.code(403).send({ error: "Only session tokens can be refreshed" });
283 }
284
285 const user = db
286 .prepare("SELECT id, username, display_name FROM users WHERE id = ?")
287 .get(payload.id) as any;
288
289 if (!user) {
290 return reply.code(401).send({ error: "User not found" });
291 }
292
293 const token = app.jwt.sign({
294 id: user.id,
295 username: user.username,
296 display_name: user.display_name,
297 type: "session",
298 });
299
300 return { token, user: { id: user.id, username: user.username, display_name: user.display_name } };
301 },
302 });
303
304 // ── Personal Access Tokens (PATs) ─────────────────────────────────
305
306 const createTokenSchema = z.object({
307 name: z.string().min(1).max(100),
308 expires_in: z.enum(["30d", "90d", "1y"]).default("1y"),
309 });
310
311 const EXPIRY_MAP: Record<string, string> = {
312 "30d": "30d",
313 "90d": "90d",
314 "1y": "365d",
315 };
316
317 app.post("/tokens", {
318 preHandler: [(app as any).authenticate],
319 handler: async (request, reply) => {
320 const parsed = createTokenSchema.safeParse(request.body);
321 if (!parsed.success) {
322 return reply.code(400).send({ error: parsed.error.flatten() });
323 }
324
325 const payload = request.user as any;
326 const { name, expires_in } = parsed.data;
327
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 const token = app.jwt.sign(
337 {
338 id: user.id,
339 username: user.username,
340 display_name: user.display_name,
341 type: "pat",
342 },
343 { expiresIn: EXPIRY_MAP[expires_in] }
344 );
345
346 const tokenHash = createHash("sha256").update(token).digest("hex");
347 const expiresAt = new Date(
348 Date.now() + parseInt(EXPIRY_MAP[expires_in]) * 24 * 60 * 60 * 1000
349 ).toISOString();
350
351 const result = db
352 .prepare(
353 "INSERT INTO api_tokens (user_id, name, token_hash, expires_at) VALUES (?, ?, ?, ?)"
354 )
355 .run(user.id, name, tokenHash, expiresAt);
356
357 return reply.code(201).send({
358 token,
359 api_token: {
360 id: result.lastInsertRowid,
361 name,
362 expires_at: expiresAt,
363 created_at: new Date().toISOString(),
364 },
365 });
366 },
367 });
368
369 app.get("/tokens", {
370 preHandler: [(app as any).authenticate],
371 handler: async (request) => {
372 const payload = request.user as any;
373
374 const tokens = db
375 .prepare(
376 "SELECT id, name, expires_at, last_used_at, created_at FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC"
377 )
378 .all(payload.id);
379
380 return { tokens };
381 },
382 });
383
384 app.delete("/tokens/:id", {
385 preHandler: [(app as any).authenticate],
386 handler: async (request, reply) => {
387 const payload = request.user as any;
388 const { id } = request.params as { id: string };
389
390 const result = db
391 .prepare("DELETE FROM api_tokens WHERE id = ? AND user_id = ?")
392 .run(id, payload.id);
393
394 if (result.changes === 0) {
395 return reply.code(404).send({ error: "Token not found" });
396 }
397
398 return reply.code(204).send();
399 },
400 });
401}
402