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
7010ba9259 // ── Device Code Flow (for headless/remote CLI auth) ──────────────
7010ba9260
7010ba9261 const deviceCodes = new Map<
7010ba9262 string,
7010ba9263 { expiresAt: number; token?: string; status: "pending" | "complete" }
7010ba9264 >();
7010ba9265
7010ba9266 // Cleanup expired device codes
7010ba9267 setInterval(() => {
7010ba9268 const now = Date.now();
7010ba9269 for (const [key, val] of deviceCodes) {
7010ba9270 if (val.expiresAt < now) deviceCodes.delete(key);
7010ba9271 }
7010ba9272 }, 60 * 1000);
7010ba9273
7010ba9274 function generateCode(): string {
7010ba9275 const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
7010ba9276 let code = "";
7010ba9277 for (let i = 0; i < 8; i++) {
7010ba9278 if (i === 4) code += "-";
7010ba9279 code += chars[Math.floor(Math.random() * chars.length)];
7010ba9280 }
7010ba9281 return code;
7010ba9282 }
7010ba9283
7010ba9284 // CLI calls this to start device code flow
7010ba9285 app.post("/device-code", async () => {
7010ba9286 const code = generateCode();
7010ba9287 deviceCodes.set(code, {
7010ba9288 expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
7010ba9289 status: "pending",
7010ba9290 });
7010ba9291 const origin = ORIGIN_ENV.split(",")[0].trim();
7010ba9292 return { code, url: `${origin}/cli-auth?code=${code}`, expires_in: 600 };
7010ba9293 });
7010ba9294
7010ba9295 // CLI polls this to check if user approved
7010ba9296 app.get("/device-code/:code", async (request, reply) => {
7010ba9297 const { code } = request.params as { code: string };
7010ba9298 const entry = deviceCodes.get(code);
7010ba9299
7010ba9300 if (!entry || entry.expiresAt < Date.now()) {
7010ba9301 return reply.code(404).send({ error: "Code not found or expired" });
7010ba9302 }
7010ba9303
7010ba9304 if (entry.status === "complete" && entry.token) {
7010ba9305 deviceCodes.delete(code);
7010ba9306 return { status: "complete", token: entry.token };
7010ba9307 }
7010ba9308
7010ba9309 return { status: "pending" };
7010ba9310 });
7010ba9311
7010ba9312 // Web page calls this (authenticated) to approve the device code
7010ba9313 app.post("/device-code/:code/approve", {
7010ba9314 preHandler: [(app as any).authenticate],
7010ba9315 handler: async (request, reply) => {
7010ba9316 const { code } = request.params as { code: string };
7010ba9317 const entry = deviceCodes.get(code);
7010ba9318
7010ba9319 if (!entry || entry.expiresAt < Date.now()) {
7010ba9320 return reply.code(404).send({ error: "Code not found or expired" });
7010ba9321 }
7010ba9322
7010ba9323 if (entry.status === "complete") {
7010ba9324 return reply.code(400).send({ error: "Code already used" });
7010ba9325 }
7010ba9326
7010ba9327 const payload = request.user as any;
7010ba9328 const user = db
7010ba9329 .prepare("SELECT id, username, display_name FROM users WHERE id = ?")
7010ba9330 .get(payload.id) as any;
7010ba9331
7010ba9332 if (!user) {
7010ba9333 return reply.code(404).send({ error: "User not found" });
7010ba9334 }
7010ba9335
7010ba9336 // Create a PAT
7010ba9337 const token = app.jwt.sign(
7010ba9338 {
7010ba9339 id: user.id,
7010ba9340 username: user.username,
7010ba9341 display_name: user.display_name,
7010ba9342 type: "pat",
7010ba9343 },
7010ba9344 { expiresIn: "365d" }
7010ba9345 );
7010ba9346
7010ba9347 const tokenHash = createHash("sha256").update(token).digest("hex");
7010ba9348 const expiresAt = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
7010ba9349
7010ba9350 db.prepare(
7010ba9351 "INSERT INTO api_tokens (user_id, name, token_hash, expires_at) VALUES (?, ?, ?, ?)"
7010ba9352 ).run(user.id, `CLI (${new Date().toLocaleDateString()})`, tokenHash, expiresAt);
7010ba9353
7010ba9354 entry.status = "complete";
7010ba9355 entry.token = token;
7010ba9356
7010ba9357 return { status: "approved" };
7010ba9358 },
7010ba9359 });
7010ba9360
fafa260361 // ── TLS client certificates (for Sapling mTLS with Mononoke) ────
fafa260362
fafa260363 const TLS_DIR = process.env.TLS_DIR ?? "/data/grove/tls";
fafa260364
fafa260365 app.get("/tls-certs", {
fafa260366 preHandler: [(app as any).authenticate],
fafa260367 handler: async (_request, reply) => {
fafa260368 const { readFileSync, existsSync } = await import("fs");
fafa260369 const { join } = await import("path");
fafa260370
fafa260371 const caPath = join(TLS_DIR, "ca.crt");
fafa260372 const certPath = join(TLS_DIR, "client.crt");
fafa260373 const keyPath = join(TLS_DIR, "client.key");
fafa260374
fafa260375 if (!existsSync(caPath) || !existsSync(certPath) || !existsSync(keyPath)) {
fafa260376 return reply.code(503).send({ error: "TLS certificates not configured on this instance" });
fafa260377 }
fafa260378
fafa260379 return {
fafa260380 ca: readFileSync(caPath, "utf-8"),
fafa260381 cert: readFileSync(certPath, "utf-8"),
fafa260382 key: readFileSync(keyPath, "utf-8"),
fafa260383 };
fafa260384 },
fafa260385 });
fafa260386
135dfe5387 // ── Get current user ─────────────────────────────────────────────
135dfe5388
135dfe5389 app.get("/me", {
135dfe5390 preHandler: [(app as any).authenticate],
135dfe5391 handler: async (request) => {
135dfe5392 const payload = request.user as any;
135dfe5393
135dfe5394 const user = db
135dfe5395 .prepare("SELECT id, username, display_name, created_at FROM users WHERE id = ?")
135dfe5396 .get(payload.id);
135dfe5397
135dfe5398 return { user };
135dfe5399 },
135dfe5400 });
3c994d3401
a9b2860402 // ── Refresh session token ────────────────────────────────────────
a9b2860403
a9b2860404 app.post("/refresh", {
a9b2860405 preHandler: [(app as any).authenticate],
a9b2860406 handler: async (request, reply) => {
a9b2860407 const payload = request.user as any;
a9b2860408
a9b2860409 if (payload.type !== "session") {
a9b2860410 return reply.code(403).send({ error: "Only session tokens can be refreshed" });
a9b2860411 }
a9b2860412
a9b2860413 const user = db
a9b2860414 .prepare("SELECT id, username, display_name FROM users WHERE id = ?")
a9b2860415 .get(payload.id) as any;
a9b2860416
a9b2860417 if (!user) {
a9b2860418 return reply.code(401).send({ error: "User not found" });
a9b2860419 }
a9b2860420
a9b2860421 const token = app.jwt.sign({
a9b2860422 id: user.id,
a9b2860423 username: user.username,
a9b2860424 display_name: user.display_name,
a9b2860425 type: "session",
a9b2860426 });
a9b2860427
a9b2860428 return { token, user: { id: user.id, username: user.username, display_name: user.display_name } };
a9b2860429 },
a9b2860430 });
a9b2860431
3c994d3432 // ── Personal Access Tokens (PATs) ─────────────────────────────────
3c994d3433
3c994d3434 const createTokenSchema = z.object({
3c994d3435 name: z.string().min(1).max(100),
3c994d3436 expires_in: z.enum(["30d", "90d", "1y"]).default("1y"),
3c994d3437 });
3c994d3438
3c994d3439 const EXPIRY_MAP: Record<string, string> = {
3c994d3440 "30d": "30d",
3c994d3441 "90d": "90d",
3c994d3442 "1y": "365d",
3c994d3443 };
3c994d3444
3c994d3445 app.post("/tokens", {
3c994d3446 preHandler: [(app as any).authenticate],
3c994d3447 handler: async (request, reply) => {
3c994d3448 const parsed = createTokenSchema.safeParse(request.body);
3c994d3449 if (!parsed.success) {
3c994d3450 return reply.code(400).send({ error: parsed.error.flatten() });
3c994d3451 }
3c994d3452
3c994d3453 const payload = request.user as any;
3c994d3454 const { name, expires_in } = parsed.data;
3c994d3455
3c994d3456 const user = db
3c994d3457 .prepare("SELECT id, username, display_name FROM users WHERE id = ?")
3c994d3458 .get(payload.id) as any;
3c994d3459
3c994d3460 if (!user) {
3c994d3461 return reply.code(404).send({ error: "User not found" });
3c994d3462 }
3c994d3463
3c994d3464 const token = app.jwt.sign(
3c994d3465 {
3c994d3466 id: user.id,
3c994d3467 username: user.username,
3c994d3468 display_name: user.display_name,
3c994d3469 type: "pat",
3c994d3470 },
3c994d3471 { expiresIn: EXPIRY_MAP[expires_in] }
3c994d3472 );
3c994d3473
3c994d3474 const tokenHash = createHash("sha256").update(token).digest("hex");
3c994d3475 const expiresAt = new Date(
3c994d3476 Date.now() + parseInt(EXPIRY_MAP[expires_in]) * 24 * 60 * 60 * 1000
3c994d3477 ).toISOString();
3c994d3478
3c994d3479 const result = db
3c994d3480 .prepare(
3c994d3481 "INSERT INTO api_tokens (user_id, name, token_hash, expires_at) VALUES (?, ?, ?, ?)"
3c994d3482 )
3c994d3483 .run(user.id, name, tokenHash, expiresAt);
3c994d3484
3c994d3485 return reply.code(201).send({
3c994d3486 token,
3c994d3487 api_token: {
3c994d3488 id: result.lastInsertRowid,
3c994d3489 name,
3c994d3490 expires_at: expiresAt,
3c994d3491 created_at: new Date().toISOString(),
3c994d3492 },
3c994d3493 });
3c994d3494 },
3c994d3495 });
3c994d3496
3c994d3497 app.get("/tokens", {
3c994d3498 preHandler: [(app as any).authenticate],
3c994d3499 handler: async (request) => {
3c994d3500 const payload = request.user as any;
3c994d3501
3c994d3502 const tokens = db
3c994d3503 .prepare(
3c994d3504 "SELECT id, name, expires_at, last_used_at, created_at FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC"
3c994d3505 )
3c994d3506 .all(payload.id);
3c994d3507
3c994d3508 return { tokens };
3c994d3509 },
3c994d3510 });
3c994d3511
3c994d3512 app.delete("/tokens/:id", {
3c994d3513 preHandler: [(app as any).authenticate],
3c994d3514 handler: async (request, reply) => {
3c994d3515 const payload = request.user as any;
3c994d3516 const { id } = request.params as { id: string };
3c994d3517
3c994d3518 const result = db
3c994d3519 .prepare("DELETE FROM api_tokens WHERE id = ? AND user_id = ?")
3c994d3520 .run(id, payload.id);
3c994d3521
3c994d3522 if (result.changes === 0) {
3c994d3523 return reply.code(404).send({ error: "Token not found" });
3c994d3524 }
3c994d3525
3c994d3526 return reply.code(204).send();
3c994d3527 },
3c994d3528 });
135dfe5529}