From 66344e0ab4fda89c6d3c9ab9d3fc1feae1efc5e6 Mon Sep 17 00:00:00 2001 From: thebabalola Date: Sat, 29 Aug 2026 22:27:53 +0100 Subject: [PATCH 1/3] feat: add authentication - gateway API keys and operator JWTs (#42) Implement the auth layer required before attestation/usage/export routes land. Every endpoint is protected by default via a global onRequest hook; public routes explicitly opt-out via config { public:true }. Gateway credentials: - API keys prefixed with mt__ for leak scanning - Stored as scrypt hash + per-key salt, verified with timingSafeEqual - Expiration and rotation overlap - multiple active entries with same prefix are all tried Operator sessions: - HS256 JWTs via jose, issuer/audience modeltrace-api, 8h lifetime - Short-lived, issued via auth.issueOperatorToken Server: - src/config/env.ts: require JWT_SECRET (>=32), API_KEY_STORE JSON array - src/auth/*: types, provider, plugin (registerAuthHooks), testing helper - src/index.ts: decorate app.auth, registerAuthHooks before routes - src/routes/health.ts, src/routes/v1/index.ts: mark liveness/meta as public - src/routes/disputes: scope checks dispute:read/write/admin, derive userId from authenticated subject Tests: - src/auth/index.test.ts covers hashing, valid/invalid keys, expiration, JWT, tampering, rotation and parse helpers - src/index.test.ts covers health/meta public, 401 unauthenticated, valid gateway key, valid JWT, scope 403, default deny - src/routes/disputes/index.test.ts now authenticates via test auth provider and checks scope rejection - .env.example updated with JWT_SECRET and API_KEY_STORE # Conflicts: # .env.example # src/config/env.test.ts # src/config/env.ts # src/index.test.ts # src/index.ts --- .env.example | 10 ++ package-lock.json | 10 ++ package.json | 1 + src/auth/index.test.ts | 111 +++++++++++++++++++++ src/auth/plugin.ts | 27 ++++++ src/auth/provider.ts | 151 +++++++++++++++++++++++++++++ src/auth/testing.ts | 44 +++++++++ src/auth/types.ts | 48 +++++++++ src/config/env.test.ts | 4 +- src/config/env.ts | 67 ++++++++----- src/index.test.ts | 156 ++++++++++++++++++++++++++---- src/index.ts | 15 +++ src/routes/disputes/index.test.ts | 25 ++++- src/routes/disputes/index.ts | 41 +++++--- src/routes/health.test.ts | 2 + src/routes/health.ts | 5 +- src/routes/v1/index.ts | 3 +- 17 files changed, 661 insertions(+), 59 deletions(-) create mode 100644 src/auth/index.test.ts create mode 100644 src/auth/plugin.ts create mode 100644 src/auth/provider.ts create mode 100644 src/auth/testing.ts create mode 100644 src/auth/types.ts diff --git a/.env.example b/.env.example index ce9b0dd..e79f81d 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,13 @@ SIGNING_PROVIDER=null RATE_LIMIT_MAX=100 RATE_LIMIT_WINDOW_MS=60000 BODY_LIMIT_BYTES=1048576 + +# --- Authentication (docs/adr/0002-authentication.md when it exists) --- +# JWT secret for operator dashboard sessions. Must be >= 32 bytes and changed +# in every non-local environment. +JWT_SECRET=change-me-in-production-min-32-chars-long + +# Gateway API keys stored as a JSON array of hashed entries. Empty array means +# only operator JWTs are accepted. Use the generateApiKey helper to create an +# entry, then paste the JSON here and discard the plaintext key. +API_KEY_STORE=[] diff --git a/package-lock.json b/package-lock.json index 820b295..76a370e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@fastify/rate-limit": "^11.2.0", "dotenv": "^16.4.7", "fastify": "^5.2.1", + "jose": "^6.2.10", "zod": "^3.24.1" }, "devDependencies": { @@ -982,6 +983,15 @@ "node": ">= 10" } }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", diff --git a/package.json b/package.json index 3616554..f8f6f11 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@fastify/rate-limit": "^11.2.0", "dotenv": "^16.4.7", "fastify": "^5.2.1", + "jose": "^6.2.10", "zod": "^3.24.1" }, "devDependencies": { diff --git a/src/auth/index.test.ts b/src/auth/index.test.ts new file mode 100644 index 0000000..a636131 --- /dev/null +++ b/src/auth/index.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { generateApiKey, hashApiKeySecret, parseApiKeyStore, resolveAuthProvider } from "./provider.js"; +import type { ApiKeyEntry, AuthScope } from "./types.js"; + +const secret = "dev-secret-32-chars-long!!"; + +function buildProvider(store: ApiKeyEntry[] = []) { + return resolveAuthProvider({ jwtSecret: secret, apiKeyStore: store }); +} + +function mockRequest(authHeader?: string) { + return { headers: { authorization: authHeader } } as import("fastify").FastifyRequest; +} + +test("generateApiKey produces a detectable prefix and storable hash", () => { + const { key, entry } = generateApiKey("gateway-prod", ["usage:write"]); + assert.ok(key.startsWith("mt_gateway-prod_")); + assert.equal(entry.name, "gateway-prod"); + assert.equal(entry.prefix, "mt_gateway-prod"); + assert.ok(entry.hash.length > 0); + assert.ok(entry.salt.length > 0); +}); + +test("API key authentication succeeds with a valid key", async () => { + const { key, entry } = generateApiKey("gateway-prod", ["usage:write", "attest:write"]); + const provider = buildProvider([entry]); + + const identity = await provider.authenticate(mockRequest(`ApiKey ${key}`)); + assert.notEqual(identity, null); + assert.equal(identity!.type, "gateway"); + assert.equal(identity!.subject, "gateway-prod"); + assert.deepEqual(identity!.scopes, ["usage:write", "attest:write"]); +}); + +test("API key authentication fails for an invalid secret", async () => { + const { entry } = generateApiKey("gateway-prod", ["usage:write"]); + const provider = buildProvider([entry]); + + const identity = await provider.authenticate(mockRequest("ApiKey mt_gateway-prod_not-the-secret")); + assert.equal(identity, null); +}); + +test("API key authentication fails for an expired key", async () => { + const { key, entry } = generateApiKey("gateway-old", ["usage:write"]); + entry.expiresAt = new Date(Date.now() - 86_400_000).toISOString(); + const provider = buildProvider([entry]); + + const identity = await provider.authenticate(mockRequest(`ApiKey ${key}`)); + assert.equal(identity, null); +}); + +test("operator JWT authentication succeeds with a valid token", async () => { + const provider = buildProvider(); + const token = await provider.issueOperatorToken("operator-1", ["export:read"] as AuthScope[]); + + const identity = await provider.authenticate(mockRequest(`Bearer ${token}`)); + assert.notEqual(identity, null); + assert.equal(identity!.type, "operator"); + assert.equal(identity!.subject, "operator-1"); + assert.deepEqual(identity!.scopes, ["export:read"]); +}); + +test("operator JWT authentication fails for a tampered token", async () => { + const provider = buildProvider(); + const token = await provider.issueOperatorToken("operator-1", ["export:read"] as AuthScope[]); + + const identity = await provider.authenticate(mockRequest(`Bearer ${token}x`)); + assert.equal(identity, null); +}); + +test("rotation overlap: two keys with the same name can both be active", async () => { + const { key: oldKey, entry: oldEntry } = generateApiKey("gateway-prod", ["usage:write"]); + const { key: newKey, entry: newEntry } = generateApiKey("gateway-prod", ["usage:write", "attest:write"]); + + const provider = buildProvider([oldEntry, newEntry]); + const oldIdentity = await provider.authenticate(mockRequest(`ApiKey ${oldKey}`)); + const newIdentity = await provider.authenticate(mockRequest(`ApiKey ${newKey}`)); + + assert.notEqual(oldIdentity, null); + assert.notEqual(newIdentity, null); + assert.deepEqual(oldIdentity!.scopes, ["usage:write"]); + assert.deepEqual(newIdentity!.scopes, ["usage:write", "attest:write"]); +}); + +test("parseApiKeyStore handles empty and populated JSON", () => { + assert.deepEqual(parseApiKeyStore(""), []); + assert.deepEqual(parseApiKeyStore("[]"), []); + + const { entry } = generateApiKey("gateway-prod", ["usage:write"]); + const parsed = parseApiKeyStore(JSON.stringify([entry])); + assert.equal(parsed.length, 1); + assert.equal(parsed[0].name, "gateway-prod"); +}); + +test("hashApiKeySecret produces deterministic output for a fixed salt", () => { + const salt = Buffer.from("fixed-salt-16b"); + const a = hashApiKeySecret("same-secret", salt); + const b = hashApiKeySecret("same-secret", salt); + assert.equal(a.hash, b.hash); + assert.equal(a.salt, b.salt); +}); + +test("verifyScope checks the request identity", async () => { + const provider = buildProvider(); + const req = mockRequest(); + req.identity = { type: "operator", subject: "op", scopes: ["export:read"] }; + + assert.equal(provider.verifyScope(req, "export:read"), true); + assert.equal(provider.verifyScope(req, "admin"), false); +}); diff --git a/src/auth/plugin.ts b/src/auth/plugin.ts new file mode 100644 index 0000000..5852cf6 --- /dev/null +++ b/src/auth/plugin.ts @@ -0,0 +1,27 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; +import type { AuthProvider } from "./types.js"; + +declare module "fastify" { + interface FastifyRequest { + identity?: import("./types.js").AuthenticatedIdentity; + } +} + +/** + * Register the global auth hook directly on the supplied Fastify instance. + * This is intentionally not a plugin so the hook applies to sibling routes + * registered on the same instance. + */ +export function registerAuthHooks(app: FastifyInstance, auth: AuthProvider) { + app.addHook("onRequest", async (req, reply) => { + // Routes opt out of auth by setting `config: { public: true }`. + const routeConfig = req.routeOptions.config as { public?: boolean }; + if (routeConfig.public) return; + + const identity = await auth.authenticate(req); + if (!identity) { + return reply.code(401).send({ error: "unauthorized" }); + } + req.identity = identity; + }); +} diff --git a/src/auth/provider.ts b/src/auth/provider.ts new file mode 100644 index 0000000..10be099 --- /dev/null +++ b/src/auth/provider.ts @@ -0,0 +1,151 @@ +import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto"; +import { SignJWT, jwtVerify } from "jose"; +import type { + ApiKeyEntry, + AuthProvider, + AuthProviderConfig, + AuthScope, + AuthenticatedIdentity, +} from "./types.js"; + +const API_KEY_PREFIX = "mt"; +const TOKEN_TYPE_API_KEY = "ApiKey"; +const TOKEN_TYPE_BEARER = "Bearer"; + +export { API_KEY_PREFIX }; + +/** + * Hash an API key secret with a per-key salt. Returns base64 strings ready to + * store in the API_KEY_STORE env variable. + */ +export function hashApiKeySecret(secret: string, salt?: Buffer) { + const s = salt ?? randomBytes(16); + const h = scryptSync(secret, s, 64); + return { + hash: h.toString("base64"), + salt: s.toString("base64"), + }; +} + +/** + * Generate a new gateway API key. The returned `key` is the only time the + * secret is exposed; callers must store the metadata (prefix/hash/salt) and + * discard the key afterwards. + */ +export function generateApiKey( + name: string, + scopes: AuthScope[], +): { key: string; entry: ApiKeyEntry } { + const secret = randomBytes(32).toString("base64url"); + const prefix = `${API_KEY_PREFIX}_${name}`; + const { hash, salt } = hashApiKeySecret(secret); + const key = `${prefix}_${secret}`; + const entry: ApiKeyEntry = { + name, + prefix, + hash, + salt, + scopes, + createdAt: new Date().toISOString(), + }; + return { key, entry }; +} + +/** + * Parse the API_KEY_STORE env value. An empty string or "[]" yields an empty + * store, which is fine for local development where only operator JWTs are used. + */ +export function parseApiKeyStore(raw: string): ApiKeyEntry[] { + const trimmed = raw.trim(); + if (!trimmed || trimmed === "[]") return []; + const parsed = JSON.parse(trimmed) as unknown; + if (!Array.isArray(parsed)) throw new Error("API_KEY_STORE must be a JSON array"); + return parsed as ApiKeyEntry[]; +} + +export function resolveAuthProvider(config: AuthProviderConfig): AuthProvider { + const secretBytes = new TextEncoder().encode(config.jwtSecret); + const issuer = config.issuer ?? "modeltrace-api"; + const audience = config.audience ?? "modeltrace-api"; + + function isExpired(entry: ApiKeyEntry): boolean { + if (!entry.expiresAt) return false; + return Date.now() > new Date(entry.expiresAt).getTime(); + } + + function parseApiKeyHeader(header: string): { prefix: string; secret: string } | null { + const [type, credential] = header.split(" ", 2); + if (type !== TOKEN_TYPE_API_KEY || !credential) return null; + + // Format: mt__. The secret is everything after the second + // underscore so it may contain underscores without breaking parsing. + const firstUnderscore = credential.indexOf("_"); + if (firstUnderscore === -1 || credential.slice(0, firstUnderscore) !== API_KEY_PREFIX) return null; + const rest = credential.slice(firstUnderscore + 1); + const secondUnderscore = rest.indexOf("_"); + if (secondUnderscore === -1) return null; + const name = rest.slice(0, secondUnderscore); + const secret = rest.slice(secondUnderscore + 1); + if (!name || !secret) return null; + return { prefix: `${API_KEY_PREFIX}_${name}`, secret }; + } + + return { + async authenticate(req): Promise { + const authHeader = req.headers.authorization; + if (typeof authHeader !== "string" || authHeader.length === 0) return null; + + const [type] = authHeader.split(" ", 1); + + if (type === TOKEN_TYPE_API_KEY) { + const parsed = parseApiKeyHeader(authHeader); + if (!parsed) return null; + + const candidates = config.apiKeyStore.filter( + (k) => k.prefix === parsed.prefix && !isExpired(k), + ); + if (candidates.length === 0) return null; + + for (const entry of candidates) { + const salt = Buffer.from(entry.salt, "base64"); + const expected = Buffer.from(entry.hash, "base64"); + const actual = scryptSync(parsed.secret, salt, expected.length); + if (timingSafeEqual(actual, expected)) { + return { type: "gateway", subject: entry.name, scopes: entry.scopes }; + } + } + return null; + } + + if (type === TOKEN_TYPE_BEARER) { + const token = authHeader.slice(TOKEN_TYPE_BEARER.length + 1); + try { + const { payload } = await jwtVerify(token, secretBytes, { issuer, audience }); + const sub = payload.sub; + const scopes = payload.scope; + if (typeof sub !== "string" || !Array.isArray(scopes)) return null; + return { type: "operator", subject: sub, scopes: scopes as AuthScope[] }; + } catch { + return null; + } + } + + return null; + }, + + async issueOperatorToken(subject, scopes) { + return new SignJWT({ scope: scopes }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject(subject) + .setIssuedAt() + .setIssuer(issuer) + .setAudience(audience) + .setExpirationTime("8h") + .sign(secretBytes); + }, + + verifyScope(req, scope) { + return req.identity?.scopes.includes(scope) ?? false; + }, + }; +} diff --git a/src/auth/testing.ts b/src/auth/testing.ts new file mode 100644 index 0000000..f597efc --- /dev/null +++ b/src/auth/testing.ts @@ -0,0 +1,44 @@ +import type { FastifyRequest } from "fastify"; +import type { AuthProvider, AuthScope, AuthenticatedIdentity } from "./types.js"; + +export interface TestAuthProviderOptions { + /** If provided, only this exact header value authenticates. */ + header?: string; + /** Default identity returned for authenticated requests. */ + identity?: AuthenticatedIdentity; +} + +/** + * A no-crypto auth provider for route unit tests. Authentication succeeds when + * the request carries `x-test-auth: ,,`. + */ +export function createTestAuthProvider( + defaults: TestAuthProviderOptions = {}, +): AuthProvider { + const headerName = defaults.header ?? "x-test-auth"; + + return { + async authenticate(req: FastifyRequest): Promise { + const raw = req.headers[headerName]; + const value = Array.isArray(raw) ? raw[0] : raw; + if (typeof value !== "string" || value.length === 0) return null; + + const [subject, ...scopes] = value.split(","); + if (!subject) return null; + + return { + type: "operator", + subject, + scopes: (scopes.length ? scopes : ["meta:read"]) as AuthScope[], + }; + }, + + async issueOperatorToken(): Promise { + return "test-token"; + }, + + verifyScope(req, scope) { + return req.identity?.scopes.includes(scope) ?? false; + }, + }; +} diff --git a/src/auth/types.ts b/src/auth/types.ts new file mode 100644 index 0000000..fc17409 --- /dev/null +++ b/src/auth/types.ts @@ -0,0 +1,48 @@ +import type { FastifyRequest } from "fastify"; + +export type AuthScope = + | "attest:write" + | "usage:write" + | "export:read" + | "admin" + | "dispute:read" + | "dispute:write" + | "meta:read"; + +export interface AuthenticatedIdentity { + /** Gateway API keys are machine clients; operator JWTs are dashboard users. */ + type: "gateway" | "operator"; + /** Stable identifier for the caller — gateway name or operator subject. */ + subject: string; + /** Scopes granted to this identity. */ + scopes: AuthScope[]; +} + +export interface ApiKeyEntry { + /** Human-readable name, e.g. "inference-gateway-prod". */ + name: string; + /** Detectable prefix for leak scanning, e.g. "mt_gateway-prod". */ + prefix: string; + /** base64 scrypt hash of the key secret. */ + hash: string; + /** base64 random salt used for the hash. */ + salt: string; + scopes: AuthScope[]; + /** ISO timestamp when the key was created. */ + createdAt: string; + /** Optional ISO expiry; the key is rejected after this point. */ + expiresAt?: string; +} + +export interface AuthProvider { + authenticate(req: FastifyRequest): Promise; + issueOperatorToken(subject: string, scopes: AuthScope[]): Promise; + verifyScope(req: FastifyRequest, scope: AuthScope): boolean; +} + +export interface AuthProviderConfig { + jwtSecret: string; + apiKeyStore: ApiKeyEntry[]; + issuer?: string; + audience?: string; +} diff --git a/src/config/env.test.ts b/src/config/env.test.ts index 54b70b4..484556e 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -18,6 +18,8 @@ const baseEnv = { RATE_LIMIT_MAX: "100", RATE_LIMIT_WINDOW_MS: "60000", BODY_LIMIT_BYTES: "1048576", + JWT_SECRET: "dev-secret-32-characters-long!!!", + API_KEY_STORE: "[]", }; Object.assign(process.env, baseEnv); @@ -104,7 +106,7 @@ test("production accepts an explicit, non-wildcard CORS_ORIGIN", () => { test(".env.example carries every required schema key", () => { const schemaKeys = Object.keys(baseEnvSchema.shape); - const optionalKeys = new Set(["SIGNING_KMS_KEY_ID", "SIGNING_ENV_SECRET_KEY"]); + const optionalKeys = new Set(["SIGNING_KMS_KEY_ID", "SIGNING_ENV_SECRET_KEY", "API_KEY_STORE"]); const exampleKeys = new Set(exampleEnvKeys()); for (const key of schemaKeys) { diff --git a/src/config/env.ts b/src/config/env.ts index 1101ded..36c7057 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -35,6 +35,10 @@ const baseEnvSchema = z.object({ RATE_LIMIT_MAX: z.coerce.number().default(100), RATE_LIMIT_WINDOW_MS: z.coerce.number().default(60000), BODY_LIMIT_BYTES: z.coerce.number().default(1048576), + + // Authentication — issue #42. + JWT_SECRET: z.string().min(32), + API_KEY_STORE: z.string().default("[]"), }); /** Exposed separately so callers that need `.shape` (e.g. tests) can get at @@ -83,31 +87,50 @@ export function parseEnv(env: NodeJS.ProcessEnv) { return envSchema.parse(env); } -const raw = parseEnv(process.env); +let _config: ReturnType | null = null; + +export function getConfig(): ReturnType { + if (!_config) { + _config = parseEnv(process.env); + } + return _config; +} export const config = { - nodeEnv: raw.NODE_ENV, - port: raw.PORT, - apiPrefix: raw.API_PREFIX, - corsOrigin: resolveCorsOrigins(raw.CORS_ORIGIN, raw.NODE_ENV), - stellar: { - network: raw.STELLAR_NETWORK, - sorobanRpcUrl: raw.SOROBAN_RPC_URL, - contracts: { - auditRegistry: raw.AUDIT_REGISTRY_CONTRACT_ID, - usageMeter: raw.USAGE_METER_CONTRACT_ID, - paymentRouter: raw.PAYMENT_ROUTER_CONTRACT_ID, - }, + get nodeEnv() { return getConfig().NODE_ENV; }, + get port() { return getConfig().PORT; }, + get apiPrefix() { return getConfig().API_PREFIX; }, + get corsOrigin() { return resolveCorsOrigins(getConfig().CORS_ORIGIN, getConfig().NODE_ENV); }, + get stellar() { + return { + network: getConfig().STELLAR_NETWORK, + sorobanRpcUrl: getConfig().SOROBAN_RPC_URL, + contracts: { + auditRegistry: getConfig().AUDIT_REGISTRY_CONTRACT_ID, + usageMeter: getConfig().USAGE_METER_CONTRACT_ID, + paymentRouter: getConfig().PAYMENT_ROUTER_CONTRACT_ID, + }, + }; + }, + get signing() { + return { + provider: getConfig().SIGNING_PROVIDER, + kmsKeyId: getConfig().SIGNING_KMS_KEY_ID, + envSecret: getConfig().SIGNING_ENV_SECRET_KEY, + nodeEnv: getConfig().NODE_ENV, + }; }, - signing: { - provider: raw.SIGNING_PROVIDER, - kmsKeyId: raw.SIGNING_KMS_KEY_ID, - envSecret: raw.SIGNING_ENV_SECRET_KEY, - nodeEnv: raw.NODE_ENV, + get rateLimit() { + return { + max: getConfig().RATE_LIMIT_MAX, + windowMs: getConfig().RATE_LIMIT_WINDOW_MS, + }; }, - rateLimit: { - max: raw.RATE_LIMIT_MAX, - windowMs: raw.RATE_LIMIT_WINDOW_MS, + get bodyLimitBytes() { return getConfig().BODY_LIMIT_BYTES; }, + get auth() { + return { + jwtSecret: getConfig().JWT_SECRET, + apiKeyStore: getConfig().API_KEY_STORE, + }; }, - bodyLimitBytes: raw.BODY_LIMIT_BYTES, }; diff --git a/src/index.test.ts b/src/index.test.ts index ad0159c..a669702 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,28 +1,32 @@ import assert from "node:assert/strict"; import { test } from "node:test"; +import Fastify from "fastify"; +import { registerAuthHooks } from "./auth/plugin.js"; +import { generateApiKey, resolveAuthProvider } from "./auth/provider.js"; +import { createTestAuthProvider } from "./auth/testing.js"; +import { buildServer } from "./index.js"; const validContractId = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; -function setBaseEnv() { - process.env.NODE_ENV = "test"; - process.env.PORT = "0"; - process.env.API_PREFIX = "/api/v1"; - process.env.CORS_ORIGIN = "http://localhost:3000"; - process.env.STELLAR_NETWORK = "testnet"; - process.env.SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org"; - process.env.AUDIT_REGISTRY_CONTRACT_ID = validContractId; - process.env.USAGE_METER_CONTRACT_ID = "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; - process.env.PAYMENT_ROUTER_CONTRACT_ID = "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; - process.env.SIGNING_PROVIDER = "null"; -} +process.env.NODE_ENV = "test"; +process.env.PORT = "0"; +process.env.API_PREFIX = "/api/v1"; +process.env.CORS_ORIGIN = "http://localhost:3000"; +process.env.STELLAR_NETWORK = "testnet"; +process.env.SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org"; +process.env.AUDIT_REGISTRY_CONTRACT_ID = validContractId; +process.env.USAGE_METER_CONTRACT_ID = "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; +process.env.PAYMENT_ROUTER_CONTRACT_ID = "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"; +process.env.SIGNING_PROVIDER = "null"; +process.env.RATE_LIMIT_MAX = "100"; +process.env.RATE_LIMIT_WINDOW_MS = "60000"; +process.env.BODY_LIMIT_BYTES = "1048576"; +process.env.JWT_SECRET = "dev-secret-32-characters-long!!!"; +process.env.API_KEY_STORE = "[]"; + +const JWT_SECRET = process.env.JWT_SECRET; async function buildApp(opts: { rateLimitMax?: number; bodyLimitBytes?: number } = {}) { - setBaseEnv(); - process.env.RATE_LIMIT_MAX = "100"; - process.env.RATE_LIMIT_WINDOW_MS = "60000"; - process.env.BODY_LIMIT_BYTES = "1048576"; - - const { buildServer } = await import("./index.js"); const app = await buildServer({ rateLimit: opts.rateLimitMax !== undefined ? { max: opts.rateLimitMax } : undefined, bodyLimitBytes: opts.bodyLimitBytes, @@ -31,6 +35,10 @@ async function buildApp(opts: { rateLimitMax?: number; bodyLimitBytes?: number } return app; } +function gatewayAuth(entry: ReturnType["entry"]) { + return resolveAuthProvider({ jwtSecret: JWT_SECRET, apiKeyStore: [entry] }); +} + test("rate limit rejects requests above the configured threshold", async () => { const app = await buildApp({ rateLimitMax: 2 }); @@ -92,3 +100,115 @@ test("small payloads within the body limit are accepted", async () => { await app.close(); }); + +test("health endpoints are public and do not require credentials", async () => { + const app = await buildApp(); + + const live = await app.inject({ method: "GET", url: "/health/live" }); + assert.equal(live.statusCode, 200); + + const health = await app.inject({ method: "GET", url: "/health" }); + assert.equal(health.statusCode, 200); + + await app.close(); +}); + +test("/api/v1/meta is public", async () => { + const app = await buildApp(); + + const res = await app.inject({ method: "GET", url: "/api/v1/meta" }); + assert.equal(res.statusCode, 200); + + await app.close(); +}); + +test("protected routes reject unauthenticated requests", async () => { + const app = await buildApp(); + + const res = await app.inject({ method: "GET", url: "/api/v1/disputes" }); + assert.equal(res.statusCode, 401); + assert.equal(res.json().error, "unauthorized"); + + await app.close(); +}); + +test("protected routes accept a valid gateway API key", async () => { + const { key, entry } = generateApiKey("gateway-prod", ["dispute:read"]); + const auth = gatewayAuth(entry); + const app = await buildServer({ auth }); + await app.ready(); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/disputes", + headers: { + authorization: `ApiKey ${key}`, + "x-role": "adjudicator", + }, + }); + + assert.equal(res.statusCode, 200); + + await app.close(); +}); + +test("protected routes accept a valid operator bearer token", async () => { + const app = await buildServer(); + await app.ready(); + + const token = await app.auth.issueOperatorToken("operator-1", ["dispute:read"]); + const res = await app.inject({ + method: "GET", + url: "/api/v1/disputes", + headers: { + authorization: `Bearer ${token}`, + "x-role": "adjudicator", + }, + }); + + assert.equal(res.statusCode, 200); + + await app.close(); +}); + +test("protected routes enforce scopes", async () => { + const { key, entry } = generateApiKey("gateway-prod", ["meta:read"]); + const auth = gatewayAuth(entry); + const app = await buildServer({ auth }); + await app.ready(); + + const res = await app.inject({ + method: "GET", + url: "/api/v1/disputes", + headers: { + authorization: `ApiKey ${key}`, + "x-role": "adjudicator", + }, + }); + + assert.equal(res.statusCode, 403); + assert.equal(res.json().error, "insufficient scope"); + + await app.close(); +}); + +test("routes without explicit public opt-in are denied by default", async () => { + const auth = createTestAuthProvider(); + const app = Fastify(); + app.decorate("auth", auth); + registerAuthHooks(app, auth); + app.get("/hidden", async () => "ok"); + await app.ready(); + + const denied = await app.inject({ method: "GET", url: "/hidden" }); + assert.equal(denied.statusCode, 401); + + const allowed = await app.inject({ + method: "GET", + url: "/hidden", + headers: { "x-test-auth": "user,meta:read" }, + }); + assert.equal(allowed.statusCode, 200); + + await app.close(); +}); diff --git a/src/index.ts b/src/index.ts index 141790f..cefe6d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,9 @@ import Fastify, { type FastifyError, type FastifyRequest } from "fastify"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import { config } from "./config/env.js"; +import { registerAuthHooks } from "./auth/plugin.js"; +import { parseApiKeyStore, resolveAuthProvider } from "./auth/provider.js"; +import type { AuthProvider } from "./auth/types.js"; import { healthRoutes } from "./routes/health.js"; import { v1Routes } from "./routes/v1/index.js"; import { @@ -14,12 +17,15 @@ declare module "fastify" { interface FastifyInstance { /** Custody-aware signer. Exposes signatures, never key material. */ signing: SigningKeyProvider; + /** Authentication provider for gateway keys and operator sessions. */ + auth: AuthProvider; } } export interface BuildServerOptions { bodyLimitBytes?: number; rateLimit?: { max?: number; windowMs?: number }; + auth?: AuthProvider; } export async function buildServer(opts: BuildServerOptions = {}) { @@ -83,6 +89,15 @@ export async function buildServer(opts: BuildServerOptions = {}) { }), }); + const auth = + opts.auth ?? + resolveAuthProvider({ + jwtSecret: config.auth.jwtSecret, + apiKeyStore: parseApiKeyStore(config.auth.apiKeyStore), + }); + app.decorate("auth", auth); + registerAuthHooks(app, auth); + await app.register(healthRoutes); await app.register(v1Routes, { prefix: config.apiPrefix }); diff --git a/src/routes/disputes/index.test.ts b/src/routes/disputes/index.test.ts index 8f23931..6499df2 100644 --- a/src/routes/disputes/index.test.ts +++ b/src/routes/disputes/index.test.ts @@ -1,18 +1,27 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import Fastify from "fastify"; +import { registerAuthHooks } from "../../auth/plugin.js"; +import { createTestAuthProvider } from "../../auth/testing.js"; import { disputeRoutes } from "./index.js"; import { DisputeStore } from "./store.js"; async function buildApp(opts: { windowMs?: number; store?: DisputeStore } = {}) { const app = Fastify(); + const auth = createTestAuthProvider(); + app.decorate("auth", auth); + registerAuthHooks(app, auth); await app.register(disputeRoutes, opts); await app.ready(); return app; } -function headers(role: string, userId: string) { - return { "x-role": role, "x-user-id": userId, "content-type": "application/json" }; +function headers(role: string, userId: string, scopes: string[] = ["dispute:read", "dispute:write", "admin"]) { + return { + "x-role": role, + "x-test-auth": `${userId},${scopes.join(",")}`, + "content-type": "application/json", + }; } test("a buyer can raise a dispute with evidence, hash-committed", async () => { @@ -146,8 +155,18 @@ test("evidence is rejected once the window has closed", async () => { assert.equal(res.statusCode, 409); }); -test("requests without a recognized role/user are rejected", async () => { +test("requests without credentials are rejected", async () => { const app = await buildApp(); const res = await app.inject({ method: "GET", url: "/disputes" }); assert.equal(res.statusCode, 401); }); + +test("requests with the wrong scope are rejected", async () => { + const app = await buildApp(); + const res = await app.inject({ + method: "GET", + url: "/disputes", + headers: headers("buyer", "buyer-1", ["meta:read"]), + }); + assert.equal(res.statusCode, 403); +}); diff --git a/src/routes/disputes/index.ts b/src/routes/disputes/index.ts index a6bf2db..12cf917 100644 --- a/src/routes/disputes/index.ts +++ b/src/routes/disputes/index.ts @@ -16,11 +16,9 @@ export interface DisputeRoutesOptions { /** * Everything the dispute lifecycle needs: raise, list (role-scoped), - * attach evidence, and resolve. Auth is a placeholder — the repo has no - * session/identity layer yet, so role and user identity are read from - * headers the same way a real gateway would inject them post-auth. This - * keeps the role-scoping logic itself (the part the acceptance criteria - * care about) real and testable without inventing an auth system. + * attach evidence, and resolve. Authentication is handled by the global + * auth plugin; this module only reads the business role from the `x-role` + * header and derives the user identity from the authenticated subject. */ export const disputeRoutes: FastifyPluginAsync = async ( app, @@ -30,18 +28,28 @@ export const disputeRoutes: FastifyPluginAsync = async ( const windowMs = opts.windowMs ?? 7 * 24 * 60 * 60 * 1000; // 7 days function actor(req: FastifyRequest): { role: DisputeRole; userId: string } | null { + if (!req.identity) return null; const roleHeader = req.headers["x-role"]; - const userId = req.headers["x-user-id"]; const parsed = disputeRoleSchema.safeParse( Array.isArray(roleHeader) ? roleHeader[0] : roleHeader, ); - if (!parsed.success || typeof userId !== "string" || userId.length === 0) return null; - return { role: parsed.data, userId }; + if (!parsed.success) return null; + return { role: parsed.data, userId: req.identity.subject }; + } + + function requireScope(req: FastifyRequest, reply: import("fastify").FastifyReply, scope: import("../../auth/types.js").AuthScope) { + if (!app.auth.verifyScope(req, scope)) { + return reply.code(403).send({ error: "insufficient scope" }); + } + return undefined; } app.post("/disputes", async (req, reply) => { + const denied = requireScope(req, reply, "dispute:write"); + if (denied) return denied; + const who = actor(req); - if (!who) return reply.code(401).send({ error: "missing or invalid x-role/x-user-id" }); + if (!who) return reply.code(401).send({ error: "missing or invalid x-role" }); const body = raiseDisputeSchema.safeParse(req.body); if (!body.success) return reply.code(400).send({ error: body.error.flatten() }); @@ -55,15 +63,21 @@ export const disputeRoutes: FastifyPluginAsync = async ( }); app.get("/disputes", async (req, reply) => { + const denied = requireScope(req, reply, "dispute:read"); + if (denied) return denied; + const who = actor(req); - if (!who) return reply.code(401).send({ error: "missing or invalid x-role/x-user-id" }); + if (!who) return reply.code(401).send({ error: "missing or invalid x-role" }); return reply.send(store.listForRole(who.role, who.userId)); }); app.post<{ Params: { id: string } }>("/disputes/:id/evidence", async (req, reply) => { + const denied = requireScope(req, reply, "dispute:write"); + if (denied) return denied; + const who = actor(req); - if (!who) return reply.code(401).send({ error: "missing or invalid x-role/x-user-id" }); + if (!who) return reply.code(401).send({ error: "missing or invalid x-role" }); const dispute = store.get(req.params.id); if (!dispute) return reply.code(404).send({ error: "dispute not found" }); @@ -82,8 +96,11 @@ export const disputeRoutes: FastifyPluginAsync = async ( }); app.post<{ Params: { id: string } }>("/disputes/:id/resolve", async (req, reply) => { + const denied = requireScope(req, reply, "admin"); + if (denied) return denied; + const who = actor(req); - if (!who) return reply.code(401).send({ error: "missing or invalid x-role/x-user-id" }); + if (!who) return reply.code(401).send({ error: "missing or invalid x-role" }); if (who.role !== "adjudicator") { return reply.code(403).send({ error: "only an adjudicator may resolve a dispute" }); } diff --git a/src/routes/health.test.ts b/src/routes/health.test.ts index 34ae9b0..a65cd6e 100644 --- a/src/routes/health.test.ts +++ b/src/routes/health.test.ts @@ -15,6 +15,8 @@ Object.assign(process.env, { USAGE_METER_CONTRACT_ID: "CBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", PAYMENT_ROUTER_CONTRACT_ID: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", SIGNING_PROVIDER: "null", + JWT_SECRET: "dev-secret-32-characters-long!!!", + API_KEY_STORE: "[]", }); const { healthRoutes, resetHealthCache, setShuttingDown } = await import( diff --git a/src/routes/health.ts b/src/routes/health.ts index a061e94..6df3812 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -101,14 +101,14 @@ export const healthRoutes: FastifyPluginAsync = async ( const cacheTtlMs = opts.cacheTtlMs ?? 5000; // Liveness check: process is running. Cheap, no dependencies. - app.get("/health/live", async () => ({ + app.get("/health/live", { config: { public: true } }, async () => ({ status: "ok", service: "api", timestamp: new Date().toISOString(), })); // Backwards-compatible /health endpoint - app.get("/health", async () => ({ + app.get("/health", { config: { public: true } }, async () => ({ status: "ok", service: "api", timestamp: new Date().toISOString(), @@ -117,6 +117,7 @@ export const healthRoutes: FastifyPluginAsync = async ( // Readiness check: dependencies reachability & shutdown state app.get( "/health/ready", + { config: { public: true } }, async (req: FastifyRequest, reply: FastifyReply) => { const timestamp = new Date().toISOString(); diff --git a/src/routes/v1/index.ts b/src/routes/v1/index.ts index 4375a1a..79a0630 100644 --- a/src/routes/v1/index.ts +++ b/src/routes/v1/index.ts @@ -4,7 +4,8 @@ import { getMetaInfo } from "../../meta/version.js"; import { disputeRoutes } from "../disputes/index.js"; export const v1Routes: FastifyPluginAsync = async (app) => { - app.get("/meta", async () => getMetaInfo(config)); + // Public service metadata — safe to expose without credentials. + app.get("/meta", { config: { public: true } }, async () => getMetaInfo(config)); await app.register(disputeRoutes); From a2dd545939962273ab33bd7528ffc934d0e31f48 Mon Sep 17 00:00:00 2001 From: thebabalola Date: Sat, 29 Aug 2026 22:32:18 +0100 Subject: [PATCH 2/3] fix: resolve merge conflicts with #76, fix body limit tests - Merged rate limiting (#76) and auth (#42) features in env.ts, index.ts, index.test.ts, .env.example, env.test.ts - Applied lazy config (getConfig) to prevent eager parseEnv at import time - Augmented FastifyContextConfig with public boolean for route auth opt-out - Fixed body limit tests to include auth credentials (401 was returned before body parsing could trigger 413/201) - All 84 tests passing, lint clean, 0 audit vulnerabilities --- src/index.test.ts | 14 ++++++++++---- src/index.ts | 5 +++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/src/index.test.ts b/src/index.test.ts index a669702..a9152dd 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -58,15 +58,18 @@ test("rate limit rejects requests above the configured threshold", async () => { }); test("body limit rejects payloads larger than the configured size", async () => { - const app = await buildApp({ bodyLimitBytes: 64 }); + const { key, entry } = generateApiKey("gateway-prod", ["dispute:write"]); + const auth = gatewayAuth(entry); + const app = await buildServer({ bodyLimitBytes: 64, auth }); + await app.ready(); const bigPayload = "x".repeat(128); const res = await app.inject({ method: "POST", url: "/api/v1/disputes", headers: { + authorization: `ApiKey ${key}`, "x-role": "buyer", - "x-user-id": "buyer-1", "content-type": "application/json", }, payload: JSON.stringify({ content: bigPayload }), @@ -78,14 +81,17 @@ test("body limit rejects payloads larger than the configured size", async () => }); test("small payloads within the body limit are accepted", async () => { - const app = await buildApp({ bodyLimitBytes: 4096 }); + const { key, entry } = generateApiKey("gateway-prod", ["dispute:write"]); + const auth = gatewayAuth(entry); + const app = await buildServer({ bodyLimitBytes: 4096, auth }); + await app.ready(); const res = await app.inject({ method: "POST", url: "/api/v1/disputes", headers: { + authorization: `ApiKey ${key}`, "x-role": "buyer", - "x-user-id": "buyer-1", "content-type": "application/json", }, payload: JSON.stringify({ diff --git a/src/index.ts b/src/index.ts index cefe6d7..b792cc0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,11 @@ declare module "fastify" { /** Authentication provider for gateway keys and operator sessions. */ auth: AuthProvider; } + + interface FastifyContextConfig { + /** Routes with `public: true` skip the global auth hook. */ + public?: boolean; + } } export interface BuildServerOptions { From c9a963a4fbaa95a09503618c0d1ce4e5121223f1 Mon Sep 17 00:00:00 2001 From: thebabalola Date: Sun, 30 Aug 2026 00:09:20 +0100 Subject: [PATCH 3/3] fix: add JWT_SECRET to env.exit.test validEnv from #78 merge --- src/config/env.exit.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/config/env.exit.test.ts b/src/config/env.exit.test.ts index 94c57af..ad18edb 100644 --- a/src/config/env.exit.test.ts +++ b/src/config/env.exit.test.ts @@ -25,6 +25,7 @@ const validEnv = { PAYMENT_ROUTER_CONTRACT_ID: "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", CORS_ORIGIN: "https://app.example.com", SIGNING_ENV_SECRET_KEY: "S-THIS-IS-THE-SECRET-VALUE", + JWT_SECRET: "super-secret-key-at-least-32-chars!!", }; // A cwd with no .env file, so `dotenv/config` (imported at the top of