From 59bd09a9e41307b42c82378d625c2011863d1212 Mon Sep 17 00:00:00 2001 From: Devinder Singh Date: Tue, 3 Nov 2026 12:01:54 +0100 Subject: [PATCH] feat: API keys and GET /v1/me behind one auth hook (step 3.1) --- api/src/app.test.ts | 10 ++- api/src/app.ts | 16 +++- api/src/auth/api-key.test.ts | 42 +++++++++++ api/src/auth/api-key.ts | 31 ++++++++ api/src/auth/authenticate.ts | 30 ++++++++ api/src/cli/create-owner.ts | 35 +++++++++ api/src/error-handler.test.ts | 63 +++++++++++++++- api/src/error-handler.ts | 20 ++++- api/src/errors.ts | 5 +- api/src/repositories/owners.ts | 28 +++++++ api/src/routes/me.ts | 12 +++ api/src/routes/v1.ts | 11 +++ api/src/schemas/owners.test.ts | 14 ++++ api/src/schemas/owners.ts | 7 ++ api/src/server.ts | 2 +- api/src/services/owners.ts | 14 ++++ api/test/integration/auth-cli.test.ts | 66 +++++++++++++++++ api/test/integration/auth-socket.ts | 20 +++++ api/test/integration/auth.test.ts | 101 ++++++++++++++++++++++++++ docs/api.md | 4 +- 20 files changed, 517 insertions(+), 14 deletions(-) create mode 100644 api/src/auth/api-key.test.ts create mode 100644 api/src/auth/api-key.ts create mode 100644 api/src/auth/authenticate.ts create mode 100644 api/src/cli/create-owner.ts create mode 100644 api/src/repositories/owners.ts create mode 100644 api/src/routes/me.ts create mode 100644 api/src/routes/v1.ts create mode 100644 api/src/schemas/owners.test.ts create mode 100644 api/src/schemas/owners.ts create mode 100644 api/src/services/owners.ts create mode 100644 api/test/integration/auth-cli.test.ts create mode 100644 api/test/integration/auth-socket.ts create mode 100644 api/test/integration/auth.test.ts diff --git a/api/src/app.test.ts b/api/src/app.test.ts index 3382718..fb9459a 100644 --- a/api/src/app.test.ts +++ b/api/src/app.test.ts @@ -1,13 +1,19 @@ +// Checks the app through HTTP injection without connecting to a database. import { afterAll, describe, expect, test } from "bun:test"; import { buildApp } from "./app.ts"; import { loadConfig } from "./config.ts"; +import { createDb } from "./db.ts"; // A config made from a fixed object, so the test never depends on this machine's environment. // DATABASE_URL is required but harmless here: these tests never connect to it. -const app = buildApp(loadConfig({ GIT_SHA: "abc123", DATABASE_URL: "postgres://test@localhost/test" }), { +const sql = createDb(loadConfig({ DATABASE_URL: "postgres://relay:x@127.0.0.1:1/relay" })); +const app = buildApp(loadConfig({ GIT_SHA: "abc123", DATABASE_URL: "postgres://test@localhost/test" }), { sql }, { logger: false, }); -afterAll(() => app.close()); +afterAll(async () => { + await app.close(); + await sql.end({ timeout: 5 }); +}); describe("health routes", () => { test("GET /health/live answers ok", async () => { diff --git a/api/src/app.ts b/api/src/app.ts index cda8204..06047c5 100644 --- a/api/src/app.ts +++ b/api/src/app.ts @@ -2,14 +2,20 @@ // It never listens on a port itself. server.ts does that, so tests can call // buildApp() and send fake requests with app.inject(). import Fastify, { LogController, type FastifyInstance } from "fastify"; +import type { Sql } from "./db.ts"; +import { v1Routes } from "./routes/v1.ts"; import type { Config } from "./config.ts"; import { errorHandler, notFoundHandler } from "./error-handler.ts"; import { healthRoutes } from "./routes/health.ts"; -export function buildApp(config: Config, options: { logger?: boolean } = {}): FastifyInstance { +export function buildApp( + config: Config, + deps: { sql: Sql }, + options: { logger?: boolean; logStream?: { write(line: string): void } } = {}, +): FastifyInstance { const app = Fastify({ // JSON logs (pino). Tests pass logger: false to keep their output clean. - logger: options.logger === false ? false : { level: config.LOG_LEVEL }, + logger: options.logger === false ? false : { level: config.LOG_LEVEL, stream: options.logStream }, bodyLimit: 256 * 1024, // 256 KiB, bigger bodies get 413 genReqId: () => crypto.randomUUID(), // a unique id for every request logController: new LogController({ @@ -20,6 +26,10 @@ export function buildApp(config: Config, options: { logger?: boolean } = {}): Fa }), }); + // Step 1: accept JSON bodies and declare the per-request identity. + app.removeContentTypeParser("text/plain"); + app.decorateRequest("owner", null); + // Every response tells the client its request id: the same id as in our logs, // so "it failed" plus the id is enough to find the exact log lines. app.addHook("onRequest", async (request, reply) => { @@ -30,7 +40,9 @@ export function buildApp(config: Config, options: { logger?: boolean } = {}): Fa app.setErrorHandler(errorHandler); app.setNotFoundHandler(notFoundHandler); + // Step 2: only the v1 plugin inherits the authentication hook. app.register(healthRoutes, { config }); + app.register(v1Routes, { prefix: "/v1", sql: deps.sql }); return app; } diff --git a/api/src/auth/api-key.test.ts b/api/src/auth/api-key.test.ts new file mode 100644 index 0000000..d214f14 --- /dev/null +++ b/api/src/auth/api-key.test.ts @@ -0,0 +1,42 @@ +// Checks key generation and defines the header parser's required behavior. +import { describe, expect, test } from "bun:test"; +import { generateApiKey, hashApiKey, parseBearerKey } from "./api-key.ts"; + +const key = "relay_live_" + "a".repeat(43); + +// Step 1: schemes are case-insensitive, but the separator is one space. +describe("parseBearerKey", () => { + for (const scheme of ["Bearer", "bearer", "BEARER"]) { + test(`${scheme} with a valid key returns the key`, () => { + expect(parseBearerKey(`${scheme} ${key}`)).toBe(key); + }); + } + const invalid: [string, string | undefined][] = [ + ["undefined", undefined], ["empty", ""], ["Basic", "Basic abc"], ["scheme alone", "Bearer"], + ["two spaces", `Bearer ${key}`], ["tab", `Bearer\t${key}`], ["trailing space", `Bearer ${key} `], + ["test prefix", "Bearer relay_test_" + "a".repeat(43)], + ["42 characters", "Bearer relay_live_" + "a".repeat(42)], + ["44 characters", "Bearer relay_live_" + "a".repeat(44)], + ...["+", "/", "="].map((character): [string, string] => + [character, "Bearer relay_live_" + "a".repeat(42) + character]), + ["combined headers", `Bearer ${key}, Bearer ${key}`], + ]; + for (const [name, header] of invalid) { + test(`rejects ${name}`, () => expect(parseBearerKey(header)).toBeNull()); + } +}); + +// Step 2: randomness, public prefix and database hash describe the same key. +describe("generateApiKey", () => { + test("has the exact key format and a safe 15-character prefix", () => { + const generated = generateApiKey(); + expect(generated.key).toMatch(/^relay_live_[A-Za-z0-9_-]{43}$/); + expect(generated.prefix).toBe(generated.key.slice(0, 15)); + }); + test("two generated keys differ", () => expect(generateApiKey().key).not.toBe(generateApiKey().key)); + test("hash matches SHA-256 of the key and has 32 bytes", () => { + const generated = generateApiKey(); + expect(generated.hash).toEqual(hashApiKey(generated.key)); + expect(generated.hash.length).toBe(32); + }); +}); diff --git a/api/src/auth/api-key.ts b/api/src/auth/api-key.ts new file mode 100644 index 0000000..26deec2 --- /dev/null +++ b/api/src/auth/api-key.ts @@ -0,0 +1,31 @@ +// Generates API keys and hashes them without storing their secret text. +import { createHash, randomBytes } from "node:crypto"; + +// Step 1: the public format and the one-way hash stored in Postgres. +export const KEY_PREFIX = "relay_live_"; +export const KEY_RANDOM_LENGTH = 43; + +export function hashApiKey(key: string): Buffer { + return createHash("sha256").update(key, "utf8").digest(); +} + +export function generateApiKey(): { key: string; prefix: string; hash: Buffer } { + const random = randomBytes(32).toString("base64url"); + const key = KEY_PREFIX + random; + return { key, prefix: KEY_PREFIX + random.slice(0, 4), hash: hashApiKey(key) }; +} + +// Step 2: check the header before any database query. +// The word "Bearer" is case-insensitive (RFC 9110), so "bearer" and "BEARER" work too. +// The key after exactly one space is strict: KEY_PREFIX + KEY_RANDOM_LENGTH base64url characters. +// Every failure returns the same null (and so the same 401), so nobody learns which part was wrong. +const SCHEME = "bearer "; +const KEY_REGEX = new RegExp(`^${KEY_PREFIX}[A-Za-z0-9_-]{${KEY_RANDOM_LENGTH}}$`); + +export function parseBearerKey(header: string | undefined): string | null { + if (typeof header !== "string") return null; + if (header.slice(0, SCHEME.length).toLowerCase() !== SCHEME) return null; + const key = header.slice(SCHEME.length); + return KEY_REGEX.test(key) ? key : null; +} + diff --git a/api/src/auth/authenticate.ts b/api/src/auth/authenticate.ts new file mode 100644 index 0000000..6a34c79 --- /dev/null +++ b/api/src/auth/authenticate.ts @@ -0,0 +1,30 @@ +// Finds the owner of a single active API key before a protected route runs. +import type { onRequestAsyncHookHandler } from "fastify"; +import type { Sql } from "../db.ts"; +import { unauthorized } from "../errors.ts"; +import { findOwnerByKeyHash } from "../repositories/owners.ts"; +import { hashApiKey, parseBearerKey } from "./api-key.ts"; + +declare module "fastify" { + interface FastifyRequest { + owner: { id: string; name: string; keyPrefix: string } | null; + } +} + +// Step 1: reject duplicate headers before Bun can hide the second value. +export function authenticate(sql: Sql): onRequestAsyncHookHandler { + return async (request) => { + let count = 0; + for (let i = 0; i < request.raw.rawHeaders.length; i += 2) { + if (request.raw.rawHeaders[i]?.toLowerCase() === "authorization") count++; + } + if (count > 1) throw unauthorized(); + const key = parseBearerKey(request.headers.authorization); + if (key === null) throw unauthorized(); + + // Step 2: database failures propagate to the shared error handler. + const owner = await findOwnerByKeyHash(sql, hashApiKey(key)); + if (owner === null) throw unauthorized(); + request.owner = { id: owner.ownerId, name: owner.ownerName, keyPrefix: owner.keyPrefix }; + }; +} diff --git a/api/src/cli/create-owner.ts b/api/src/cli/create-owner.ts new file mode 100644 index 0000000..ab6300a --- /dev/null +++ b/api/src/cli/create-owner.ts @@ -0,0 +1,35 @@ +// Creates an owner and prints its API key once, without using a logger. +// Usage: bun src/cli/create-owner.ts --name "Acme" +// Production: kubectl -n relay exec deploy/relay-api -- bun src/cli/create-owner.ts --name "Acme" +import { loadConfig } from "../config.ts"; +import { createDb, type Sql } from "../db.ts"; +import { OwnerName } from "../schemas/owners.ts"; +import { createOwnerWithKey } from "../services/owners.ts"; + +// Step 1: validate arguments before making a pool or writing anything. +let sql: Sql | undefined; +try { + const args = process.argv.slice(2); + if (args.length !== 2 || args[0] !== "--name") throw new Error("Usage: --name "); + const parsed = OwnerName.safeParse(args[1]); + if (!parsed.success) throw new Error("Name must be 1–100 characters without control characters"); + sql = createDb(loadConfig()); + const { owner, key } = await createOwnerWithKey(sql, parsed.data); + + // Step 2: stdout is the only place the full key is ever printed. + console.log(`✅ Owner created: ${owner.name} (${owner.id})`); + console.log(`API key (shown once, store it now in a password manager): ${key}`); +} catch (error) { + // Never print database errors: detail and where may contain secret row values. + const reason = sql ? "Could not create owner" : error instanceof Error ? error.message : "Could not create owner"; + console.error(`❌ ${reason}`); + process.exitCode = 1; +} finally { + // Step 3: close even after a failed insert, without leaking close-error details. + try { + await sql?.end({ timeout: 5 }); + } catch { + console.error("❌ Could not close database connections"); + process.exitCode = 1; + } +} diff --git a/api/src/error-handler.test.ts b/api/src/error-handler.test.ts index 2be6e43..167e4c7 100644 --- a/api/src/error-handler.test.ts +++ b/api/src/error-handler.test.ts @@ -1,11 +1,14 @@ +// Checks the app through HTTP injection without connecting to a database. import { afterAll, describe, expect, test } from "bun:test"; import { z } from "zod"; import { buildApp } from "./app.ts"; import { loadConfig } from "./config.ts"; -import { conflict } from "./errors.ts"; +import { createDb } from "./db.ts"; +import { AppError, conflict } from "./errors.ts"; // A harmless database URL: these tests never connect to it. -const app = buildApp(loadConfig({ DATABASE_URL: "postgres://test@localhost/test" }), { logger: false }); +const sql = createDb(loadConfig({ DATABASE_URL: "postgres://relay:x@127.0.0.1:1/relay" })); +const app = buildApp(loadConfig({ DATABASE_URL: "postgres://test@localhost/test" }), { sql }, { logger: false }); // Test-only routes that fail on purpose, one for each kind of error. app.get("/test/zod", async () => z.object({ name: z.string() }).parse({})); @@ -24,7 +27,15 @@ app.get("/test/db-bug", async () => { throw Object.assign(new Error('relation "nope" does not exist'), { code: "42P01" }); }); -afterAll(() => app.close()); +// Step 1: details remain public only when deliberately supplied by application code. +app.get("/test/details", async () => { + throw new AppError(422, "validation_failed", "Invalid input", [{ path: "name", message: "Required" }]); +}); + +afterAll(async () => { + await app.close(); + await sql.end({ timeout: 5 }); +}); describe("error handler", () => { test("unknown route → 404 not_found, in our error shape", async () => { @@ -102,3 +113,49 @@ describe("error handler", () => { expect(res.json().error.code).toBe("internal_error"); }); }); + +// Step 2: the default text parser must not bypass the JSON-only contract. +for (const contentType of ["text/plain", undefined]) { + test(`echo rejects ${contentType ?? "missing content type"}`, async () => { + const res = await app.inject({ + method: "POST", url: "/test/echo", payload: "hello", + headers: contentType ? { "content-type": contentType } : {}, + }); + expect(res.statusCode).toBe(415); + expect(res.json().error.code).toBe("unsupported_media_type"); + }); +} +test("JSON with a charset passes", async () => { + const res = await app.inject({ method: "POST", url: "/test/echo", + headers: { "content-type": "application/json; charset=utf-8" }, payload: '{"ok":true}' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ ok: true }); +}); +test("AppError carries deliberately supplied details", async () => { + const res = await app.inject({ method: "GET", url: "/test/details" }); + expect(res.statusCode).toBe(422); + expect(res.json().error.details).toEqual([{ path: "name", message: "Required" }]); +}); + +// Step 3: a database row or JSON payload must never reach the log stream. +test("Postgres error logs contain the code without secret detail or where", async () => { + const lines: string[] = []; + const logged = buildApp(loadConfig({ DATABASE_URL: "postgres://relay:x@127.0.0.1:1/relay" }), { sql }, + { logStream: { write: (line) => { lines.push(line); } } }); + logged.get("/test/pg-secret", async () => { + throw Object.assign(new Error("CHECK failed"), { + name: "PostgresError", code: "23514", severity: "ERROR", + detail: "Failing row contains (MARKER_SECRET_123)", where: "payload MARKER_SECRET_123", + hint: "MARKER_SECRET_123", internal_query: "MARKER_SECRET_123", + }); + }); + try { + const res = await logged.inject({ method: "GET", url: "/test/pg-secret" }); + expect(res.statusCode).toBe(500); + expect(res.json().error.code).toBe("internal_error"); + expect(lines.join("")).toContain("23514"); + expect(lines.join("")).not.toContain("MARKER_SECRET_123"); + } finally { + await logged.close(); + } +}); diff --git a/api/src/error-handler.ts b/api/src/error-handler.ts index 5e9a469..b084beb 100644 --- a/api/src/error-handler.ts +++ b/api/src/error-handler.ts @@ -25,6 +25,20 @@ function send( return reply.code(status).send({ error }); } +// Step 1: allow only safe database metadata into logs. On Postgres 18.6 a CHECK +// failure's detail holds the whole row, including signing secrets; JSON errors can +// put the payload in where. Never log detail, where, hint or internal_query. +function logSafe(error: FastifyError) { + if (error.name !== "PostgresError") return error; + const dbError = error as FastifyError & Record; + return { + type: "PostgresError", code: dbError.code, message: dbError.message, + severity: dbError.severity, table: dbError.table_name, + constraint: dbError.constraint_name, routine: dbError.routine, + }; +} + +// Step 2: translate errors once, without exposing internal database details. export function errorHandler(error: FastifyError, request: FastifyRequest, reply: FastifyReply) { if (error instanceof ZodError) { @@ -33,13 +47,13 @@ export function errorHandler(error: FastifyError, request: FastifyRequest, reply } if (error instanceof AppError) { - return send(reply, request, error.status, error.code, error.message); + return send(reply, request, error.status, error.code, error.message, error.details); } // The database can't be reached right now (a restart, a short blip). The pod stays up and // ready; only this request fails, with a clear "try again in 5 s" instead of a vague 500. if (isDatabaseUnavailable(error)) { - request.log.warn({ err: error }, "⚠️ database unavailable, answered 503"); + request.log.warn({ err: logSafe(error) }, "⚠️ database unavailable, answered 503"); reply.header("retry-after", "5"); return send(reply, request, 503, "service_unavailable", "The service is briefly unavailable, try again shortly"); } @@ -49,7 +63,7 @@ export function errorHandler(error: FastifyError, request: FastifyRequest, reply return send(reply, request, error.statusCode, code, error.message); } - request.log.error(error); + request.log.error(logSafe(error), "❌ unexpected error, answered 500"); return send(reply, request, 500, "internal_error", "Something went wrong on our side"); } diff --git a/api/src/errors.ts b/api/src/errors.ts index eb2f53b..b0992aa 100644 --- a/api/src/errors.ts +++ b/api/src/errors.ts @@ -4,7 +4,7 @@ export class AppError extends Error { readonly status: number; readonly code: string; - constructor(status: number, code: string, message: string) { + constructor(status: number, code: string, message: string, readonly details?: { path: string; message: string }[]) { super(message); this.name = "AppError"; this.status = status; @@ -15,3 +15,6 @@ export class AppError extends Error { export const notFound = (what: string) => new AppError(404, "not_found", `${what} not found`); export const conflict = (code: string, message: string) => new AppError(409, code, message); + +// Step 1: every authentication failure has the same public answer. +export const unauthorized = () => new AppError(401, "unauthorized", "A valid API key is required"); diff --git a/api/src/repositories/owners.ts b/api/src/repositories/owners.ts new file mode 100644 index 0000000..15cf5b1 --- /dev/null +++ b/api/src/repositories/owners.ts @@ -0,0 +1,28 @@ +// Reads and writes owners and their hashed API keys using parameterized SQL. +import type postgres from "postgres"; +import type { Sql } from "../db.ts"; + +type Db = Sql | postgres.TransactionSql; +type Owner = { id: string; name: string }; +type KeyOwner = { ownerId: string; ownerName: string; keyPrefix: string }; + +// Step 1: writes accept a transaction handle so owner and key are saved together. +export async function insertOwner(db: Db, name: string): Promise { + const [owner] = await db`INSERT INTO owners (name) VALUES (${name}) RETURNING id, name`; + if (!owner) throw new Error("Owner insert returned no row"); + return owner; +} + +export async function insertApiKey(db: Db, key: { ownerId: string; prefix: string; keyHash: Buffer }): Promise { + await db`INSERT INTO api_keys (owner_id, prefix, key_hash) + VALUES (${key.ownerId}, ${key.prefix}, ${key.keyHash})`; +} + +// Step 2: this lookup has no owner filter: it discovers the owner from an active key. +export async function findOwnerByKeyHash(db: Db, keyHash: Buffer): Promise { + const [owner] = await db` + SELECT owners.id AS "ownerId", owners.name AS "ownerName", api_keys.prefix AS "keyPrefix" + FROM api_keys JOIN owners ON owners.id = api_keys.owner_id + WHERE api_keys.key_hash = ${keyHash} AND api_keys.revoked_at IS NULL`; + return owner ?? null; +} diff --git a/api/src/routes/me.ts b/api/src/routes/me.ts new file mode 100644 index 0000000..c9062c6 --- /dev/null +++ b/api/src/routes/me.ts @@ -0,0 +1,12 @@ +// Returns only the authenticated owner's identity and the safe key prefix. +import type { FastifyPluginAsync } from "fastify"; +import { unauthorized } from "../errors.ts"; + +// Step 1: build the response field by field so the secret key cannot be included. +export const meRoutes: FastifyPluginAsync = async (app) => { + app.get("/me", async (request) => { + const owner = request.owner; + if (owner === null) throw unauthorized(); + return { owner: { id: owner.id, name: owner.name }, key: { prefix: owner.keyPrefix } }; + }); +}; diff --git a/api/src/routes/v1.ts b/api/src/routes/v1.ts new file mode 100644 index 0000000..6f69d13 --- /dev/null +++ b/api/src/routes/v1.ts @@ -0,0 +1,11 @@ +// Keeps authentication inside the v1 route scope, leaving health routes open. +import type { FastifyPluginAsync } from "fastify"; +import { authenticate } from "../auth/authenticate.ts"; +import type { Sql } from "../db.ts"; +import { meRoutes } from "./me.ts"; + +// Step 1: child routes inherit this hook through Fastify encapsulation. +export const v1Routes: FastifyPluginAsync<{ sql: Sql }> = async (app, { sql }) => { + app.addHook("onRequest", authenticate(sql)); + app.register(meRoutes); +}; diff --git a/api/src/schemas/owners.test.ts b/api/src/schemas/owners.test.ts new file mode 100644 index 0000000..e61c628 --- /dev/null +++ b/api/src/schemas/owners.test.ts @@ -0,0 +1,14 @@ +// Checks owner names, including NUL which cannot be passed through process arguments. +import { expect, test } from "bun:test"; +import { OwnerName } from "./owners.ts"; + +// Step 1: reject unsafe text before Postgres sees it. +test("owner names reject empty text, control characters and more than 100 characters", () => { + for (const name of ["", "Acme\u0000", "Acme\n", "x".repeat(101), "😀".repeat(101)]) { + expect(OwnerName.safeParse(name).success).toBe(false); + } + expect(OwnerName.parse("Acme Å😀")).toBe("Acme Å😀"); + // Positive controls right at the limit: 100 code points pass, also as 100 emoji (200 UTF-16 units). + expect(OwnerName.parse("x".repeat(100))).toHaveLength(100); + expect(OwnerName.safeParse("😀".repeat(100)).success).toBe(true); +}); diff --git a/api/src/schemas/owners.ts b/api/src/schemas/owners.ts new file mode 100644 index 0000000..5581413 --- /dev/null +++ b/api/src/schemas/owners.ts @@ -0,0 +1,7 @@ +// Checks owner names before they can reach a Postgres text column. +import { z } from "zod"; + +// Step 1: reject control characters, including NUL (Postgres refuses it with 22021). +// Zod 4.6.7 counts code points, exactly like Postgres length() (checked: "👍🏽" is 2 in both), +// so this limit and the table's CHECK agree. Otherwise a NUL could turn invalid input into a 500. +export const OwnerName = z.string().min(1).max(100).regex(/^[^\p{Cc}]+$/u); diff --git a/api/src/server.ts b/api/src/server.ts index 35a50bc..d43172c 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -8,8 +8,8 @@ import { createDb, waitForDatabase } from "./db.ts"; const config = loadConfig(); // Step 2: the app and the database pool. The pool doesn't connect yet; the first query does. -const app = buildApp(config); const sql = createDb(config); +const app = buildApp(config, { sql }); // When Kubernetes sends SIGTERM or Ctrl+C sends SIGINT: stop taking requests and let running // ones finish, then close the database connections (queries get 5 s), then exit. diff --git a/api/src/services/owners.ts b/api/src/services/owners.ts new file mode 100644 index 0000000..a5f4c35 --- /dev/null +++ b/api/src/services/owners.ts @@ -0,0 +1,14 @@ +// Creates an owner and its first API key as one indivisible database transaction. +import { generateApiKey } from "../auth/api-key.ts"; +import type { Sql } from "../db.ts"; +import { insertApiKey, insertOwner } from "../repositories/owners.ts"; + +// Step 1: either both inserts succeed, or Postgres rolls both back. +export async function createOwnerWithKey(sql: Sql, name: string) { + const { key, prefix, hash } = generateApiKey(); + return sql.begin(async (tx) => { + const owner = await insertOwner(tx, name); + await insertApiKey(tx, { ownerId: owner.id, prefix, keyHash: hash }); + return { owner, key, prefix }; + }); +} diff --git a/api/test/integration/auth-cli.test.ts b/api/test/integration/auth-cli.test.ts new file mode 100644 index 0000000..ae40e24 --- /dev/null +++ b/api/test/integration/auth-cli.test.ts @@ -0,0 +1,66 @@ +// Runs the owner CLI as a child process and removes only owners this file created. +import { afterAll, expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; +import { hashApiKey } from "../../src/auth/api-key.ts"; +import { DATABASE_URL, testDb } from "./helpers.ts"; + +// Step 1: use the same runtime and test database, never a production configuration. +const sql = testDb(); +const names: string[] = []; +const ids: string[] = []; +async function run(name: string) { + const child = Bun.spawn([process.execPath, "src/cli/create-owner.ts", "--name", name], { + cwd: fileURLToPath(new URL("../../", import.meta.url)), + env: { ...process.env, DATABASE_URL }, stdout: "pipe", stderr: "pipe", + }); + const [code, stdout, stderr] = await Promise.all([ + child.exited, new Response(child.stdout).text(), new Response(child.stderr).text(), + ]); + return { code, stdout, stderr }; +} +afterAll(async () => { + try { + // Recover the id by the unique name too, so a failed output assertion leaves no fixture. + for (const name of names) { + const rows = await sql`SELECT id FROM owners WHERE name = ${name}`; + for (const row of rows) ids.push(row.id); + } + if (ids.length) await sql`DELETE FROM owners WHERE id IN ${sql(ids)}`; + } finally { + await sql.end({ timeout: 5 }); + } +}); + +// Step 2: the printed key is usable through its hash, independently of the header TODO. +test("CLI creates one owner and prints exactly one key", async () => { + const name = `cli-${crypto.randomUUID()}`; + names.push(name); + const result = await run(name); + const [owner] = await sql`SELECT id, name FROM owners WHERE name = ${name}`; + if (owner) ids.push(owner.id); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + const keys = result.stdout.match(/relay_live_[A-Za-z0-9_-]{43}/g) ?? []; + expect(keys.length).toBe(1); + const key = keys[0]; + if (!key) throw new Error("CLI did not print a key"); + expect(result.stdout.trim().split("\n")).toEqual([ + `✅ Owner created: ${name} (${owner?.id})`, + `API key (shown once, store it now in a password manager): ${key}`, + ]); + const [saved] = await sql`SELECT owner_id FROM api_keys WHERE key_hash = ${hashApiKey(key)}`; + expect(saved?.owner_id).toBe(owner?.id); +}); + +// Step 3: NUL is covered by the schema unit test: OS arguments cannot carry it. +for (const name of ["", `invalid-${crypto.randomUUID()}\n`]) { + test(`CLI rejects ${name === "" ? "an empty name" : "a control character"} without creating an owner`, async () => { + names.push(name); + const result = await run(name); + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("❌"); + const rows = await sql`SELECT id FROM owners WHERE name = ${name}`; + expect(rows.length).toBe(0); + }); +} diff --git a/api/test/integration/auth-socket.ts b/api/test/integration/auth-socket.ts new file mode 100644 index 0000000..bc83111 --- /dev/null +++ b/api/test/integration/auth-socket.ts @@ -0,0 +1,20 @@ +// Sends raw HTTP so duplicate Authorization headers survive the client unchanged. +import { createConnection } from "node:net"; + +// Step 1: wait for the server to close this one-request connection, with a test deadline. +export function rawMe(port: number, keys: string[]): Promise { + return new Promise((resolve, reject) => { + const socket = createConnection({ host: "127.0.0.1", port }); + let response = ""; + socket.setTimeout(5_000, () => socket.destroy(new Error("Raw HTTP request timed out"))); + socket.on("error", reject); + socket.on("data", (chunk) => { response += chunk.toString(); }); + socket.on("connect", () => { + socket.write([ + "GET /v1/me HTTP/1.1", "Host: localhost", "Connection: close", + ...keys.map((key) => `Authorization: Bearer ${key}`), "", "", + ].join("\r\n")); + }); + socket.on("end", () => resolve(Number(response.split(" ")[1]))); + }); +} diff --git a/api/test/integration/auth.test.ts b/api/test/integration/auth.test.ts new file mode 100644 index 0000000..b115297 --- /dev/null +++ b/api/test/integration/auth.test.ts @@ -0,0 +1,101 @@ +// Exercises authentication against real Postgres, cleaning up only this file's owners. +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { buildApp } from "../../src/app.ts"; +import { generateApiKey, hashApiKey } from "../../src/auth/api-key.ts"; +import { loadConfig } from "../../src/config.ts"; +import { createOwnerWithKey } from "../../src/services/owners.ts"; +import { DATABASE_URL, testDb } from "./helpers.ts"; +import { rawMe } from "./auth-socket.ts"; + +// Step 1: create isolated fixtures and keep their ids for cleanup even after a failure. +const sql = testDb(); +const deadSql = testDb({ url: "postgres://relay:x@127.0.0.1:1/relay" }); +const lines: string[] = []; +const logStream = { write: (line: string) => { lines.push(line); } }; +const config = loadConfig({ DATABASE_URL }); +const app = buildApp(config, { sql }, { logStream }); +const unavailable = buildApp(config, { sql: deadSql }, { logStream }); +type Created = Awaited>; +let a: Created, b: Created, revoked: Created; +const ids: string[] = []; +let port: number; +beforeAll(async () => { + for (const label of ["A", "B", "revoked"]) { + const created = await createOwnerWithKey(sql, `auth-${label}-${crypto.randomUUID()}`); + ids.push(created.owner.id); + if (label === "A") a = created; + else if (label === "B") b = created; + else revoked = created; + } + await sql`UPDATE api_keys SET revoked_at = now() WHERE owner_id = ${revoked.owner.id}`; + await app.listen({ port: 0, host: "127.0.0.1" }); + const address = app.server.address(); + if (!address || typeof address === "string") throw new Error("Expected a TCP address"); + port = address.port; +}); +afterAll(async () => { + try { + await Promise.all([app.close(), unavailable.close()]); + if (ids.length) await sql`DELETE FROM owners WHERE id IN ${sql(ids)}`; + } finally { + await Promise.all([sql.end({ timeout: 5 }), deadSql.end({ timeout: 5 })]); + } +}); + +// Step 2: a valid key reveals only its own owner and the public key prefix. +test("fresh key returns its owner and prefix without the full key", async () => { + const res = await app.inject({ url: "/v1/me", headers: { authorization: `Bearer ${a.key}` } }); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ owner: a.owner, key: { prefix: a.prefix } }); + expect(res.body.includes(a.key)).toBe(false); +}); +test("owner B's key returns B, not A", async () => { + const res = await app.inject({ url: "/v1/me", headers: { authorization: `Bearer ${b.key}` } }); + expect(res.statusCode).toBe(200); + expect(res.json().owner).toEqual(b.owner); + expect(res.json().owner.id).not.toBe(a.owner.id); +}); +test("unknown, revoked, missing and Basic keys have the same 401", async () => { + const errors = []; + for (const header of [`Bearer ${generateApiKey().key}`, `Bearer ${revoked.key}`, undefined, "Basic abc"]) { + const res = await app.inject({ url: "/v1/me", headers: header ? { authorization: header } : {} }); + expect(res.statusCode).toBe(401); + const { code, message } = res.json().error; + errors.push({ code, message }); + } + expect(errors).toEqual(Array(4).fill({ code: "unauthorized", message: "A valid API key is required" })); +}); +test("SHA-256 agrees with Postgres UTF-8 hashing", async () => { + const [row] = await sql`SELECT sha256(convert_to(${a.key}, 'UTF8')) AS hash`; + expect(row?.hash.equals(hashApiKey(a.key))).toBe(true); +}); +test("two Authorization headers over a real socket return 401", async () => { + expect(await rawMe(port, [a.key, b.key])).toBe(401); +}); +test("one Authorization header over a real socket returns 200", async () => { + expect(await rawMe(port, [a.key])).toBe(200); +}); +test("closed database port returns 503 and Retry-After", async () => { + const res = await unavailable.inject({ url: "/v1/me", headers: { authorization: `Bearer ${a.key}` } }); + expect(res.statusCode).toBe(503); + expect(res.json().error.code).toBe("service_unavailable"); + expect(res.headers["retry-after"]).toBe("5"); +}); +test("logs of a 200, 401 and 503 never contain the full key", async () => { + const start = lines.length; + const ok = await app.inject({ url: "/v1/me", headers: { authorization: `Bearer ${a.key}` } }); + const denied = await app.inject({ url: "/v1/me", headers: { authorization: `Basic ${a.key}` } }); + const down = await unavailable.inject({ url: "/v1/me", headers: { authorization: `Bearer ${a.key}` } }); + const log = lines.slice(start).join(""); + expect(log.length).toBeGreaterThan(0); + expect(log.includes(a.key)).toBe(false); + expect([ok.statusCode, denied.statusCode, down.statusCode]).toEqual([200, 401, 503]); +}); + +// Step 3: the root not-found handler stays public, as do health and version. +test("unknown v1 paths return 404 and operational routes remain open", async () => { + expect((await app.inject({ url: "/v1/unknown" })).statusCode).toBe(404); + for (const url of ["/health/live", "/health/ready", "/version"]) { + expect((await app.inject({ url })).statusCode).toBe(200); + } +}); diff --git a/docs/api.md b/docs/api.md index e3d90ea..df373e9 100644 --- a/docs/api.md +++ b/docs/api.md @@ -9,7 +9,7 @@ that changes this file, approved by both of us, before or together with the code | Topic | Rule | |---|---| | Base URL | `https://api.myrelay.tech` | -| Auth | Every `/v1` route needs `Authorization: Bearer relay_live_`. `/health/*` and `/version` need none | +| Auth | Every `/v1` route needs `Authorization: Bearer relay_live_`. `/health/*` and `/version` need none. Two `Authorization` headers → 401. Unknown `/v1` paths → 404 without checking the key (nothing is behind them) | | Format | JSON only (`Content-Type: application/json`), request bodies up to 256 KiB | | Field names | `snake_case` | | IDs | UUIDv7 strings, e.g. `0199a3b2-7c41-7d2e-9f10-3b5c8e1a2d47` | @@ -43,7 +43,7 @@ A 422 also has `details`, one entry per problem: | 409 | `endpoint_deleted` | Replay of a delivery whose endpoint was deleted | | 409 | `endpoint_limit_reached` | Creating an endpoint when the owner already has 20 | | 413 | `payload_too_large` | Body over 256 KiB | -| 415 | `unsupported_media_type` | Body that isn't JSON | +| 415 | `unsupported_media_type` | Body that isn't `application/json` (including `text/plain`) | | 422 | `validation_failed` | Body or query fails the rules below (`details` lists why) | | 422 | `invalid_endpoint_url` | Endpoint URL that isn't `https`, or points to a private or internal address | | 500 | `internal_error` | Our bug. The message never contains internal details |