Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=[]
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
111 changes: 111 additions & 0 deletions src/auth/index.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
27 changes: 27 additions & 0 deletions src/auth/plugin.ts
Original file line number Diff line number Diff line change
@@ -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;
});
}
151 changes: 151 additions & 0 deletions src/auth/provider.ts
Original file line number Diff line number Diff line change
@@ -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_<name>_<secret>. 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<AuthenticatedIdentity | null> {
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;
},
};
}
44 changes: 44 additions & 0 deletions src/auth/testing.ts
Original file line number Diff line number Diff line change
@@ -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: <subject>,<scope1>,<scope2>`.
*/
export function createTestAuthProvider(
defaults: TestAuthProviderOptions = {},
): AuthProvider {
const headerName = defaults.header ?? "x-test-auth";

return {
async authenticate(req: FastifyRequest): Promise<AuthenticatedIdentity | null> {
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<string> {
return "test-token";
},

verifyScope(req, scope) {
return req.identity?.scopes.includes(scope) ?? false;
},
};
}
Loading
Loading