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
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ API gateway, metering, and billing backend for the AgentPay protocol (machine-to
agentpay-backend/
├── src/
│ ├── index.ts # Thin Express composition root that exports app
│ ├── auth/ # API-key hashing, creation, and constant-time checks
│ ├── events.ts # Bounded in-memory audit event log helpers
│ ├── middleware/ # CORS, security headers, request id, pause, rate limit
│ ├── routes/ # Feature routers for admin, usage, services, keys, webhooks
Expand Down Expand Up @@ -84,11 +85,39 @@ npm run build
npm start
```

The API is currently open for local development and demos. You do not need an
API key for the metering flow until API-key enforcement lands. Add your own
The API is open by default for local development and demos. Add your own
`X-Request-Id` header when you want to correlate client logs with backend
responses. The backend echoes the value on success and structured errors.

### Authentication

Set `REQUIRE_API_KEY=true` to require credentials on state-changing routes.
`GET`, `HEAD`, and `OPTIONS` remain open for dashboards, health checks, and
metadata readers. Non-admin write routes require a valid tenant key in the
`X-API-Key` header:

```bash
curl -sS -X POST "$BASE_URL/api/v1/usage" \
-H "Content-Type: application/json" \
-H "X-API-Key: $AGENTPAY_API_KEY" \
-d '{"agent":"agent-alpha","serviceId":"embedding-v1","requests":3}'
```

Tenant keys are returned once from `POST /api/v1/api-keys`. The in-memory store
keeps only a SHA-256 hash plus the public 8-character prefix, and
`GET /api/v1/api-keys` never returns the live secret.

Set `ADMIN_API_KEY` alongside `REQUIRE_API_KEY=true` for privileged writes.
`POST /api/v1/admin/*` and API-key creation/revocation require this admin key
instead of a tenant key:

```bash
curl -sS -X POST "$BASE_URL/api/v1/api-keys" \
-H "Content-Type: application/json" \
-H "X-API-Key: $ADMIN_API_KEY" \
-d '{"label":"ops"}'
```

Set a shell variable for the local base URL:

```bash
Expand Down
26 changes: 18 additions & 8 deletions src/apikey-recognition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,22 @@ import {
import type { AgentPayRequest } from "./types.js";

// We create a minimal test app that mounts the same middleware stack as the
// real app to act as our "test-only assertion path" for observing req.apiKey.
// real app to act as our test-only path for observing request API-key metadata.
const testApp = express();
installPreRouteMiddleware(testApp);
installRequestStateMiddleware(testApp);
testApp.get("/_test/api-key", (req, res) => {
res.json({ apiKey: (req as AgentPayRequest).apiKey });
res.json({
apiKeyHash: (req as AgentPayRequest).apiKeyHash,
apiKeyPrefix: (req as AgentPayRequest).apiKeyPrefix,
});
});

void describe("API Key Recognition Middleware", () => {
beforeEach(() => {
apiKeyStore.clear();
delete process.env.REQUIRE_API_KEY;
delete process.env.ADMIN_API_KEY;
});

void it("recognises a created key (case-insensitive header, exact value)", async () => {
Expand All @@ -35,23 +40,27 @@ void describe("API Key Recognition Middleware", () => {

// 2. Assert it is recognised via exact case
const resExact = await request(testApp).get("/_test/api-key").set("X-API-Key", key);
assert.strictEqual(resExact.body.apiKey, key);
assert.strictEqual(resExact.body.apiKeyPrefix, key.slice(0, 8));
assert.ok(resExact.body.apiKeyHash);

// 3. Assert it is recognised via lowercase header
const resLower = await request(testApp).get("/_test/api-key").set("x-api-key", key);
assert.strictEqual(resLower.body.apiKey, key);
assert.strictEqual(resLower.body.apiKeyPrefix, key.slice(0, 8));
assert.strictEqual(resLower.body.apiKeyHash, resExact.body.apiKeyHash);
});

void it("silently ignores an unknown key", async () => {
const res = await request(testApp)
.get("/_test/api-key")
.set("X-API-Key", "apk_unknown123");
assert.strictEqual(res.body.apiKey, undefined);
assert.strictEqual(res.body.apiKeyHash, undefined);
assert.strictEqual(res.body.apiKeyPrefix, undefined);
});

void it("leaves apiKey undefined if no key is provided", async () => {
void it("leaves API key metadata undefined if no key is provided", async () => {
const res = await request(testApp).get("/_test/api-key");
assert.strictEqual(res.body.apiKey, undefined);
assert.strictEqual(res.body.apiKeyHash, undefined);
assert.strictEqual(res.body.apiKeyPrefix, undefined);
});

void it("silently ignores a revoked key", async () => {
Expand All @@ -67,7 +76,8 @@ void describe("API Key Recognition Middleware", () => {

// Try to use the revoked key
const res = await request(testApp).get("/_test/api-key").set("X-API-Key", key);
assert.strictEqual(res.body.apiKey, undefined);
assert.strictEqual(res.body.apiKeyHash, undefined);
assert.strictEqual(res.body.apiKeyPrefix, undefined);
});

void it("ensures the API remains open: a write with no X-API-Key still succeeds", async () => {
Expand Down
142 changes: 142 additions & 0 deletions src/auth/apiKeys.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { beforeEach, describe, it } from "node:test";
import assert from "node:assert";
import request from "supertest";
import { createApp } from "../index.js";
import { apiKeyStore, pauseState, usageStore } from "../store/state.js";

beforeEach(() => {
apiKeyStore.clear();
usageStore.clear();
pauseState.paused = false;
delete process.env.REQUIRE_API_KEY;
delete process.env.ADMIN_API_KEY;
});

async function createTenantKey(): Promise<string> {
const app = createApp();
const created = await request(app).post("/api/v1/api-keys").send({ label: "tenant" });
assert.strictEqual(created.status, 201);
return readCreatedKey(created.body);
}

function readCreatedKey(body: unknown): string {
const key =
body && typeof body === "object" && "key" in body
? (body as { key: unknown }).key
: undefined;
if (typeof key !== "string") {
throw new TypeError("expected API-key response to include a string key");
}
assert.match(key, /^apk_/);
return key;
}

void describe("API key authentication", () => {
void it("keeps write endpoints open when REQUIRE_API_KEY is disabled", async () => {
const app = createApp();

const res = await request(app)
.post("/api/v1/usage")
.send({ agent: "agent-open", serviceId: "svc-open", requests: 1 });

assert.strictEqual(res.status, 201);
assert.strictEqual(res.body.total, 1);
});

void it("requires a valid tenant API key for state-changing non-admin routes", async () => {
const key = await createTenantKey();
process.env.REQUIRE_API_KEY = "true";
const app = createApp();

const read = await request(app).get("/api/v1/usage/agent-auth/svc-auth");
assert.strictEqual(read.status, 200);

const missing = await request(app)
.post("/api/v1/usage")
.send({ agent: "agent-auth", serviceId: "svc-auth", requests: 1 });
assert.strictEqual(missing.status, 401);
assert.strictEqual(missing.body.error, "unauthorized");
assert.ok(missing.body.requestId);

const unknown = await request(app)
.post("/api/v1/usage")
.set("X-API-Key", "apk_unknown")
.send({ agent: "agent-auth", serviceId: "svc-auth", requests: 1 });
assert.strictEqual(unknown.status, 401);
assert.strictEqual(unknown.body.error, "unauthorized");
assert.ok(unknown.body.requestId);

const accepted = await request(app)
.post("/api/v1/usage")
.set("X-API-Key", key)
.send({ agent: "agent-auth", serviceId: "svc-auth", requests: 2 });
assert.strictEqual(accepted.status, 201);
assert.strictEqual(accepted.body.total, 2);
});

void it("stores tenant API keys hashed and lists only the public prefix", async () => {
const key = await createTenantKey();
const prefix = key.slice(0, 8);

assert.strictEqual(apiKeyStore.has(key), false);

const listed = await request(createApp()).get("/api/v1/api-keys");
assert.strictEqual(listed.status, 200);
assert.strictEqual(listed.body.items.length, 1);
assert.strictEqual(listed.body.items[0].prefix, prefix);
assert.strictEqual(listed.body.items[0].key, undefined);
});

void it("requires ADMIN_API_KEY for API-key creation when enforcement is enabled", async () => {
process.env.REQUIRE_API_KEY = "true";
process.env.ADMIN_API_KEY = "admin-secret";
const app = createApp();

const missing = await request(app)
.post("/api/v1/api-keys")
.send({ label: "new-tenant" });
assert.strictEqual(missing.status, 401);
assert.strictEqual(missing.body.error, "unauthorized");

const created = await request(app)
.post("/api/v1/api-keys")
.set("X-API-Key", "admin-secret")
.send({ label: "new-tenant" });
assert.strictEqual(created.status, 201);
const key = readCreatedKey(created.body);
assert.strictEqual(apiKeyStore.has(key), false);
});

void it("requires ADMIN_API_KEY instead of a tenant key for admin writes", async () => {
const tenantKey = await createTenantKey();
process.env.REQUIRE_API_KEY = "true";
process.env.ADMIN_API_KEY = "admin-secret";
const app = createApp();

const status = await request(app).get("/api/v1/admin/status");
assert.strictEqual(status.status, 200);

const missing = await request(app).post("/api/v1/admin/pause");
assert.strictEqual(missing.status, 401);
assert.strictEqual(missing.body.error, "unauthorized");
assert.ok(missing.body.requestId);

const tenant = await request(app)
.post("/api/v1/admin/pause")
.set("X-API-Key", tenantKey);
assert.strictEqual(tenant.status, 401);
assert.strictEqual(tenant.body.error, "unauthorized");

const wrongAdmin = await request(app)
.post("/api/v1/admin/pause")
.set("X-API-Key", "admin-wrong");
assert.strictEqual(wrongAdmin.status, 401);
assert.strictEqual(wrongAdmin.body.error, "unauthorized");

const accepted = await request(app)
.post("/api/v1/admin/pause")
.set("X-API-Key", "admin-secret");
assert.strictEqual(accepted.status, 200);
assert.strictEqual(accepted.body.paused, true);
});
});
68 changes: 68 additions & 0 deletions src/auth/apiKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
import type { ApiKeyRecord } from "../store/state.js";

export type VerifiedApiKey = {
hash: string;
prefix: string;
record: ApiKeyRecord;
};

/** Hashes an API key before it is stored or compared. */
export function hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}

/** Creates a tenant API key and the hashed in-memory record for it. */
export function createApiKeyRecord(
label: string,
now = Date.now()
): {
key: string;
hash: string;
record: ApiKeyRecord;
} {
const key = `apk_${randomUUID().replace(/-/g, "")}`;
return {
key,
hash: hashApiKey(key),
record: {
label,
createdAt: now,
prefix: key.slice(0, 8),
},
};
}

/** Compares fixed-format SHA-256 hex digests without early-exit string checks. */
export function timingSafeEqualHex(leftHex: string, rightHex: string): boolean {
const left = Buffer.from(leftHex, "hex");
const right = Buffer.from(rightHex, "hex");
return left.length === right.length && timingSafeEqual(left, right);
}

/** Compares two secrets by digest so length differences do not leak directly. */
export function timingSafeEqualSecret(supplied: string, expected: string): boolean {
return timingSafeEqualHex(hashApiKey(supplied), hashApiKey(expected));
}

/**
* Finds a supplied tenant key in the hashed store while comparing every stored
* hash so the matching key does not determine the loop exit timing.
*/
export function verifyApiKey(
supplied: string | undefined,
store: Map<string, ApiKeyRecord>
): VerifiedApiKey | undefined {
if (typeof supplied !== "string" || supplied.length === 0) {
return undefined;
}

const suppliedHash = hashApiKey(supplied);
let matched: VerifiedApiKey | undefined;
for (const [storedHash, record] of store.entries()) {
if (timingSafeEqualHex(suppliedHash, storedHash)) {
matched = { hash: storedHash, prefix: record.prefix, record };
}
}
return matched;
}
Loading