diff --git a/packages/server/src/__tests__/api-keys-apikey-auth.test.ts b/packages/server/src/__tests__/api-keys-apikey-auth.test.ts new file mode 100644 index 0000000..f7f040f --- /dev/null +++ b/packages/server/src/__tests__/api-keys-apikey-auth.test.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + assertJsonResponse, + createTestApp, + createTestRequest, + generateTestToken, +} from "./test-helpers.js"; + +type ApiKeyRecord = { + id: string; + scopes: string[]; +}; + +type ApiKeyCreateResponse = { + apiKey: ApiKeyRecord; + key: string; +}; + +function createAdminAuthorizationHeader(): HeadersInit { + return { + Authorization: `Bearer ${generateTestToken({ + sub: "agent_admin_api_keys", + org: "org_test", + wks: "ws_admin", + sponsorId: "user_admin_api_keys", + sponsorChain: ["user_admin_api_keys", "agent_admin_api_keys"], + scopes: ["*"], + })}`, + }; +} + +async function mintApiKeyViaBearer( + app: ReturnType, + body: { name: string; scopes: string[] }, +): Promise { + const response = await app.request( + createTestRequest("POST", "/v1/api-keys", body, createAdminAuthorizationHeader()), + undefined, + app.bindings, + ); + + return assertJsonResponse(response, 201); +} + +test("POST /v1/api-keys with valid x-api-key mints a new key without a bearer token", async () => { + const app = createTestApp(); + const operator = await mintApiKeyViaBearer(app, { + name: "operator-key", + scopes: ["*:*:*:*"], + }); + + const response = await app.request( + createTestRequest( + "POST", + "/v1/api-keys", + { name: "minted-via-api-key", scopes: ["relayauth:identity:manage:*"] }, + { "x-api-key": operator.key }, + ), + undefined, + app.bindings, + ); + + const body = await assertJsonResponse(response, 201); + assert.equal(body.apiKey.scopes[0], "relayauth:identity:manage:*"); + assert.ok(body.key.length > 0, "expected a minted api-key value"); +}); + +test("POST /v1/api-keys with x-api-key missing required scope returns 403", async () => { + const app = createTestApp(); + const readOnly = await mintApiKeyViaBearer(app, { + name: "read-only-operator", + scopes: ["relayauth:api-key:read:*"], + }); + + const response = await app.request( + createTestRequest( + "POST", + "/v1/api-keys", + { name: "should-not-mint", scopes: ["relayauth:identity:manage:*"] }, + { "x-api-key": readOnly.key }, + ), + undefined, + app.bindings, + ); + + const body = await assertJsonResponse<{ error: string }>(response, 403); + assert.equal(body.error, "insufficient_scope"); +}); + +test("GET /v1/api-keys with valid x-api-key lists api-keys", async () => { + const app = createTestApp(); + const reader = await mintApiKeyViaBearer(app, { + name: "reader-key", + scopes: ["relayauth:api-key:read:*"], + }); + + const response = await app.request( + createTestRequest("GET", "/v1/api-keys", undefined, { "x-api-key": reader.key }), + undefined, + app.bindings, + ); + + const body = await assertJsonResponse<{ data: ApiKeyRecord[] }>(response, 200); + assert.ok(Array.isArray(body.data)); + assert.ok(body.data.some((row) => row.id === reader.apiKey.id), "listing should include the caller's own api-key"); +}); + +test("POST /v1/api-keys/:id/revoke with valid x-api-key revokes the target key", async () => { + const app = createTestApp(); + const operator = await mintApiKeyViaBearer(app, { + name: "revoker-key", + scopes: ["*:*:*:*"], + }); + const target = await mintApiKeyViaBearer(app, { + name: "soon-to-be-revoked", + scopes: ["relayauth:identity:read:*"], + }); + + const response = await app.request( + createTestRequest( + "POST", + `/v1/api-keys/${target.apiKey.id}/revoke`, + {}, + { "x-api-key": operator.key }, + ), + undefined, + app.bindings, + ); + + const body = await assertJsonResponse<{ id: string; revoked: boolean }>(response, 200); + assert.equal(body.id, target.apiKey.id); + assert.equal(body.revoked, true); +}); + +test("POST /v1/api-keys with revoked x-api-key returns 401", async () => { + const app = createTestApp(); + const operator = await mintApiKeyViaBearer(app, { + name: "to-be-revoked-operator", + scopes: ["*:*:*:*"], + }); + + const revokeResponse = await app.request( + createTestRequest( + "POST", + `/v1/api-keys/${operator.apiKey.id}/revoke`, + {}, + createAdminAuthorizationHeader(), + ), + undefined, + app.bindings, + ); + await assertJsonResponse(revokeResponse, 200); + + const response = await app.request( + createTestRequest( + "POST", + "/v1/api-keys", + { name: "should-fail", scopes: ["relayauth:identity:read:*"] }, + { "x-api-key": operator.key }, + ), + undefined, + app.bindings, + ); + + const body = await assertJsonResponse<{ error: string; code?: string }>(response, 401); + assert.match(body.error, /api key|revoked|invalid/i); + assert.notEqual(body.code, "missing_authorization"); +}); diff --git a/packages/server/src/routes/api-keys.ts b/packages/server/src/routes/api-keys.ts index 44fa68e..ad607ff 100644 --- a/packages/server/src/routes/api-keys.ts +++ b/packages/server/src/routes/api-keys.ts @@ -2,7 +2,7 @@ import { matchScope, RelayAuthError, validateSubset } from "@relayauth/sdk"; import { Hono, type Context } from "hono"; import type { AppEnv } from "../env.js"; import { extractPrefix, generateApiKey, hashApiKey } from "../lib/api-keys.js"; -import { authenticateAndAuthorize } from "../lib/auth.js"; +import { authenticateAndAuthorizeFromContext } from "../lib/auth.js"; import { isStorageError } from "../storage/index.js"; import type { StoredApiKey } from "../storage/api-key-types.js"; @@ -25,9 +25,8 @@ type ApiKeyResponse = { const apiKeys = new Hono(); apiKeys.post("/", async (c) => { - const auth = await authenticateAndAuthorize( - c.req.header("authorization"), - c.env, + const auth = await authenticateAndAuthorizeFromContext( + c, "relayauth:api-key:manage:*", matchScope, ); @@ -94,9 +93,8 @@ apiKeys.post("/", async (c) => { }); apiKeys.get("/", async (c) => { - const auth = await authenticateAndAuthorize( - c.req.header("authorization"), - c.env, + const auth = await authenticateAndAuthorizeFromContext( + c, "relayauth:api-key:read:*", matchScope, ); @@ -133,9 +131,8 @@ apiKeys.get("/", async (c) => { }); apiKeys.post("/:apiKeyId/revoke", async (c) => { - const auth = await authenticateAndAuthorize( - c.req.header("authorization"), - c.env, + const auth = await authenticateAndAuthorizeFromContext( + c, "relayauth:api-key:manage:*", matchScope, ); diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index e6db9e2..b08ea55 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -127,6 +127,8 @@ export function createApp(options: CreateAppOptions = {}): Hono { app.use("/v1/identities/*", apiKeyAuth()); app.use("/v1/tokens", apiKeyAuth()); app.use("/v1/tokens/*", apiKeyAuth()); + app.use("/v1/api-keys", apiKeyAuth()); + app.use("/v1/api-keys/*", apiKeyAuth()); app.use("*", async (c, next) => { if (isPublicPath(c.req.path)) {