Skip to content

Commit 788534a

Browse files
authored
Merge pull request #35 from AgentWorkforce/fix/api-keys-self-service-auth
fix(server): accept x-api-key on /v1/api-keys (close bootstrap footgun post-#34)
2 parents e7572c0 + 1a84142 commit 788534a

3 files changed

Lines changed: 178 additions & 10 deletions

File tree

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import {
4+
assertJsonResponse,
5+
createTestApp,
6+
createTestRequest,
7+
generateTestToken,
8+
} from "./test-helpers.js";
9+
10+
type ApiKeyRecord = {
11+
id: string;
12+
scopes: string[];
13+
};
14+
15+
type ApiKeyCreateResponse = {
16+
apiKey: ApiKeyRecord;
17+
key: string;
18+
};
19+
20+
function createAdminAuthorizationHeader(): HeadersInit {
21+
return {
22+
Authorization: `Bearer ${generateTestToken({
23+
sub: "agent_admin_api_keys",
24+
org: "org_test",
25+
wks: "ws_admin",
26+
sponsorId: "user_admin_api_keys",
27+
sponsorChain: ["user_admin_api_keys", "agent_admin_api_keys"],
28+
scopes: ["*"],
29+
})}`,
30+
};
31+
}
32+
33+
async function mintApiKeyViaBearer(
34+
app: ReturnType<typeof createTestApp>,
35+
body: { name: string; scopes: string[] },
36+
): Promise<ApiKeyCreateResponse> {
37+
const response = await app.request(
38+
createTestRequest("POST", "/v1/api-keys", body, createAdminAuthorizationHeader()),
39+
undefined,
40+
app.bindings,
41+
);
42+
43+
return assertJsonResponse<ApiKeyCreateResponse>(response, 201);
44+
}
45+
46+
test("POST /v1/api-keys with valid x-api-key mints a new key without a bearer token", async () => {
47+
const app = createTestApp();
48+
const operator = await mintApiKeyViaBearer(app, {
49+
name: "operator-key",
50+
scopes: ["*:*:*:*"],
51+
});
52+
53+
const response = await app.request(
54+
createTestRequest(
55+
"POST",
56+
"/v1/api-keys",
57+
{ name: "minted-via-api-key", scopes: ["relayauth:identity:manage:*"] },
58+
{ "x-api-key": operator.key },
59+
),
60+
undefined,
61+
app.bindings,
62+
);
63+
64+
const body = await assertJsonResponse<ApiKeyCreateResponse>(response, 201);
65+
assert.equal(body.apiKey.scopes[0], "relayauth:identity:manage:*");
66+
assert.ok(body.key.length > 0, "expected a minted api-key value");
67+
});
68+
69+
test("POST /v1/api-keys with x-api-key missing required scope returns 403", async () => {
70+
const app = createTestApp();
71+
const readOnly = await mintApiKeyViaBearer(app, {
72+
name: "read-only-operator",
73+
scopes: ["relayauth:api-key:read:*"],
74+
});
75+
76+
const response = await app.request(
77+
createTestRequest(
78+
"POST",
79+
"/v1/api-keys",
80+
{ name: "should-not-mint", scopes: ["relayauth:identity:manage:*"] },
81+
{ "x-api-key": readOnly.key },
82+
),
83+
undefined,
84+
app.bindings,
85+
);
86+
87+
const body = await assertJsonResponse<{ error: string }>(response, 403);
88+
assert.equal(body.error, "insufficient_scope");
89+
});
90+
91+
test("GET /v1/api-keys with valid x-api-key lists api-keys", async () => {
92+
const app = createTestApp();
93+
const reader = await mintApiKeyViaBearer(app, {
94+
name: "reader-key",
95+
scopes: ["relayauth:api-key:read:*"],
96+
});
97+
98+
const response = await app.request(
99+
createTestRequest("GET", "/v1/api-keys", undefined, { "x-api-key": reader.key }),
100+
undefined,
101+
app.bindings,
102+
);
103+
104+
const body = await assertJsonResponse<{ data: ApiKeyRecord[] }>(response, 200);
105+
assert.ok(Array.isArray(body.data));
106+
assert.ok(body.data.some((row) => row.id === reader.apiKey.id), "listing should include the caller's own api-key");
107+
});
108+
109+
test("POST /v1/api-keys/:id/revoke with valid x-api-key revokes the target key", async () => {
110+
const app = createTestApp();
111+
const operator = await mintApiKeyViaBearer(app, {
112+
name: "revoker-key",
113+
scopes: ["*:*:*:*"],
114+
});
115+
const target = await mintApiKeyViaBearer(app, {
116+
name: "soon-to-be-revoked",
117+
scopes: ["relayauth:identity:read:*"],
118+
});
119+
120+
const response = await app.request(
121+
createTestRequest(
122+
"POST",
123+
`/v1/api-keys/${target.apiKey.id}/revoke`,
124+
{},
125+
{ "x-api-key": operator.key },
126+
),
127+
undefined,
128+
app.bindings,
129+
);
130+
131+
const body = await assertJsonResponse<{ id: string; revoked: boolean }>(response, 200);
132+
assert.equal(body.id, target.apiKey.id);
133+
assert.equal(body.revoked, true);
134+
});
135+
136+
test("POST /v1/api-keys with revoked x-api-key returns 401", async () => {
137+
const app = createTestApp();
138+
const operator = await mintApiKeyViaBearer(app, {
139+
name: "to-be-revoked-operator",
140+
scopes: ["*:*:*:*"],
141+
});
142+
143+
const revokeResponse = await app.request(
144+
createTestRequest(
145+
"POST",
146+
`/v1/api-keys/${operator.apiKey.id}/revoke`,
147+
{},
148+
createAdminAuthorizationHeader(),
149+
),
150+
undefined,
151+
app.bindings,
152+
);
153+
await assertJsonResponse(revokeResponse, 200);
154+
155+
const response = await app.request(
156+
createTestRequest(
157+
"POST",
158+
"/v1/api-keys",
159+
{ name: "should-fail", scopes: ["relayauth:identity:read:*"] },
160+
{ "x-api-key": operator.key },
161+
),
162+
undefined,
163+
app.bindings,
164+
);
165+
166+
const body = await assertJsonResponse<{ error: string; code?: string }>(response, 401);
167+
assert.match(body.error, /api key|revoked|invalid/i);
168+
assert.notEqual(body.code, "missing_authorization");
169+
});

packages/server/src/routes/api-keys.ts

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { matchScope, RelayAuthError, validateSubset } from "@relayauth/sdk";
22
import { Hono, type Context } from "hono";
33
import type { AppEnv } from "../env.js";
44
import { extractPrefix, generateApiKey, hashApiKey } from "../lib/api-keys.js";
5-
import { authenticateAndAuthorize } from "../lib/auth.js";
5+
import { authenticateAndAuthorizeFromContext } from "../lib/auth.js";
66
import { isStorageError } from "../storage/index.js";
77
import type { StoredApiKey } from "../storage/api-key-types.js";
88

@@ -25,9 +25,8 @@ type ApiKeyResponse = {
2525
const apiKeys = new Hono<AppEnv>();
2626

2727
apiKeys.post("/", async (c) => {
28-
const auth = await authenticateAndAuthorize(
29-
c.req.header("authorization"),
30-
c.env,
28+
const auth = await authenticateAndAuthorizeFromContext(
29+
c,
3130
"relayauth:api-key:manage:*",
3231
matchScope,
3332
);
@@ -94,9 +93,8 @@ apiKeys.post("/", async (c) => {
9493
});
9594

9695
apiKeys.get("/", async (c) => {
97-
const auth = await authenticateAndAuthorize(
98-
c.req.header("authorization"),
99-
c.env,
96+
const auth = await authenticateAndAuthorizeFromContext(
97+
c,
10098
"relayauth:api-key:read:*",
10199
matchScope,
102100
);
@@ -133,9 +131,8 @@ apiKeys.get("/", async (c) => {
133131
});
134132

135133
apiKeys.post("/:apiKeyId/revoke", async (c) => {
136-
const auth = await authenticateAndAuthorize(
137-
c.req.header("authorization"),
138-
c.env,
134+
const auth = await authenticateAndAuthorizeFromContext(
135+
c,
139136
"relayauth:api-key:manage:*",
140137
matchScope,
141138
);

packages/server/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ export function createApp(options: CreateAppOptions = {}): Hono<AppEnv> {
127127
app.use("/v1/identities/*", apiKeyAuth());
128128
app.use("/v1/tokens", apiKeyAuth());
129129
app.use("/v1/tokens/*", apiKeyAuth());
130+
app.use("/v1/api-keys", apiKeyAuth());
131+
app.use("/v1/api-keys/*", apiKeyAuth());
130132

131133
app.use("*", async (c, next) => {
132134
if (isPublicPath(c.req.path)) {

0 commit comments

Comments
 (0)