@tumbaland/backend-core 1.31.0 → 1.33.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/apiKeys/ApiKey.test.ts +118 -0
- package/src/apiKeys/index.test.ts +72 -0
- package/src/apiKeys/middleware.test.ts +100 -1
- package/src/apiKeys/service.test.ts +352 -0
- package/src/apiKeys/types.test.ts +41 -0
package/package.json
CHANGED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ApiKey } from './ApiKey';
|
|
2
|
+
|
|
3
|
+
/** The fields the schema insists on, so each test only varies what it is about. */
|
|
4
|
+
const complete = {
|
|
5
|
+
userId: 'u1',
|
|
6
|
+
userEmail: 'u1@example.com',
|
|
7
|
+
userName: 'Tester',
|
|
8
|
+
name: 'Claude Code',
|
|
9
|
+
keyId: 'abcdef123456',
|
|
10
|
+
hash: 'a'.repeat(64),
|
|
11
|
+
sealedCiphertext: 'ct',
|
|
12
|
+
sealedIv: 'iv',
|
|
13
|
+
sealedTag: 'tag'
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe('required fields', () => {
|
|
17
|
+
it('accepts a complete document', () => {
|
|
18
|
+
expect(new ApiKey(complete).validateSync()).toBeUndefined();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it.each([
|
|
22
|
+
'userId',
|
|
23
|
+
'userEmail',
|
|
24
|
+
'name',
|
|
25
|
+
'keyId',
|
|
26
|
+
'hash',
|
|
27
|
+
'sealedCiphertext',
|
|
28
|
+
'sealedIv',
|
|
29
|
+
'sealedTag'
|
|
30
|
+
])('refuses a document with no %s', (field) => {
|
|
31
|
+
const doc = new ApiKey({ ...complete, [field]: undefined });
|
|
32
|
+
expect(doc.validateSync()?.errors[field]).toBeDefined();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('will not store a key without the material needed to verify it', () => {
|
|
36
|
+
// A record missing its digest could never authenticate anything, and one
|
|
37
|
+
// missing the sealed copy could never be revealed — both are corruption,
|
|
38
|
+
// not a valid state.
|
|
39
|
+
const doc = new ApiKey({ ...complete, hash: undefined, sealedCiphertext: undefined });
|
|
40
|
+
const err = doc.validateSync();
|
|
41
|
+
|
|
42
|
+
expect(err?.errors.hash).toBeDefined();
|
|
43
|
+
expect(err?.errors.sealedCiphertext).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('defaults', () => {
|
|
48
|
+
it('treats an unpinned key as personal rather than as unset', () => {
|
|
49
|
+
// `null` and "nobody set this" must not be distinguishable downstream: the
|
|
50
|
+
// middleware reads a null groupId as the owner's personal scope.
|
|
51
|
+
expect(new ApiKey(complete).groupId).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('starts with no scopes, so a key grants nothing until asked', () => {
|
|
55
|
+
expect(new ApiKey(complete).scopes).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('starts the reveal counter at zero', () => {
|
|
59
|
+
expect(new ApiKey(complete).revealCount).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('leaves the lifecycle timestamps unset', () => {
|
|
63
|
+
const doc = new ApiKey(complete);
|
|
64
|
+
|
|
65
|
+
expect(doc.lastUsedAt).toBeUndefined();
|
|
66
|
+
expect(doc.expiresAt).toBeUndefined();
|
|
67
|
+
expect(doc.revokedAt).toBeUndefined();
|
|
68
|
+
expect(doc.lastRevealedAt).toBeUndefined();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('name', () => {
|
|
73
|
+
it('trims surrounding whitespace', () => {
|
|
74
|
+
expect(new ApiKey({ ...complete, name: ' Claude ' }).name).toBe('Claude');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('caps the length so a listing stays readable', () => {
|
|
78
|
+
const doc = new ApiKey({ ...complete, name: 'x'.repeat(61) });
|
|
79
|
+
expect(doc.validateSync()?.errors.name).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('accepts a name at the cap', () => {
|
|
83
|
+
const doc = new ApiKey({ ...complete, name: 'x'.repeat(60) });
|
|
84
|
+
expect(doc.validateSync()?.errors.name).toBeUndefined();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('indexes', () => {
|
|
89
|
+
const indexes = ApiKey.schema.indexes().map(([fields]) => fields);
|
|
90
|
+
|
|
91
|
+
it('indexes keyId, which every verification looks a key up by', () => {
|
|
92
|
+
const path = ApiKey.schema.path('keyId') as unknown as { options: Record<string, unknown> };
|
|
93
|
+
expect(path.options.index).toBe(true);
|
|
94
|
+
expect(path.options.unique).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('indexes a user’s keys newest first, matching how the listing reads them', () => {
|
|
98
|
+
expect(indexes).toContainEqual({ userId: 1, createdAt: -1 });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe('collection', () => {
|
|
103
|
+
it('lives in api_keys', () => {
|
|
104
|
+
// Named explicitly rather than pluralised by Mongoose, so the collection a
|
|
105
|
+
// migration or a manual query targets is not a guess.
|
|
106
|
+
expect(ApiKey.collection.collectionName).toBe('api_keys');
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('model registration', () => {
|
|
111
|
+
it('reuses the compiled model instead of redefining it', () => {
|
|
112
|
+
// backend-core is imported by every service, and some import it more than
|
|
113
|
+
// once through different paths; recompiling would throw OverwriteModelError.
|
|
114
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
115
|
+
const again = require('./ApiKey').ApiKey;
|
|
116
|
+
expect(again).toBe(ApiKey);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as core from '../index';
|
|
2
|
+
import * as apiKeys from './index';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The package's public surface, as the services actually consume it.
|
|
6
|
+
*
|
|
7
|
+
* Services install this lib from npm, so an export dropped in a refactor does
|
|
8
|
+
* not fail here — it fails inside a Docker build as "has no exported member X",
|
|
9
|
+
* against a registry that genuinely has the code. That is a slow and confusing
|
|
10
|
+
* loop, and it is entirely preventable by naming the surface once.
|
|
11
|
+
*/
|
|
12
|
+
const PUBLIC_SURFACE = [
|
|
13
|
+
// middleware the services mount
|
|
14
|
+
'authenticateAgent',
|
|
15
|
+
'requireScope',
|
|
16
|
+
'denyApiKeys',
|
|
17
|
+
// key lifecycle, used by auth-service
|
|
18
|
+
'createApiKey',
|
|
19
|
+
'listApiKeys',
|
|
20
|
+
'revealApiKey',
|
|
21
|
+
'revokeApiKey',
|
|
22
|
+
'deleteApiKey',
|
|
23
|
+
'verifyApiKey',
|
|
24
|
+
// configuration and vocabulary
|
|
25
|
+
'isEncryptionConfigured',
|
|
26
|
+
'API_KEY_SCOPES',
|
|
27
|
+
'isApiKeyScope',
|
|
28
|
+
'ApiKey',
|
|
29
|
+
// helpers
|
|
30
|
+
'looksLikeApiKey',
|
|
31
|
+
'displayPrefix',
|
|
32
|
+
'resetEncryptionKeyCache'
|
|
33
|
+
] as const;
|
|
34
|
+
|
|
35
|
+
describe('the apiKeys barrel', () => {
|
|
36
|
+
it.each(PUBLIC_SURFACE)('exports %s', (name) => {
|
|
37
|
+
expect(apiKeys).toHaveProperty(name);
|
|
38
|
+
expect((apiKeys as Record<string, unknown>)[name]).toBeDefined();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('the package root', () => {
|
|
43
|
+
it.each(PUBLIC_SURFACE)('re-exports %s, which is how services import it', (name) => {
|
|
44
|
+
expect((core as Record<string, unknown>)[name]).toBeDefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('exposes the same binding through both paths', () => {
|
|
48
|
+
for (const name of PUBLIC_SURFACE) {
|
|
49
|
+
expect((core as Record<string, unknown>)[name]).toBe((apiKeys as Record<string, unknown>)[name]);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('still exports the session middleware the key work sits alongside', () => {
|
|
54
|
+
// `authenticateToken` stays JWT-only and is what auth-service and
|
|
55
|
+
// payment-service rely on to refuse keys outright.
|
|
56
|
+
expect(core.authenticateToken).toBeDefined();
|
|
57
|
+
expect(core.optionalAuth).toBeDefined();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('the scope vocabulary', () => {
|
|
62
|
+
it('is the same list the services and the UI are written against', () => {
|
|
63
|
+
expect([...core.API_KEY_SCOPES]).toEqual([
|
|
64
|
+
'relationship:read',
|
|
65
|
+
'relationship:write',
|
|
66
|
+
'album:read',
|
|
67
|
+
'album:write',
|
|
68
|
+
'finance:read',
|
|
69
|
+
'finance:write'
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -8,7 +8,7 @@ jest.mock('../logging/logger', () => ({
|
|
|
8
8
|
|
|
9
9
|
import jwt from 'jsonwebtoken';
|
|
10
10
|
import { verifyApiKey } from './service';
|
|
11
|
-
import { authenticateAgent, denyApiKeys } from './middleware';
|
|
11
|
+
import { authenticateAgent, requireScope, denyApiKeys } from './middleware';
|
|
12
12
|
|
|
13
13
|
const mockedVerify = verifyApiKey as jest.Mock;
|
|
14
14
|
const ORIGINAL_ENV = process.env;
|
|
@@ -157,6 +157,37 @@ describe('authenticateAgent — API keys', () => {
|
|
|
157
157
|
});
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
+
it('authenticates a key stored before the owner snapshot existed', async () => {
|
|
161
|
+
// Records written by an earlier version carry no userEmail/userName. Such a
|
|
162
|
+
// key must still work — the id is what every handler actually reads — rather
|
|
163
|
+
// than 500 on a missing field.
|
|
164
|
+
mockedVerify.mockResolvedValue({
|
|
165
|
+
ok: true,
|
|
166
|
+
userId: 'u1',
|
|
167
|
+
keyId: 'k1',
|
|
168
|
+
scopes: ['relationship:read'],
|
|
169
|
+
groupId: null
|
|
170
|
+
});
|
|
171
|
+
const request = withKey();
|
|
172
|
+
const next = jest.fn();
|
|
173
|
+
|
|
174
|
+
await authenticateAgent('relationship:read')(request, mockRes(), next);
|
|
175
|
+
|
|
176
|
+
expect(next).toHaveBeenCalled();
|
|
177
|
+
expect(request.user).toEqual({ id: 'u1', email: '', name: '' });
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('treats a verification result with no scopes as granting nothing', async () => {
|
|
181
|
+
mockedVerify.mockResolvedValue({ ok: true, userId: 'u1', keyId: 'k1', groupId: null });
|
|
182
|
+
const res = mockRes();
|
|
183
|
+
const next = jest.fn();
|
|
184
|
+
|
|
185
|
+
await authenticateAgent('relationship:read')(withKey(), res, next);
|
|
186
|
+
|
|
187
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
188
|
+
expect(next).not.toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
|
|
160
191
|
describe('authenticateAgent — tenant pinning', () => {
|
|
161
192
|
it('grants a personal key no groups at all', async () => {
|
|
162
193
|
mockedVerify.mockResolvedValue(okVerification({ groupId: null }));
|
|
@@ -231,6 +262,74 @@ describe('authenticateAgent — tenant pinning', () => {
|
|
|
231
262
|
});
|
|
232
263
|
});
|
|
233
264
|
|
|
265
|
+
describe('requireScope', () => {
|
|
266
|
+
const keyReq = (scopes: string[]) =>
|
|
267
|
+
req({ apiKey: { keyId: 'k1', scopes, groupId: null } } as Partial<Request>);
|
|
268
|
+
|
|
269
|
+
it('lets a session through unconditionally', () => {
|
|
270
|
+
// Scopes narrow what software may do on a person's behalf, not what the
|
|
271
|
+
// person may do themselves — a session carries no apiKey context at all.
|
|
272
|
+
const next = jest.fn();
|
|
273
|
+
requireScope('relationship:write')(req(), mockRes(), next);
|
|
274
|
+
expect(next).toHaveBeenCalled();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('lets a key with the required scope through', () => {
|
|
278
|
+
const next = jest.fn();
|
|
279
|
+
requireScope('relationship:read')(keyReq(['relationship:read']), mockRes(), next);
|
|
280
|
+
expect(next).toHaveBeenCalled();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('refuses a key without it, naming what is missing', () => {
|
|
284
|
+
const res = mockRes();
|
|
285
|
+
const next = jest.fn();
|
|
286
|
+
|
|
287
|
+
requireScope('relationship:write')(keyReq(['relationship:read']), res, next);
|
|
288
|
+
|
|
289
|
+
expect(res.status).toHaveBeenCalledWith(403);
|
|
290
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
291
|
+
expect.objectContaining({ message: expect.stringContaining('relationship:write') })
|
|
292
|
+
);
|
|
293
|
+
expect(next).not.toHaveBeenCalled();
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('does not let a read scope stand in for a write', () => {
|
|
297
|
+
const next = jest.fn();
|
|
298
|
+
requireScope('album:write')(keyReq(['album:read']), mockRes(), next);
|
|
299
|
+
expect(next).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('does not let one area’s scope stand in for another’s', () => {
|
|
303
|
+
const next = jest.fn();
|
|
304
|
+
requireScope('finance:read')(keyReq(['relationship:read', 'album:read']), mockRes(), next);
|
|
305
|
+
expect(next).not.toHaveBeenCalled();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('requires every named scope, not just one of them', () => {
|
|
309
|
+
const res = mockRes();
|
|
310
|
+
const next = jest.fn();
|
|
311
|
+
|
|
312
|
+
requireScope('relationship:read', 'album:write')(keyReq(['relationship:read']), res, next);
|
|
313
|
+
|
|
314
|
+
expect(next).not.toHaveBeenCalled();
|
|
315
|
+
expect(res.json).toHaveBeenCalledWith(
|
|
316
|
+
expect.objectContaining({ message: expect.stringContaining('album:write') })
|
|
317
|
+
);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('refuses a key with no scopes at all', () => {
|
|
321
|
+
const next = jest.fn();
|
|
322
|
+
requireScope('relationship:read')(keyReq([]), mockRes(), next);
|
|
323
|
+
expect(next).not.toHaveBeenCalled();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('lets any authenticated caller through when no scope is named', () => {
|
|
327
|
+
const next = jest.fn();
|
|
328
|
+
requireScope()(keyReq([]), mockRes(), next);
|
|
329
|
+
expect(next).toHaveBeenCalled();
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
234
333
|
describe('denyApiKeys', () => {
|
|
235
334
|
it('lets a session through', () => {
|
|
236
335
|
const next = jest.fn();
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
const ORIGINAL_ENV = process.env;
|
|
2
|
+
|
|
3
|
+
jest.mock('./ApiKey', () => ({
|
|
4
|
+
ApiKey: {
|
|
5
|
+
create: jest.fn(),
|
|
6
|
+
find: jest.fn(),
|
|
7
|
+
findOne: jest.fn(),
|
|
8
|
+
updateOne: jest.fn(),
|
|
9
|
+
deleteOne: jest.fn()
|
|
10
|
+
}
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { ApiKey } from './ApiKey';
|
|
14
|
+
import {
|
|
15
|
+
createApiKey,
|
|
16
|
+
listApiKeys,
|
|
17
|
+
revealApiKey,
|
|
18
|
+
revokeApiKey,
|
|
19
|
+
deleteApiKey,
|
|
20
|
+
verifyApiKey
|
|
21
|
+
} from './service';
|
|
22
|
+
import { encryptSecret, generateKey, parseKey, resetEncryptionKeyCache, sha256 } from './crypto';
|
|
23
|
+
|
|
24
|
+
const mockedCreate = ApiKey.create as unknown as jest.Mock;
|
|
25
|
+
const mockedFind = ApiKey.find as unknown as jest.Mock;
|
|
26
|
+
const mockedFindOne = ApiKey.findOne as unknown as jest.Mock;
|
|
27
|
+
const mockedUpdateOne = ApiKey.updateOne as unknown as jest.Mock;
|
|
28
|
+
const mockedDeleteOne = ApiKey.deleteOne as unknown as jest.Mock;
|
|
29
|
+
|
|
30
|
+
const CREATED_AT = new Date('2026-09-01T00:00:00.000Z');
|
|
31
|
+
|
|
32
|
+
/** A stored key document, with the save()/field mutation the service relies on. */
|
|
33
|
+
function storedKey(over: Record<string, unknown> = {}) {
|
|
34
|
+
return {
|
|
35
|
+
_id: 'k1',
|
|
36
|
+
userId: 'u1',
|
|
37
|
+
userEmail: 'u1@example.com',
|
|
38
|
+
userName: 'Tester',
|
|
39
|
+
name: 'Claude',
|
|
40
|
+
keyId: 'abcdef123456',
|
|
41
|
+
hash: sha256('a-secret-value-long-enough'),
|
|
42
|
+
sealedCiphertext: 'ct',
|
|
43
|
+
sealedIv: 'iv',
|
|
44
|
+
sealedTag: 'tag',
|
|
45
|
+
scopes: ['relationship:read'],
|
|
46
|
+
groupId: null,
|
|
47
|
+
revealCount: 0,
|
|
48
|
+
lastRevealedAt: undefined as Date | undefined,
|
|
49
|
+
createdAt: CREATED_AT,
|
|
50
|
+
lastUsedAt: undefined,
|
|
51
|
+
expiresAt: undefined,
|
|
52
|
+
revokedAt: undefined,
|
|
53
|
+
save: jest.fn().mockResolvedValue(undefined),
|
|
54
|
+
...over
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() => {
|
|
59
|
+
jest.clearAllMocks();
|
|
60
|
+
process.env = { ...ORIGINAL_ENV, API_KEY_ENCRYPTION_SECRET: 'test-encryption-secret' };
|
|
61
|
+
resetEncryptionKeyCache();
|
|
62
|
+
mockedUpdateOne.mockResolvedValue({});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
process.env = ORIGINAL_ENV;
|
|
67
|
+
resetEncryptionKeyCache();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('createApiKey', () => {
|
|
71
|
+
beforeEach(() => mockedCreate.mockImplementation(async (doc) => ({ ...doc, _id: 'k1', createdAt: CREATED_AT })));
|
|
72
|
+
|
|
73
|
+
const input = {
|
|
74
|
+
userId: 'u1',
|
|
75
|
+
userEmail: 'u1@example.com',
|
|
76
|
+
userName: 'Tester',
|
|
77
|
+
name: 'Claude',
|
|
78
|
+
scopes: ['relationship:read' as const]
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
it('returns a usable token and stores only its digest', async () => {
|
|
82
|
+
const { token } = await createApiKey(input);
|
|
83
|
+
const [stored] = mockedCreate.mock.calls[0];
|
|
84
|
+
|
|
85
|
+
// The plaintext must never reach the hash column — that is the whole point
|
|
86
|
+
// of keeping a digest alongside the reversible copy.
|
|
87
|
+
expect(stored.hash).not.toContain(token);
|
|
88
|
+
expect(stored.hash).toBe(sha256(parseKey(token)!.secret));
|
|
89
|
+
expect(stored.keyId).toBe(parseKey(token)!.id);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('stores the token encrypted, recoverable only with the secret', async () => {
|
|
93
|
+
const { token } = await createApiKey(input);
|
|
94
|
+
const [stored] = mockedCreate.mock.calls[0];
|
|
95
|
+
|
|
96
|
+
expect(stored.sealedCiphertext).not.toContain(token);
|
|
97
|
+
expect(stored.sealedIv).toBeTruthy();
|
|
98
|
+
expect(stored.sealedTag).toBeTruthy();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('defaults an unpinned key to the owner’s personal scope', async () => {
|
|
102
|
+
await createApiKey(input);
|
|
103
|
+
expect(mockedCreate.mock.calls[0][0].groupId).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('pins a key to the group it was issued for', async () => {
|
|
107
|
+
await createApiKey({ ...input, groupId: 'g1' });
|
|
108
|
+
expect(mockedCreate.mock.calls[0][0].groupId).toBe('g1');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('snapshots the owner so verification needs no second query', async () => {
|
|
112
|
+
await createApiKey(input);
|
|
113
|
+
expect(mockedCreate.mock.calls[0][0]).toMatchObject({
|
|
114
|
+
userEmail: 'u1@example.com',
|
|
115
|
+
userName: 'Tester'
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('never returns the secret material in the summary', async () => {
|
|
120
|
+
const { summary } = await createApiKey(input);
|
|
121
|
+
expect(summary).not.toHaveProperty('hash');
|
|
122
|
+
expect(summary).not.toHaveProperty('sealedCiphertext');
|
|
123
|
+
expect(summary.prefix).toBe(`tmb_live_${mockedCreate.mock.calls[0][0].keyId}`);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('listApiKeys', () => {
|
|
128
|
+
it('lists the owner’s keys newest first, carrying no secrets', async () => {
|
|
129
|
+
const sort = jest.fn().mockResolvedValue([storedKey(), storedKey({ _id: 'k2', name: 'Other' })]);
|
|
130
|
+
mockedFind.mockReturnValue({ sort });
|
|
131
|
+
|
|
132
|
+
const keys = await listApiKeys('u1');
|
|
133
|
+
|
|
134
|
+
expect(mockedFind).toHaveBeenCalledWith({ userId: 'u1' });
|
|
135
|
+
expect(sort).toHaveBeenCalledWith({ createdAt: -1 });
|
|
136
|
+
for (const key of keys) {
|
|
137
|
+
expect(key).not.toHaveProperty('hash');
|
|
138
|
+
expect(key).not.toHaveProperty('sealedCiphertext');
|
|
139
|
+
expect(JSON.stringify(key)).not.toContain('ct');
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('renders absent timestamps as null rather than undefined', async () => {
|
|
144
|
+
mockedFind.mockReturnValue({ sort: jest.fn().mockResolvedValue([storedKey()]) });
|
|
145
|
+
const [key] = await listApiKeys('u1');
|
|
146
|
+
|
|
147
|
+
expect(key.lastUsedAt).toBeNull();
|
|
148
|
+
expect(key.expiresAt).toBeNull();
|
|
149
|
+
expect(key.revokedAt).toBeNull();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('revealApiKey', () => {
|
|
154
|
+
it('decrypts the stored token for its owner', async () => {
|
|
155
|
+
const { token } = generateKey();
|
|
156
|
+
const sealed = encryptSecret(token);
|
|
157
|
+
const key = storedKey({
|
|
158
|
+
sealedCiphertext: sealed.ciphertext,
|
|
159
|
+
sealedIv: sealed.iv,
|
|
160
|
+
sealedTag: sealed.tag
|
|
161
|
+
});
|
|
162
|
+
mockedFindOne.mockResolvedValue(key);
|
|
163
|
+
|
|
164
|
+
await expect(revealApiKey('u1', 'k1')).resolves.toBe(token);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('scopes the lookup to the owner, so another user’s key is simply not found', async () => {
|
|
168
|
+
mockedFindOne.mockResolvedValue(null);
|
|
169
|
+
|
|
170
|
+
await expect(revealApiKey('someone-else', 'k1')).resolves.toBeNull();
|
|
171
|
+
// Ownership is part of the query rather than a check afterwards — there is
|
|
172
|
+
// no path where a mismatched user still reaches the decrypt.
|
|
173
|
+
expect(mockedFindOne).toHaveBeenCalledWith({ _id: 'k1', userId: 'someone-else' });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('counts and timestamps every reveal', async () => {
|
|
177
|
+
const sealed = encryptSecret(generateKey().token);
|
|
178
|
+
const key = storedKey({
|
|
179
|
+
sealedCiphertext: sealed.ciphertext,
|
|
180
|
+
sealedIv: sealed.iv,
|
|
181
|
+
sealedTag: sealed.tag,
|
|
182
|
+
revealCount: 2
|
|
183
|
+
});
|
|
184
|
+
mockedFindOne.mockResolvedValue(key);
|
|
185
|
+
|
|
186
|
+
await revealApiKey('u1', 'k1');
|
|
187
|
+
|
|
188
|
+
expect(key.revealCount).toBe(3);
|
|
189
|
+
expect(key.lastRevealedAt).toBeInstanceOf(Date);
|
|
190
|
+
expect(key.save).toHaveBeenCalled();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('still reveals a revoked key, so the owner can see what they turned off', async () => {
|
|
194
|
+
const { token } = generateKey();
|
|
195
|
+
const sealed = encryptSecret(token);
|
|
196
|
+
mockedFindOne.mockResolvedValue(
|
|
197
|
+
storedKey({
|
|
198
|
+
sealedCiphertext: sealed.ciphertext,
|
|
199
|
+
sealedIv: sealed.iv,
|
|
200
|
+
sealedTag: sealed.tag,
|
|
201
|
+
revokedAt: new Date()
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
await expect(revealApiKey('u1', 'k1')).resolves.toBe(token);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
describe('revokeApiKey', () => {
|
|
210
|
+
it('stamps the key revoked', async () => {
|
|
211
|
+
const key = storedKey();
|
|
212
|
+
mockedFindOne.mockResolvedValue(key);
|
|
213
|
+
|
|
214
|
+
const summary = await revokeApiKey('u1', 'k1');
|
|
215
|
+
|
|
216
|
+
expect(key.revokedAt).toBeInstanceOf(Date);
|
|
217
|
+
expect(key.save).toHaveBeenCalled();
|
|
218
|
+
expect(summary?.revokedAt).not.toBeNull();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('leaves an already-revoked key’s original timestamp alone', async () => {
|
|
222
|
+
const revokedAt = new Date('2026-01-01T00:00:00.000Z');
|
|
223
|
+
const key = storedKey({ revokedAt });
|
|
224
|
+
mockedFindOne.mockResolvedValue(key);
|
|
225
|
+
|
|
226
|
+
await revokeApiKey('u1', 'k1');
|
|
227
|
+
|
|
228
|
+
expect(key.revokedAt).toBe(revokedAt);
|
|
229
|
+
expect(key.save).not.toHaveBeenCalled();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('returns null for a key that is not the caller’s', async () => {
|
|
233
|
+
mockedFindOne.mockResolvedValue(null);
|
|
234
|
+
await expect(revokeApiKey('u2', 'k1')).resolves.toBeNull();
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe('deleteApiKey', () => {
|
|
239
|
+
it('deletes only within the owner’s keys', async () => {
|
|
240
|
+
mockedDeleteOne.mockResolvedValue({ deletedCount: 1 });
|
|
241
|
+
|
|
242
|
+
await expect(deleteApiKey('u1', 'k1')).resolves.toBe(true);
|
|
243
|
+
expect(mockedDeleteOne).toHaveBeenCalledWith({ _id: 'k1', userId: 'u1' });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('reports false when nothing matched', async () => {
|
|
247
|
+
mockedDeleteOne.mockResolvedValue({ deletedCount: 0 });
|
|
248
|
+
await expect(deleteApiKey('u2', 'k1')).resolves.toBe(false);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
describe('verifyApiKey', () => {
|
|
253
|
+
/** A stored record that will actually accept `token`. */
|
|
254
|
+
const acceptingKey = (token: string, over: Record<string, unknown> = {}) =>
|
|
255
|
+
storedKey({
|
|
256
|
+
keyId: parseKey(token)!.id,
|
|
257
|
+
hash: sha256(parseKey(token)!.secret),
|
|
258
|
+
...over
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('rejects a malformed token without touching the database', async () => {
|
|
262
|
+
await expect(verifyApiKey('not-a-key')).resolves.toEqual({ ok: false, rejection: 'malformed' });
|
|
263
|
+
expect(mockedFindOne).not.toHaveBeenCalled();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('rejects a token for a key that does not exist', async () => {
|
|
267
|
+
mockedFindOne.mockResolvedValue(null);
|
|
268
|
+
const { token } = generateKey();
|
|
269
|
+
|
|
270
|
+
await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'unknown' });
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('rejects a revoked key', async () => {
|
|
274
|
+
const { token } = generateKey();
|
|
275
|
+
mockedFindOne.mockResolvedValue(acceptingKey(token, { revokedAt: new Date() }));
|
|
276
|
+
|
|
277
|
+
await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'revoked' });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('rejects a key whose expiry has passed', async () => {
|
|
281
|
+
const { token } = generateKey();
|
|
282
|
+
mockedFindOne.mockResolvedValue(acceptingKey(token, { expiresAt: new Date(Date.now() - 1000) }));
|
|
283
|
+
|
|
284
|
+
await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'expired' });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('accepts a key whose expiry is still ahead', async () => {
|
|
288
|
+
const { token } = generateKey();
|
|
289
|
+
mockedFindOne.mockResolvedValue(acceptingKey(token, { expiresAt: new Date(Date.now() + 60_000) }));
|
|
290
|
+
|
|
291
|
+
await expect(verifyApiKey(token)).resolves.toMatchObject({ ok: true });
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('rejects a right-shaped token whose secret is wrong', async () => {
|
|
295
|
+
const { token } = generateKey();
|
|
296
|
+
const other = generateKey();
|
|
297
|
+
// Same public id, a different secret's digest — the id half is not a credential.
|
|
298
|
+
mockedFindOne.mockResolvedValue(
|
|
299
|
+
storedKey({ keyId: parseKey(token)!.id, hash: sha256(parseKey(other.token)!.secret) })
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'bad-secret' });
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('looks a key up by its public id alone', async () => {
|
|
306
|
+
const { token } = generateKey();
|
|
307
|
+
mockedFindOne.mockResolvedValue(acceptingKey(token));
|
|
308
|
+
|
|
309
|
+
await verifyApiKey(token);
|
|
310
|
+
|
|
311
|
+
expect(mockedFindOne).toHaveBeenCalledWith({ keyId: parseKey(token)!.id });
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('returns the identity, scopes and tenant a valid key carries', async () => {
|
|
315
|
+
const { token } = generateKey();
|
|
316
|
+
mockedFindOne.mockResolvedValue(
|
|
317
|
+
acceptingKey(token, { scopes: ['relationship:read', 'album:write'], groupId: 'g1' })
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
await expect(verifyApiKey(token)).resolves.toEqual({
|
|
321
|
+
ok: true,
|
|
322
|
+
userId: 'u1',
|
|
323
|
+
userEmail: 'u1@example.com',
|
|
324
|
+
userName: 'Tester',
|
|
325
|
+
keyId: 'k1',
|
|
326
|
+
scopes: ['relationship:read', 'album:write'],
|
|
327
|
+
groupId: 'g1'
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('records that the key was used', async () => {
|
|
332
|
+
const { token } = generateKey();
|
|
333
|
+
mockedFindOne.mockResolvedValue(acceptingKey(token));
|
|
334
|
+
|
|
335
|
+
await verifyApiKey(token);
|
|
336
|
+
|
|
337
|
+
expect(mockedUpdateOne).toHaveBeenCalledWith(
|
|
338
|
+
{ _id: 'k1' },
|
|
339
|
+
{ $set: { lastUsedAt: expect.any(Date) } }
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('still authenticates when recording the usage timestamp fails', async () => {
|
|
344
|
+
const { token } = generateKey();
|
|
345
|
+
mockedFindOne.mockResolvedValue(acceptingKey(token));
|
|
346
|
+
mockedUpdateOne.mockRejectedValue(new Error('write concern failed'));
|
|
347
|
+
|
|
348
|
+
// The timestamp is a convenience for the listing UI; losing it must never
|
|
349
|
+
// cost an otherwise valid request.
|
|
350
|
+
await expect(verifyApiKey(token)).resolves.toMatchObject({ ok: true });
|
|
351
|
+
});
|
|
352
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { API_KEY_SCOPES, isApiKeyScope } from './types';
|
|
2
|
+
|
|
3
|
+
describe('API_KEY_SCOPES', () => {
|
|
4
|
+
it('has no duplicates', () => {
|
|
5
|
+
expect(new Set(API_KEY_SCOPES).size).toBe(API_KEY_SCOPES.length);
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('offers a read and a write for every area it covers', () => {
|
|
9
|
+
for (const area of ['relationship', 'album', 'finance']) {
|
|
10
|
+
expect(API_KEY_SCOPES).toContain(`${area}:read`);
|
|
11
|
+
expect(API_KEY_SCOPES).toContain(`${area}:write`);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('grants nothing over auth or payment, which stay a person’s to do', () => {
|
|
16
|
+
// A key that could mint keys could not be revoked, and one that could change
|
|
17
|
+
// a subscription has no ceiling on what it can cost.
|
|
18
|
+
for (const scope of API_KEY_SCOPES) {
|
|
19
|
+
expect(scope.startsWith('auth:')).toBe(false);
|
|
20
|
+
expect(scope.startsWith('payment:')).toBe(false);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe('isApiKeyScope', () => {
|
|
26
|
+
it.each([...API_KEY_SCOPES])('accepts %s', (scope) => {
|
|
27
|
+
expect(isApiKeyScope(scope)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it.each([
|
|
31
|
+
['an invented area', 'admin:everything'],
|
|
32
|
+
['an invented verb', 'relationship:delete'],
|
|
33
|
+
['a bare area', 'relationship'],
|
|
34
|
+
['an empty string', ''],
|
|
35
|
+
['a non-string', 42],
|
|
36
|
+
['null', null],
|
|
37
|
+
['undefined', undefined]
|
|
38
|
+
])('rejects %s', (_label, value) => {
|
|
39
|
+
expect(isApiKeyScope(value)).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
});
|