Skip to content

Commit 73813cc

Browse files
fix: Add API key scoped permissions (#319)
* fix: Add API key scoped permissions * fix: Add API key scoped permissions --------- Co-authored-by: Emmanuel Chukwunyere <emmanuelanalaba@gmail.com>
1 parent 8fe3ffc commit 73813cc

10 files changed

Lines changed: 545 additions & 12 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
NEXT_PUBLIC_STELLAR_NETWORK=testnet
22
NEXT_PUBLIC_CONTRACT_ID=YOUR_CONTRACT_ID_HERE
33
NEXT_PUBLIC_RPC_URL=https://soroban-testnet.stellar.org
4+
API_KEY_SIGNING_SECRET=replace-with-a-long-random-server-secret

AUDIT-219.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Audit: Issue #219 API Key Scoped Permissions
2+
3+
## Result
4+
5+
Issue #219 is implemented, including the security gap found during the first audit.
6+
7+
## What Is Fixed
8+
9+
- API key creation now includes a permission scope selector defaulting to `read` in `src/app/settings/api-keys/page.tsx`.
10+
- Generated keys store a `scope: "read" | "write"` field and the key list displays the scope with a badge.
11+
- Existing keys without a `scope` field migrate to `write` for backward-compatible display behavior, and the settings page shows a one-time review banner.
12+
- Mutating API routes under `src/app/api` require write scope through `requireWriteScope`.
13+
- New API keys are minted by `POST /api/api-keys` and signed server-side.
14+
- Write-route authorization now verifies the API key signature instead of trusting the `sk_write_` prefix, so forged keys like `sk_write_anything` are rejected.
15+
- Unit tests cover migration behavior, read-only rejection, write-key allowance, and forged write-key rejection.
16+
17+
## Files Reviewed / Updated
18+
19+
- `src/app/settings/api-keys/page.tsx`
20+
- `src/app/api/api-keys/route.ts`
21+
- `src/app/api/test-webhook/route.ts`
22+
- `src/app/api/send-confirmation/route.ts`
23+
- `src/lib/apiKeys.ts`
24+
- `src/lib/apiKeyAuth.ts`
25+
- `src/lib/signedApiKeys.ts`
26+
- `src/__tests__/apiKeys.test.ts`
27+
- `.env.example`
28+
29+
## Remaining Notes
30+
31+
- Deployments should set a stable `API_KEY_SIGNING_SECRET`. If this secret changes, previously generated signed keys will stop verifying.
32+
- Legacy pre-signed keys are still migrated to `write` in the local settings UI, but unsigned legacy tokens are no longer accepted by protected server routes because the server cannot authenticate browser-local historical keys safely.
33+
34+
## Verification
35+
36+
- `npm install` completed and restored the local Jest package.
37+
- `npm test -- --runTestsByPath src/__tests__/apiKeys.test.ts --runInBand` could not complete because Next's native SWC package fails to load locally: `next-swc.win32-x64-msvc.node is not a valid Win32 application`.
38+
- `npm rebuild @next/swc-win32-x64-msvc` completed, but the SWC loader error persisted.
39+
- `npx tsc --noEmit` could not complete because of an existing unrelated type declaration issue for `html2canvas` in `src/components/AchievementCard.tsx`.

src/__tests__/apiKeys.test.ts

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
/**
2+
* Unit tests for API key scoped permissions (#219):
3+
* - Legacy key migration defaults to write scope
4+
* - Read-only key rejected on write endpoints
5+
* - Write key allowed on write endpoints
6+
*/
7+
8+
import {
9+
STORAGE_KEY,
10+
checkWriteScope,
11+
generateKeyValue,
12+
loadKeys,
13+
parseScopeFromKey,
14+
} from "@/lib/apiKeys";
15+
import { generateSignedApiKey, verifySignedApiKey } from "@/lib/signedApiKeys";
16+
import { POST as testWebhookPost } from "@/app/api/test-webhook/route";
17+
18+
const store: Record<string, string> = {};
19+
const localStorageMock = {
20+
getItem: (k: string) => store[k] ?? null,
21+
setItem: (k: string, v: string) => {
22+
store[k] = v;
23+
},
24+
removeItem: (k: string) => {
25+
delete store[k];
26+
},
27+
clear: () => {
28+
Object.keys(store).forEach((k) => delete store[k]);
29+
},
30+
};
31+
Object.defineProperty(global, "localStorage", { value: localStorageMock });
32+
33+
let fetchMock: jest.Mock;
34+
35+
beforeEach(() => {
36+
localStorageMock.clear();
37+
fetchMock = jest.fn().mockResolvedValue({
38+
ok: true,
39+
status: 200,
40+
text: async () => "ok",
41+
headers: { forEach: () => {} },
42+
});
43+
global.fetch = fetchMock;
44+
});
45+
46+
describe("parseScopeFromKey", () => {
47+
it("returns read for sk_read_ keys", () => {
48+
expect(parseScopeFromKey("sk_read_abc-123")).toBe("read");
49+
});
50+
51+
it("returns write for sk_write_ keys", () => {
52+
expect(parseScopeFromKey("sk_write_abc-123")).toBe("write");
53+
});
54+
55+
it("returns write for legacy sk_<uuid> keys", () => {
56+
expect(parseScopeFromKey("sk_550e8400-e29b-41d4-a716-446655440000")).toBe("write");
57+
});
58+
59+
it("returns null for invalid keys", () => {
60+
expect(parseScopeFromKey("invalid")).toBeNull();
61+
});
62+
});
63+
64+
describe("checkWriteScope", () => {
65+
it("rejects read-only keys with 403", () => {
66+
const result = checkWriteScope(generateSignedApiKey("read").key, verifySignedApiKey);
67+
expect(result).toEqual({
68+
ok: false,
69+
status: 403,
70+
error: "This endpoint requires write scope. Your API key is read-only.",
71+
});
72+
});
73+
74+
it("allows write-scoped keys", () => {
75+
const result = checkWriteScope(generateSignedApiKey("write").key, verifySignedApiKey);
76+
expect(result).toEqual({ ok: true, scope: "write" });
77+
});
78+
79+
it("rejects forged write-prefixed keys", () => {
80+
const result = checkWriteScope("sk_write_anything", verifySignedApiKey);
81+
expect(result).toEqual({ ok: false, status: 401, error: "Invalid API key." });
82+
});
83+
84+
it("returns 401 when token is missing", () => {
85+
const result = checkWriteScope(null);
86+
expect(result.ok).toBe(false);
87+
if (!result.ok) expect(result.status).toBe(401);
88+
});
89+
});
90+
91+
describe("loadKeys migration", () => {
92+
it("defaults legacy keys without scope to write", () => {
93+
localStorageMock.setItem(
94+
STORAGE_KEY,
95+
JSON.stringify([
96+
{
97+
id: "legacy-1",
98+
name: "old-service",
99+
key: "sk_550e8400-e29b-41d4-a716-446655440000",
100+
createdAt: 1_700_000_000_000,
101+
},
102+
])
103+
);
104+
105+
const { keys, migratedLegacy } = loadKeys();
106+
expect(migratedLegacy).toBe(true);
107+
expect(keys).toHaveLength(1);
108+
expect(keys[0]?.scope).toBe("write");
109+
});
110+
111+
it("does not flag keys that already have scope", () => {
112+
localStorageMock.setItem(
113+
STORAGE_KEY,
114+
JSON.stringify([
115+
{
116+
id: "new-1",
117+
name: "readonly",
118+
key: generateKeyValue("read"),
119+
scope: "read",
120+
createdAt: Date.now(),
121+
},
122+
])
123+
);
124+
125+
const { keys, migratedLegacy } = loadKeys();
126+
expect(migratedLegacy).toBe(false);
127+
expect(keys[0]?.scope).toBe("read");
128+
});
129+
});
130+
131+
describe("POST /api/test-webhook scope enforcement", () => {
132+
const url = "https://example.com/hook";
133+
134+
async function callWebhook(token: string | null) {
135+
const headers: Record<string, string> = { "Content-Type": "application/json" };
136+
if (token) headers.Authorization = `Bearer ${token}`;
137+
138+
const req = new Request("http://localhost/api/test-webhook", {
139+
method: "POST",
140+
headers,
141+
body: JSON.stringify({ url }),
142+
});
143+
144+
return testWebhookPost(req as unknown as import("next/server").NextRequest);
145+
}
146+
147+
it("rejects read-only keys with 403", async () => {
148+
const res = await callWebhook(generateSignedApiKey("read").key);
149+
expect(res.status).toBe(403);
150+
const body = await res.json();
151+
expect(body.error).toContain("write scope");
152+
expect(fetchMock).not.toHaveBeenCalled();
153+
});
154+
155+
it("allows write-scoped keys", async () => {
156+
const res = await callWebhook(generateSignedApiKey("write").key);
157+
expect(res.status).toBe(200);
158+
expect(fetchMock).toHaveBeenCalled();
159+
});
160+
161+
it("rejects forged write keys", async () => {
162+
const res = await callWebhook("sk_write_anything");
163+
expect(res.status).toBe(401);
164+
const body = await res.json();
165+
expect(body.error).toBe("Invalid API key.");
166+
expect(fetchMock).not.toHaveBeenCalled();
167+
});
168+
});

src/app/api/api-keys/route.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { type ApiKeyScope } from "@/lib/apiKeys";
3+
import { generateSignedApiKey } from "@/lib/signedApiKeys";
4+
5+
function isScope(value: unknown): value is ApiKeyScope {
6+
return value === "read" || value === "write";
7+
}
8+
9+
export async function POST(request: NextRequest) {
10+
const body = await request.json().catch(() => null);
11+
const scope = body?.scope;
12+
13+
if (!isScope(scope)) {
14+
return NextResponse.json({ error: "scope must be read or write" }, { status: 400 });
15+
}
16+
17+
return NextResponse.json(generateSignedApiKey(scope));
18+
}

src/app/api/send-confirmation/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextRequest, NextResponse } from "next/server";
2+
import { requireWriteScope } from "@/lib/apiKeyAuth";
23

34
interface ConfirmationRequest {
45
email: string;
@@ -8,6 +9,9 @@ interface ConfirmationRequest {
89
}
910

1011
export async function POST(request: NextRequest) {
12+
const authError = requireWriteScope(request);
13+
if (authError) return authError;
14+
1115
try {
1216
const body: ConfirmationRequest = await request.json();
1317
const { email, invoiceId, txHash, amount } = body;

src/app/api/test-webhook/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextRequest, NextResponse } from "next/server";
2+
import { requireWriteScope } from "@/lib/apiKeyAuth";
23

34
const SAMPLE_PAYLOADS: Record<string, object> = {
45
"invoice.created": {
@@ -37,6 +38,9 @@ const SAMPLE_PAYLOADS: Record<string, object> = {
3738
};
3839

3940
export async function POST(req: NextRequest) {
41+
const authError = requireWriteScope(req);
42+
if (authError) return authError;
43+
4044
const body = await req.json().catch(() => null);
4145
if (!body || typeof body.url !== "string" || !body.url) {
4246
return NextResponse.json({ error: "url is required" }, { status: 400 });

0 commit comments

Comments
 (0)