hub-api/src/routes/auth.tsblame
View source
135dfe51import type { FastifyInstance } from "fastify";
135dfe52import { z } from "zod";
3c994d33import { createHash } from "crypto";
135dfe54import {
135dfe55 generateRegistrationOptions,
135dfe56 verifyRegistrationResponse,
135dfe57 generateAuthenticationOptions,
135dfe58 verifyAuthenticationResponse,
135dfe59} from "@simplewebauthn/server";
135dfe510import type {
135dfe511 RegistrationResponseJSON,
135dfe512 AuthenticationResponseJSON,
135dfe513 AuthenticatorTransportFuture,
135dfe514} from "@simplewebauthn/server";
135dfe515
135dfe516const RP_NAME = "Grove";
135dfe517const RP_ID = process.env.RP_ID ?? "localhost";
a33b2b618const ORIGIN_ENV = process.env.ORIGIN ?? "http://localhost:3000";
a33b2b619const EXPECTED_ORIGINS = ORIGIN_ENV.split(",")
a33b2b620 .map((origin) => origin.trim())
a33b2b621 .filter(Boolean);
a33b2b622const EXPECTED_ORIGIN = EXPECTED_ORIGINS.length === 1
a33b2b623 ? EXPECTED_ORIGINS[0]
a33b2b624 : EXPECTED_ORIGINS;
135dfe525const CHALLENGE_TTL = 5 * 60 * 1000;
135dfe526
135dfe527const registerBeginSchema = z.object({
135dfe528 username: z.string().min(2).max(39).regex(/^[a-zA-Z0-9_-]+$/),
135dfe529 display_name: z.string().optional(),
135dfe530});
135dfe531
135dfe532export async function authRoutes(app: FastifyInstance) {
135dfe533 const db = (app as any).db;
135dfe534 const challenges = (app as any).challenges as Map<
135dfe535 string,
135dfe536 { username?: string; displayName?: string; userId?: number; expiresAt: number }
135dfe537 >;
135dfe538
135dfe539 // ── Registration Step 1: Generate challenge ──────────────────────
135dfe540
135dfe541 app.post("/register/begin", async (request, reply) => {
135dfe542 const parsed = registerBeginSchema.safeParse(request.body);
135dfe543 if (!parsed.success) {
135dfe544 return reply.code(400).send({ error: parsed.error.flatten() });
135dfe545 }
135dfe546
135dfe547 const { username, display_name } = parsed.data;
135dfe548
135dfe549 const existing = db
135dfe550 .prepare("SELECT id FROM users WHERE username = ?")
135dfe551 .get(username);
79efd4152 const orgExists = db
79efd4153 .prepare("SELECT 1 FROM orgs WHERE name = ?")
79efd4154 .get(username);
135dfe555
79efd4156 if (existing || orgExists) {
135dfe557 return reply.code(409).send({ error: "Username already taken" });
135dfe558 }
135dfe559
135dfe560 const options = await generateRegistrationOptions({
135dfe561 rpName: RP_NAME,
135dfe562 rpID: RP_ID,
135dfe563 userName: username,
135dfe564 userDisplayName: display_name ?? username,
135dfe565 attestationType: "none",
135dfe566 authenticatorSelection: {
135dfe567 residentKey: "preferred",
135dfe568 userVerification: "preferred",
135dfe569 },
135dfe570 });
135dfe571
135dfe572 challenges.set(options.challenge, {
135dfe573 username,
135dfe574 displayName: display_name ?? username,
135dfe575 expiresAt: Date.now() + CHALLENGE_TTL,
135dfe576 });
135dfe577
135dfe578 return { options };
135dfe579 });
135dfe580
135dfe581 // ── Registration Step 2: Verify attestation ──────────────────────
135dfe582
135dfe583 app.post("/register/complete", async (request, reply) => {
135dfe584 const body = request.body as {
135dfe585 response: RegistrationResponseJSON;
135dfe586 challenge: string;
135dfe587 };
135dfe588
135dfe589 const stored = challenges.get(body.challenge);
135dfe590 if (!stored || stored.expiresAt < Date.now()) {
135dfe591 return reply.code(400).send({ error: "Challenge expired or invalid" });
135dfe592 }
135dfe593
135dfe594 try {
135dfe595 const verification = await verifyRegistrationResponse({
135dfe596 response: body.response,
135dfe597 expectedChallenge: body.challenge,
a33b2b698 expectedOrigin: EXPECTED_ORIGIN,
135dfe599 expectedRPID: RP_ID,
135dfe5100 });
135dfe5101
135dfe5102 if (!verification.verified || !verification.registrationInfo) {
135dfe5103 return reply.code(400).send({ error: "Registration verification failed" });
135dfe5104 }
135dfe5105
135dfe5106 const { credential, credentialDeviceType, credentialBackedUp } =
135dfe5107 verification.registrationInfo;
135dfe5108
135dfe5109 const userResult = db
135dfe5110 .prepare("INSERT INTO users (username, display_name) VALUES (?, ?)")
135dfe5111 .run(stored.username, stored.displayName);
135dfe5112
135dfe5113 const userId = userResult.lastInsertRowid;
135dfe5114
135dfe5115 db.prepare(`
135dfe5116 INSERT INTO credentials (id, user_id, public_key, counter, transports, device_type, backed_up)
135dfe5117 VALUES (?, ?, ?, ?, ?, ?, ?)
135dfe5118 `).run(
135dfe5119 credential.id,
135dfe5120 userId,
135dfe5121 Buffer.from(credential.publicKey),
135dfe5122 credential.counter,
135dfe5123 JSON.stringify(credential.transports ?? []),
135dfe5124 credentialDeviceType,
135dfe5125 credentialBackedUp ? 1 : 0
135dfe5126 );
135dfe5127
135dfe5128 challenges.delete(body.challenge);
135dfe5129
3c994d3130 const token = app.jwt.sign({
3c994d3131 id: Number(userId),
3c994d3132 username: stored.username!,
3c994d3133 display_name: stored.displayName,
3c994d3134 type: "session",
3c994d3135 });
135dfe5136
135dfe5137 return reply.code(201).send({
135dfe5138 token,
135dfe5139 user: {
135dfe5140 id: Number(userId),
135dfe5141 username: stored.username,
135dfe5142 display_name: stored.displayName,
135dfe5143 },
135dfe5144 });
135dfe5145 } catch (err: any) {
135dfe5146 return reply.code(400).send({ error: err.message });
135dfe5147 }
135dfe5148 });
135dfe5149
135dfe5150 // ── Login Step 1: Generate challenge ─────────────────────────────
135dfe5151
135dfe5152 app.post("/login/begin", async (request, reply) => {
135dfe5153 let allowCredentials: { id: string; transports?: AuthenticatorTransportFuture[] }[] | undefined;
135dfe5154
135dfe5155 const body = (request.body ?? {}) as { username?: string };
135dfe5156
135dfe5157 if (body.username) {
135dfe5158 const user = db
135dfe5159 .prepare("SELECT id FROM users WHERE username = ?")
135dfe5160 .get(body.username) as any;
135dfe5161
135dfe5162 if (!user) {
135dfe5163 return reply.code(404).send({ error: "User not found" });
135dfe5164 }
135dfe5165
135dfe5166 const creds = db
135dfe5167 .prepare("SELECT id, transports FROM credentials WHERE user_id = ?")
135dfe5168 .all(user.id) as any[];
135dfe5169
135dfe5170 allowCredentials = creds.map((c) => ({
135dfe5171 id: c.id,
135dfe5172 transports: JSON.parse(c.transports || "[]") as AuthenticatorTransportFuture[],
135dfe5173 }));
135dfe5174 }
135dfe5175
135dfe5176 const options = await generateAuthenticationOptions({
135dfe5177 rpID: RP_ID,
135dfe5178 allowCredentials,
135dfe5179 });
135dfe5180
135dfe5181 challenges.set(options.challenge, {
135dfe5182 username: body.username,
135dfe5183 expiresAt: Date.now() + CHALLENGE_TTL,
135dfe5184 });
135dfe5185
135dfe5186 return { options };
135dfe5187 });
135dfe5188
135dfe5189 // ── Login Step 2: Verify assertion ───────────────────────────────
135dfe5190
135dfe5191 app.post("/login/complete", async (request, reply) => {
135dfe5192 const body = request.body as {
135dfe5193 response: AuthenticationResponseJSON;
135dfe5194 challenge: string;
135dfe5195 };
135dfe5196
135dfe5197 const stored = challenges.get(body.challenge);
135dfe5198 if (!stored || stored.expiresAt < Date.now()) {
135dfe5199 return reply.code(400).send({ error: "Challenge expired or invalid" });
135dfe5200 }
135dfe5201
135dfe5202 const credentialId = body.response.id;
135dfe5203 const credRow = db
135dfe5204 .prepare(`
135dfe5205 SELECT c.*, u.username, u.display_name
135dfe5206 FROM credentials c
135dfe5207 JOIN users u ON c.user_id = u.id
135dfe5208 WHERE c.id = ?
135dfe5209 `)
135dfe5210 .get(credentialId) as any;
135dfe5211
135dfe5212 if (!credRow) {
135dfe5213 return reply.code(400).send({ error: "Unknown credential" });
135dfe5214 }
135dfe5215
135dfe5216 try {
135dfe5217 const verification = await verifyAuthenticationResponse({
135dfe5218 response: body.response,
135dfe5219 expectedChallenge: body.challenge,
a33b2b6220 expectedOrigin: EXPECTED_ORIGIN,
135dfe5221 expectedRPID: RP_ID,
135dfe5222 credential: {
135dfe5223 id: credRow.id,
135dfe5224 publicKey: new Uint8Array(credRow.public_key),
135dfe5225 counter: credRow.counter,
135dfe5226 transports: JSON.parse(credRow.transports || "[]"),
135dfe5227 },
135dfe5228 });
135dfe5229
135dfe5230 if (!verification.verified) {
135dfe5231 return reply.code(400).send({ error: "Authentication failed" });
135dfe5232 }
135dfe5233
135dfe5234 db.prepare("UPDATE credentials SET counter = ? WHERE id = ?")
135dfe5235 .run(verification.authenticationInfo.newCounter, credRow.id);
135dfe5236
135dfe5237 challenges.delete(body.challenge);
135dfe5238
135dfe5239 const token = app.jwt.sign({
135dfe5240 id: credRow.user_id,
135dfe5241 username: credRow.username,
3c994d3242 display_name: credRow.display_name,
3c994d3243 type: "session",
135dfe5244 });
135dfe5245
135dfe5246 return {
135dfe5247 token,
135dfe5248 user: {
135dfe5249 id: credRow.user_id,
135dfe5250 username: credRow.username,
135dfe5251 display_name: credRow.display_name,
135dfe5252 },
135dfe5253 };
135dfe5254 } catch (err: any) {
135dfe5255 return reply.code(400).send({ error: err.message });
135dfe5256 }
135dfe5257 });
135dfe5258
135dfe5259 // ── Get current user ─────────────────────────────────────────────
135dfe5260
135dfe5261 app.get("/me", {
135dfe5262 preHandler: [(app as any).authenticate],
135dfe5263 handler: async (request) => {
135dfe5264 const payload = request.user as any;
135dfe5265
135dfe5266 const user = db
135dfe5267 .prepare("SELECT id, username, display_name, created_at FROM users WHERE id = ?")
135dfe5268 .get(payload.id);
135dfe5269
135dfe5270 return { user };
135dfe5271 },
135dfe5272 });
3c994d3273
a9b2860274 // ── Refresh session token ────────────────────────────────────────
a9b2860275
a9b2860276 app.post("/refresh", {
a9b2860277 preHandler: [(app as any).authenticate],
a9b2860278 handler: async (request, reply) => {
a9b2860279 const payload = request.user as any;
a9b2860280
a9b2860281 if (payload.type !== "session") {
a9b2860282 return reply.code(403).send({ error: "Only session tokens can be refreshed" });
a9b2860283 }
a9b2860284
a9b2860285 const user = db
a9b2860286 .prepare("SELECT id, username, display_name FROM users WHERE id = ?")
a9b2860287 .get(payload.id) as any;
a9b2860288
a9b2860289 if (!user) {
a9b2860290 return reply.code(401).send({ error: "User not found" });
a9b2860291 }
a9b2860292
a9b2860293 const token = app.jwt.sign({
a9b2860294 id: user.id,
a9b2860295 username: user.username,
a9b2860296 display_name: user.display_name,
a9b2860297 type: "session",
a9b2860298 });
a9b2860299
a9b2860300 return { token, user: { id: user.id, username: user.username, display_name: user.display_name } };
a9b2860301 },
a9b2860302 });
a9b2860303
3c994d3304 // ── Personal Access Tokens (PATs) ─────────────────────────────────
3c994d3305
3c994d3306 const createTokenSchema = z.object({
3c994d3307 name: z.string().min(1).max(100),
3c994d3308 expires_in: z.enum(["30d", "90d", "1y"]).default("1y"),
3c994d3309 });
3c994d3310
3c994d3311 const EXPIRY_MAP: Record<string, string> = {
3c994d3312 "30d": "30d",
3c994d3313 "90d": "90d",
3c994d3314 "1y": "365d",
3c994d3315 };
3c994d3316
3c994d3317 app.post("/tokens", {
3c994d3318 preHandler: [(app as any).authenticate],
3c994d3319 handler: async (request, reply) => {
3c994d3320 const parsed = createTokenSchema.safeParse(request.body);
3c994d3321 if (!parsed.success) {
3c994d3322 return reply.code(400).send({ error: parsed.error.flatten() });
3c994d3323 }
3c994d3324
3c994d3325 const payload = request.user as any;
3c994d3326 const { name, expires_in } = parsed.data;
3c994d3327
3c994d3328 const user = db
3c994d3329 .prepare("SELECT id, username, display_name FROM users WHERE id = ?")
3c994d3330 .get(payload.id) as any;
3c994d3331
3c994d3332 if (!user) {
3c994d3333 return reply.code(404).send({ error: "User not found" });
3c994d3334 }
3c994d3335
3c994d3336 const token = app.jwt.sign(
3c994d3337 {
3c994d3338 id: user.id,
3c994d3339 username: user.username,
3c994d3340 display_name: user.display_name,
3c994d3341 type: "pat",
3c994d3342 },
3c994d3343 { expiresIn: EXPIRY_MAP[expires_in] }
3c994d3344 );
3c994d3345
3c994d3346 const tokenHash = createHash("sha256").update(token).digest("hex");
3c994d3347 const expiresAt = new Date(
3c994d3348 Date.now() + parseInt(EXPIRY_MAP[expires_in]) * 24 * 60 * 60 * 1000
3c994d3349 ).toISOString();
3c994d3350
3c994d3351 const result = db
3c994d3352 .prepare(
3c994d3353 "INSERT INTO api_tokens (user_id, name, token_hash, expires_at) VALUES (?, ?, ?, ?)"
3c994d3354 )
3c994d3355 .run(user.id, name, tokenHash, expiresAt);
3c994d3356
3c994d3357 return reply.code(201).send({
3c994d3358 token,
3c994d3359 api_token: {
3c994d3360 id: result.lastInsertRowid,
3c994d3361 name,
3c994d3362 expires_at: expiresAt,
3c994d3363 created_at: new Date().toISOString(),
3c994d3364 },
3c994d3365 });
3c994d3366 },
3c994d3367 });
3c994d3368
3c994d3369 app.get("/tokens", {
3c994d3370 preHandler: [(app as any).authenticate],
3c994d3371 handler: async (request) => {
3c994d3372 const payload = request.user as any;
3c994d3373
3c994d3374 const tokens = db
3c994d3375 .prepare(
3c994d3376 "SELECT id, name, expires_at, last_used_at, created_at FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC"
3c994d3377 )
3c994d3378 .all(payload.id);
3c994d3379
3c994d3380 return { tokens };
3c994d3381 },
3c994d3382 });
3c994d3383
3c994d3384 app.delete("/tokens/:id", {
3c994d3385 preHandler: [(app as any).authenticate],
3c994d3386 handler: async (request, reply) => {
3c994d3387 const payload = request.user as any;
3c994d3388 const { id } = request.params as { id: string };
3c994d3389
3c994d3390 const result = db
3c994d3391 .prepare("DELETE FROM api_tokens WHERE id = ? AND user_id = ?")
3c994d3392 .run(id, payload.id);
3c994d3393
3c994d3394 if (result.changes === 0) {
3c994d3395 return reply.code(404).send({ error: "Token not found" });
3c994d3396 }
3c994d3397
3c994d3398 return reply.code(204).send();
3c994d3399 },
3c994d3400 });
135dfe5401}