From 495709e96f60f7d5577044899ab66b6bbae35d86 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 28 Sep 2026 08:27:34 +0200 Subject: [PATCH 1/2] sec(auth): close API-key persistence primitive on password rotation (#102) CreateAPIKey required no password re-verification (unlike the comparable MFASetup/MFADisable pattern), ExpiresAt was optional and uncapped so a key could be minted to never expire, and ChangePassword/ConfirmPasswordReset (and UpdateUserProfile) invalidated sessions but left any API key the attacker held fully valid. Together these let a briefly-stolen session mint a durable credential that survives the victim's own password rotation. - CreateAPIKey now re-verifies the caller's password (same helper as MFASetup/MFADisable) and requires ExpiresAt, capped at the new MaxAPIKeyLifetime (1 year). - ChangePassword, ConfirmPasswordReset and UpdateUserProfile now revoke the user's API keys alongside their existing session invalidation, via a shared invalidateUserCredentialsBestEffort helper. - Property (1) of the linked finding (unscoped key inherits the owner's full permissions via computeEffectivePermissionsFromAuthCtx) is tracked and fixed separately in #61; this PR does not touch that code path. Co-Authored-By: claude-flow --- frontend/src/__tests__/api-apikeys.test.ts | 8 +- frontend/src/__tests__/apikeys.test.ts | 160 ++++++++---------- frontend/src/__tests__/html.test.ts | 14 +- frontend/src/api/types.ts | 7 +- frontend/src/apikeys.ts | 72 ++++---- frontend/src/index.html | 11 +- internal/api/handler_apikeys.go | 31 +++- internal/api/handler_apikeys_test.go | 155 +++++++++++++++++ internal/api/openapi.yaml | 6 +- internal/api/types_apikeys.go | 1 + internal/auth/carveout_wildcard_test.go | 18 +- internal/auth/errors.go | 14 ++ internal/auth/service_api_test.go | 1 + internal/auth/service_apikeys.go | 69 +++++++- internal/auth/service_apikeys_api.go | 3 +- internal/auth/service_apikeys_api_test.go | 38 +++-- internal/auth/service_apikeys_test.go | 128 +++++++++++--- internal/auth/service_password.go | 29 +++- .../auth/service_password_callback_test.go | 5 + internal/auth/service_password_test.go | 118 +++++++++++++ internal/auth/service_test.go | 2 + internal/auth/service_user.go | 10 +- internal/auth/service_user_test.go | 1 + internal/auth/test_helpers.go | 13 ++ internal/server/adapter_test.go | 21 ++- 25 files changed, 723 insertions(+), 212 deletions(-) diff --git a/frontend/src/__tests__/api-apikeys.test.ts b/frontend/src/__tests__/api-apikeys.test.ts index ef38a9a89..a129f4ace 100644 --- a/frontend/src/__tests__/api-apikeys.test.ts +++ b/frontend/src/__tests__/api-apikeys.test.ts @@ -110,13 +110,14 @@ describe('API Keys API Module', () => { json: () => Promise.resolve(mockResponse) }); - const result = await createApiKey({ name: 'New Key' }); + const request = { name: 'New Key', password: 'cGFzc3dvcmQ=', expires_at: '2025-12-31T00:00:00Z' }; + const result = await createApiKey(request); expect(fetchMock).toHaveBeenCalledWith( '/api/api-keys', expect.objectContaining({ method: 'POST', - body: JSON.stringify({ name: 'New Key' }), + body: JSON.stringify(request), headers: expect.objectContaining({ 'Content-Type': 'application/json', 'x-amz-content-sha256': expect.stringMatching(/^[a-f0-9]{64}$/) @@ -140,6 +141,7 @@ describe('API Keys API Module', () => { const request = { name: 'Admin Key', + password: 'cGFzc3dvcmQ=', permissions: [{ action: 'read', resource: '*' }], expires_at: '2025-12-31T00:00:00Z' }; @@ -163,7 +165,7 @@ describe('API Keys API Module', () => { json: () => Promise.resolve({ error: 'Invalid request' }) }); - await expect(createApiKey({ name: '' })).rejects.toThrow('Invalid request'); + await expect(createApiKey({ name: '', password: '', expires_at: '2025-12-31T00:00:00Z' })).rejects.toThrow('Invalid request'); }); }); diff --git a/frontend/src/__tests__/apikeys.test.ts b/frontend/src/__tests__/apikeys.test.ts index 96d853402..ce9a7293e 100644 --- a/frontend/src/__tests__/apikeys.test.ts +++ b/frontend/src/__tests__/apikeys.test.ts @@ -20,7 +20,11 @@ jest.mock('../api', () => ({ getApiKeysUsageStats: jest.fn(), createApiKey: jest.fn(), revokeApiKey: jest.fn(), - deleteApiKey: jest.fn() + deleteApiKey: jest.fn(), + // Real implementation (not mocked): createApiKey encodes the password + // field with this before building the request, so tests need the real + // encoding to assert on the resulting request shape. + base64Encode: (str: string) => Buffer.from(str, 'binary').toString('base64') })); // confirmDialog (introduced in P2) is used for revoke/delete in place of @@ -43,10 +47,8 @@ describe('API Keys Module', () => {
- + + + Confirm your password to create this API key
-