Skip to content

Commit 0e4984d

Browse files
committed
Allow choosing API key scopes
1 parent b720341 commit 0e4984d

7 files changed

Lines changed: 210 additions & 11 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ Notes:
331331
Personal API keys for automation clients:
332332

333333
- Create and manage keys from the authenticated account API: `GET /auth/api-keys`, `POST /auth/api-keys`, and `DELETE /auth/api-keys/:id`.
334-
- `POST /auth/api-keys` accepts `{ "name": "Obsidian" }` and returns the plaintext token once. Store it immediately; ExcaliDash stores only a hash and metadata.
334+
- `POST /auth/api-keys` accepts `{ "name": "Obsidian" }` and optional `scopes`, for example `{ "name": "Obsidian", "scopes": ["drawings:read", "drawings:write"] }`. If omitted, all API-key scopes are granted. Store the returned plaintext token immediately; ExcaliDash stores only a hash and metadata.
335335
- Automation clients such as the Obsidian plugin should call normal drawing and collection APIs with `Authorization: Bearer <key>`, for example `Authorization: Bearer exd_...`.
336336
- API keys are scoped automation credentials for `/drawings` and `/collections` only. They cannot manage account settings, create/revoke API keys, call admin endpoints, or use import/export APIs.
337337
- API-key Bearer requests without browser `Origin`/`Referer` headers do not require CSRF tokens. Cookie-authenticated browser requests keep the existing CSRF behavior.

backend/src/auth/accountRoutes.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const buildApp = (options?: { impersonatorId?: string }) => {
2323
},
2424
apiKey: {
2525
findFirst: vi.fn(),
26+
findMany: vi.fn(),
27+
create: vi.fn(),
2628
update: vi.fn(),
2729
},
2830
} as any;
@@ -141,4 +143,82 @@ describe("accountRoutes local-password safeguards", () => {
141143
expect(prisma.apiKey.findFirst).not.toHaveBeenCalled();
142144
expect(prisma.apiKey.update).not.toHaveBeenCalled();
143145
});
146+
147+
it("creates API keys with sanitized custom scopes", async () => {
148+
const { app, prisma } = buildApp();
149+
prisma.apiKey.create.mockImplementation(async ({ data }: any) => ({
150+
id: "key-1",
151+
name: data.name,
152+
prefix: data.prefix,
153+
scopes: data.scopes,
154+
lastUsedAt: null,
155+
revokedAt: null,
156+
createdAt: new Date("2026-05-01T12:00:00.000Z"),
157+
updatedAt: new Date("2026-05-01T12:00:00.000Z"),
158+
}));
159+
160+
const response = await request(app).post("/api-keys").send({
161+
name: "Scoped Key",
162+
scopes: [" drawings:read ", "drawings:read", "collections:write"],
163+
});
164+
165+
expect(response.status).toBe(201);
166+
expect(prisma.apiKey.create).toHaveBeenCalledWith(expect.objectContaining({
167+
data: expect.objectContaining({
168+
name: "Scoped Key",
169+
scopes: "drawings:read,collections:write",
170+
}),
171+
}));
172+
expect(response.body.apiKey.scopes).toEqual(["drawings:read", "collections:write"]);
173+
expect(response.body.token).toMatch(/^exd_/);
174+
});
175+
176+
it("keeps default API key scopes when scopes are omitted", async () => {
177+
const { app, prisma } = buildApp();
178+
prisma.apiKey.create.mockImplementation(async ({ data }: any) => ({
179+
id: "key-1",
180+
name: data.name,
181+
prefix: data.prefix,
182+
scopes: data.scopes,
183+
lastUsedAt: null,
184+
revokedAt: null,
185+
createdAt: new Date("2026-05-01T12:00:00.000Z"),
186+
updatedAt: new Date("2026-05-01T12:00:00.000Z"),
187+
}));
188+
189+
const response = await request(app).post("/api-keys").send({ name: "Default Key" });
190+
191+
expect(response.status).toBe(201);
192+
expect(prisma.apiKey.create).toHaveBeenCalledWith(expect.objectContaining({
193+
data: expect.objectContaining({
194+
scopes: "drawings:read,drawings:write,collections:read,collections:write",
195+
}),
196+
}));
197+
});
198+
199+
it("rejects API key creation with no scopes", async () => {
200+
const { app, prisma } = buildApp();
201+
202+
const response = await request(app).post("/api-keys").send({
203+
name: "No Scopes",
204+
scopes: [],
205+
});
206+
207+
expect(response.status).toBe(400);
208+
expect(response.body?.message).toContain("at least one");
209+
expect(prisma.apiKey.create).not.toHaveBeenCalled();
210+
});
211+
212+
it("rejects API key creation with invalid scopes", async () => {
213+
const { app, prisma } = buildApp();
214+
215+
const response = await request(app).post("/api-keys").send({
216+
name: "Bad Scope",
217+
scopes: ["drawings:read", "admin:write"],
218+
});
219+
220+
expect(response.status).toBe(400);
221+
expect(response.body?.message).toContain("valid API key scope");
222+
expect(prisma.apiKey.create).not.toHaveBeenCalled();
223+
});
144224
});

backend/src/auth/accountRoutes.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,16 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
9090
updatedAt: apiKey.updatedAt,
9191
});
9292

93+
const normalizeApiKeyScopes = (scopes: string[] | undefined): string[] | null => {
94+
if (!scopes) return [...DEFAULT_API_KEY_SCOPES];
95+
const allowedScopes = new Set<string>(DEFAULT_API_KEY_SCOPES);
96+
const normalized = Array.from(new Set(scopes.map((scope) => scope.trim()).filter(Boolean)));
97+
if (normalized.length === 0 || normalized.some((scope) => !allowedScopes.has(scope))) {
98+
return null;
99+
}
100+
return normalized;
101+
};
102+
93103
router.post("/password-reset-request", loginAttemptRateLimiter, async (req: Request, res: Response) => {
94104
if (!(await ensureAuthEnabled(res))) return;
95105
if (!config.enablePasswordReset) {
@@ -363,6 +373,14 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
363373
});
364374
}
365375

376+
const scopes = normalizeApiKeyScopes(parsed.data.scopes);
377+
if (!scopes) {
378+
return res.status(400).json({
379+
error: "Validation error",
380+
message: "Select at least one valid API key scope",
381+
});
382+
}
383+
366384
const generated = generateApiKey();
367385
const apiKey = await prisma.apiKey.create({
368386
data: {
@@ -371,7 +389,7 @@ export const registerAccountRoutes = (deps: RegisterAccountRoutesDeps) => {
371389
keyId: generated.keyId,
372390
tokenHash: generated.tokenHash,
373391
prefix: generated.prefix,
374-
scopes: serializeApiKeyScopes(DEFAULT_API_KEY_SCOPES),
392+
scopes: serializeApiKeyScopes(scopes),
375393
},
376394
select: {
377395
id: true,

backend/src/auth/schemas.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,5 @@ export const mustResetPasswordSchema = z.object({
127127

128128
export const apiKeyCreateSchema = z.object({
129129
name: z.string().trim().min(1).max(100),
130+
scopes: z.array(z.string()).optional(),
130131
});

frontend/src/api/index.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,13 @@ export interface CreateApiKeyResponse {
119119
token: string;
120120
}
121121

122+
export const API_KEY_SCOPES = [
123+
"drawings:read",
124+
"drawings:write",
125+
"collections:read",
126+
"collections:write",
127+
] as const;
128+
122129
export const authStatus = async (): Promise<AuthStatusResponse> => {
123130
const response = await axios.get<AuthStatusResponse>(
124131
`${API_URL}/auth/status`,
@@ -183,8 +190,8 @@ export const listApiKeys = async (): Promise<ApiKeyMetadata[]> => {
183190
return response.data.apiKeys;
184191
};
185192

186-
export const createApiKey = async (name: string): Promise<CreateApiKeyResponse> => {
187-
const response = await api.post<CreateApiKeyResponse>("/auth/api-keys", { name });
193+
export const createApiKey = async (name: string, scopes?: string[]): Promise<CreateApiKeyResponse> => {
194+
const response = await api.post<CreateApiKeyResponse>("/auth/api-keys", { name, scopes });
188195
return response.data;
189196
};
190197

frontend/src/pages/Profile.apiKeys.test.tsx

Lines changed: 55 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ vi.mock("../components/Layout", () => ({
2323
}));
2424

2525
vi.mock("../api", () => ({
26+
API_KEY_SCOPES: ["drawings:read", "drawings:write", "collections:read", "collections:write"],
2627
api: {
2728
put: vi.fn(),
2829
post: vi.fn(),
@@ -54,15 +55,16 @@ describe("Profile API keys", () => {
5455
mockAuthUser.mockReturnValue({ id: "user-1", email: "user@example.com", name: "User One" });
5556
vi.mocked(api.getCollections).mockResolvedValue([]);
5657
vi.mocked(api.listApiKeys).mockResolvedValue([existingApiKey]);
57-
vi.mocked(api.createApiKey).mockResolvedValue({
58+
vi.mocked(api.createApiKey).mockImplementation(async (name, scopes) => ({
5859
apiKey: {
5960
...existingApiKey,
6061
id: "key-2",
61-
name: "CI Token",
62+
name,
6263
prefix: "exd_key_new456",
64+
scopes: scopes ?? ["drawings:read", "drawings:write", "collections:read", "collections:write"],
6365
},
6466
token: "exd_key_new456.secret-token-value",
65-
});
67+
}));
6668
vi.mocked(api.revokeApiKey).mockResolvedValue(undefined);
6769
Object.defineProperty(navigator, "clipboard", {
6870
configurable: true,
@@ -89,7 +91,12 @@ describe("Profile API keys", () => {
8991

9092
expect(await screen.findByDisplayValue("exd_key_new456.secret-token-value")).toBeInTheDocument();
9193
expect(screen.getByText(/copy this token now/i)).toBeInTheDocument();
92-
expect(api.createApiKey).toHaveBeenCalledWith("CI Token");
94+
expect(api.createApiKey).toHaveBeenCalledWith("CI Token", [
95+
"drawings:read",
96+
"drawings:write",
97+
"collections:read",
98+
"collections:write",
99+
]);
93100

94101
fireEvent.click(screen.getByRole("button", { name: /copy generated api token/i }));
95102
await waitFor(() => {
@@ -154,4 +161,48 @@ describe("Profile API keys", () => {
154161
resolveApiKeys([existingApiKey]);
155162
expect(await screen.findByText("Existing Key")).toBeInTheDocument();
156163
});
164+
165+
it("submits and displays custom API key scopes", async () => {
166+
render(
167+
<MemoryRouter>
168+
<Profile />
169+
</MemoryRouter>
170+
);
171+
172+
await screen.findByText("Existing Key");
173+
174+
fireEvent.change(screen.getByLabelText(/api key name/i), {
175+
target: { value: "Read Token" },
176+
});
177+
fireEvent.click(screen.getByLabelText(/write drawings/i));
178+
fireEvent.click(screen.getByLabelText(/read collections/i));
179+
fireEvent.click(screen.getByLabelText(/write collections/i));
180+
fireEvent.click(screen.getByRole("button", { name: /create api key/i }));
181+
182+
expect(await screen.findByDisplayValue("exd_key_new456.secret-token-value")).toBeInTheDocument();
183+
expect(api.createApiKey).toHaveBeenCalledWith("Read Token", ["drawings:read"]);
184+
expect(screen.getAllByText("drawings:read").length).toBeGreaterThan(0);
185+
});
186+
187+
it("disables API key creation and shows validation when no scopes are selected", async () => {
188+
render(
189+
<MemoryRouter>
190+
<Profile />
191+
</MemoryRouter>
192+
);
193+
194+
await screen.findByText("Existing Key");
195+
196+
fireEvent.change(screen.getByLabelText(/api key name/i), {
197+
target: { value: "No Scope Token" },
198+
});
199+
fireEvent.click(screen.getByLabelText(/read drawings/i));
200+
fireEvent.click(screen.getByLabelText(/write drawings/i));
201+
fireEvent.click(screen.getByLabelText(/read collections/i));
202+
fireEvent.click(screen.getByLabelText(/write collections/i));
203+
204+
expect(screen.getByText(/select at least one api key scope/i)).toBeInTheDocument();
205+
expect(screen.getByRole("button", { name: /create api key/i })).toBeDisabled();
206+
expect(api.createApiKey).not.toHaveBeenCalled();
207+
});
157208
});

frontend/src/pages/Profile.tsx

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ const formatApiKeyDate = (value: string | null) => {
2727
return new Date(value).toLocaleString();
2828
};
2929

30+
const API_KEY_SCOPE_LABELS: Record<string, string> = {
31+
'drawings:read': 'Read drawings',
32+
'drawings:write': 'Write drawings',
33+
'collections:read': 'Read collections',
34+
'collections:write': 'Write collections',
35+
};
36+
3037
export const Profile: React.FC = () => {
3138
const { user: authUser, logout, authEnabled } = useAuth();
3239
const navigate = useNavigate();
@@ -51,6 +58,7 @@ export const Profile: React.FC = () => {
5158
const [apiKeys, setApiKeys] = useState<api.ApiKeyMetadata[]>([]);
5259
const [apiKeysLoading, setApiKeysLoading] = useState(false);
5360
const [apiKeyName, setApiKeyName] = useState('');
61+
const [selectedApiKeyScopes, setSelectedApiKeyScopes] = useState<string[]>([...api.API_KEY_SCOPES]);
5462
const [apiKeyActionLoading, setApiKeyActionLoading] = useState(false);
5563
const [apiKeyError, setApiKeyError] = useState('');
5664
const [generatedToken, setGeneratedToken] = useState('');
@@ -273,6 +281,10 @@ export const Profile: React.FC = () => {
273281
setApiKeyError('API key name is required');
274282
return;
275283
}
284+
if (selectedApiKeyScopes.length === 0) {
285+
setApiKeyError('Select at least one API key scope');
286+
return;
287+
}
276288

277289
setApiKeyActionLoading(true);
278290
setApiKeyError('');
@@ -282,9 +294,10 @@ export const Profile: React.FC = () => {
282294
setCopiedToken(false);
283295

284296
try {
285-
const response = await api.createApiKey(trimmedName);
297+
const response = await api.createApiKey(trimmedName, selectedApiKeyScopes);
286298
setApiKeys(prev => [response.apiKey, ...prev]);
287299
setApiKeyName('');
300+
setSelectedApiKeyScopes([...api.API_KEY_SCOPES]);
288301
setGeneratedToken(response.token);
289302
setGeneratedTokenName(response.apiKey.name);
290303
setSuccess('API key created. Copy the token now; it will not be shown again.');
@@ -326,6 +339,14 @@ export const Profile: React.FC = () => {
326339
setCopiedToken(false);
327340
};
328341

342+
const handleApiKeyScopeChange = (scope: string, checked: boolean) => {
343+
const next = checked
344+
? [...selectedApiKeyScopes, scope]
345+
: selectedApiKeyScopes.filter(value => value !== scope);
346+
setSelectedApiKeyScopes(api.API_KEY_SCOPES.filter(value => next.includes(value)));
347+
setApiKeyError(next.length === 0 ? 'Select at least one API key scope' : '');
348+
};
349+
329350
const handleRevokeApiKey = async (id: string) => {
330351
setApiKeyActionLoading(true);
331352
setApiKeyError('');
@@ -556,7 +577,8 @@ export const Profile: React.FC = () => {
556577
</div>
557578
)}
558579

559-
<div className="flex flex-col sm:flex-row gap-3 mb-6">
580+
<div className="space-y-4 mb-6">
581+
<div className="flex flex-col sm:flex-row gap-3">
560582
<div className="flex-1">
561583
<label htmlFor="apiKeyName" className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
562584
API Key Name
@@ -573,11 +595,31 @@ export const Profile: React.FC = () => {
573595
</div>
574596
<button
575597
onClick={() => void handleCreateApiKey()}
576-
disabled={apiKeysLoading || apiKeyActionLoading || !apiKeyName.trim()}
598+
disabled={apiKeysLoading || apiKeyActionLoading || !apiKeyName.trim() || selectedApiKeyScopes.length === 0}
577599
className="sm:self-end px-6 py-3 bg-emerald-600 dark:bg-emerald-500 text-white font-bold rounded-xl border-2 border-black dark:border-neutral-700 shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)] hover:shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] dark:hover:shadow-[4px_4px_0px_0px_rgba(255,255,255,0.2)] hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:translate-y-0 disabled:hover:shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] dark:disabled:hover:shadow-[2px_2px_0px_0px_rgba(255,255,255,0.2)]"
578600
>
579601
{apiKeyActionLoading ? 'Creating...' : 'Create API Key'}
580602
</button>
603+
</div>
604+
<fieldset>
605+
<legend className="block text-sm font-bold text-slate-700 dark:text-neutral-300 mb-2">
606+
Scopes
607+
</legend>
608+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
609+
{api.API_KEY_SCOPES.map(scope => (
610+
<label key={scope} className="flex items-center gap-2 px-3 py-2 bg-slate-50 dark:bg-neutral-800 border-2 border-slate-200 dark:border-neutral-700 rounded-xl text-sm font-medium text-slate-700 dark:text-neutral-300">
611+
<input
612+
type="checkbox"
613+
checked={selectedApiKeyScopes.includes(scope)}
614+
onChange={(event) => handleApiKeyScopeChange(scope, event.target.checked)}
615+
className="h-4 w-4 accent-emerald-600"
616+
/>
617+
<span>{API_KEY_SCOPE_LABELS[scope]}</span>
618+
<span className="font-mono text-xs text-slate-500 dark:text-neutral-500">{scope}</span>
619+
</label>
620+
))}
621+
</div>
622+
</fieldset>
581623
</div>
582624

583625
{apiKeysLoading ? (

0 commit comments

Comments
 (0)