@tumbaland/backend-core 1.44.0 → 1.46.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/dist/fileService/client.d.ts +54 -0
- package/dist/fileService/client.d.ts.map +1 -0
- package/dist/fileService/client.js +137 -0
- package/dist/fileService/client.js.map +1 -0
- package/dist/fileService/index.d.ts +4 -0
- package/dist/fileService/index.d.ts.map +1 -0
- package/dist/fileService/index.js +40 -0
- package/dist/fileService/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/corsMiddleware.d.ts.map +1 -1
- package/dist/middleware/corsMiddleware.js +4 -1
- package/dist/middleware/corsMiddleware.js.map +1 -1
- package/dist/middleware/errorHandler.d.ts.map +1 -1
- package/dist/middleware/errorHandler.js +5 -8
- package/dist/middleware/errorHandler.js.map +1 -1
- package/dist/utils/escapeRegex.d.ts +8 -0
- package/dist/utils/escapeRegex.d.ts.map +1 -0
- package/dist/utils/escapeRegex.js +12 -0
- package/dist/utils/escapeRegex.js.map +1 -0
- package/package.json +5 -1
- package/.versionrc.json +0 -7
- package/__mocks__/uuid.js +0 -8
- package/jest.config.js +0 -24
- package/src/apiKeys/ApiKey.test.ts +0 -142
- package/src/apiKeys/ApiKey.ts +0 -102
- package/src/apiKeys/crypto.test.ts +0 -161
- package/src/apiKeys/crypto.ts +0 -163
- package/src/apiKeys/index.test.ts +0 -72
- package/src/apiKeys/index.ts +0 -35
- package/src/apiKeys/middleware.test.ts +0 -651
- package/src/apiKeys/middleware.ts +0 -341
- package/src/apiKeys/service.test.ts +0 -401
- package/src/apiKeys/service.ts +0 -168
- package/src/apiKeys/types.test.ts +0 -41
- package/src/apiKeys/types.ts +0 -124
- package/src/app/createBaseApp.test.ts +0 -109
- package/src/app/createBaseApp.ts +0 -102
- package/src/app/shutdown.test.ts +0 -129
- package/src/app/shutdown.ts +0 -81
- package/src/audit/AuditEvent.ts +0 -123
- package/src/audit/actor.test.ts +0 -95
- package/src/audit/actor.ts +0 -68
- package/src/audit/context.test.ts +0 -91
- package/src/audit/context.ts +0 -83
- package/src/audit/index.ts +0 -11
- package/src/audit/plugin.test.ts +0 -258
- package/src/audit/plugin.ts +0 -254
- package/src/audit/reads.test.ts +0 -164
- package/src/audit/reads.ts +0 -88
- package/src/audit/service.test.ts +0 -115
- package/src/audit/service.ts +0 -95
- package/src/auth/session.test.ts +0 -89
- package/src/auth/session.ts +0 -75
- package/src/config/env.test.ts +0 -28
- package/src/config/env.ts +0 -13
- package/src/database/connection.test.ts +0 -188
- package/src/database/connection.ts +0 -90
- package/src/entitlements/UsageMeter.ts +0 -49
- package/src/entitlements/client.test.ts +0 -200
- package/src/entitlements/client.ts +0 -179
- package/src/entitlements/definitions.test.ts +0 -161
- package/src/entitlements/definitions.ts +0 -268
- package/src/entitlements/index.ts +0 -42
- package/src/entitlements/middleware.test.ts +0 -196
- package/src/entitlements/middleware.ts +0 -150
- package/src/entitlements/reconcile.test.ts +0 -333
- package/src/entitlements/reconcile.ts +0 -384
- package/src/entitlements/types.ts +0 -21
- package/src/entitlements/usage.test.ts +0 -314
- package/src/entitlements/usage.ts +0 -223
- package/src/errors/HttpError.test.ts +0 -76
- package/src/errors/HttpError.ts +0 -91
- package/src/groups/client.test.ts +0 -215
- package/src/groups/client.ts +0 -182
- package/src/groups/index.ts +0 -7
- package/src/groups/membership.test.ts +0 -84
- package/src/groups/membership.ts +0 -133
- package/src/groups/subject.test.ts +0 -85
- package/src/groups/subject.ts +0 -50
- package/src/health/createHealthCheck.test.ts +0 -89
- package/src/health/createHealthCheck.ts +0 -67
- package/src/health/healthController.test.ts +0 -113
- package/src/health/healthController.ts +0 -56
- package/src/index.ts +0 -88
- package/src/logging/logger.test.ts +0 -91
- package/src/logging/logger.ts +0 -103
- package/src/metrics/index.test.ts +0 -116
- package/src/metrics/index.ts +0 -111
- package/src/middleware/authMiddleware.test.ts +0 -275
- package/src/middleware/authMiddleware.ts +0 -91
- package/src/middleware/corsMiddleware.test.ts +0 -135
- package/src/middleware/corsMiddleware.ts +0 -65
- package/src/middleware/errorHandler.test.ts +0 -188
- package/src/middleware/errorHandler.ts +0 -103
- package/src/middleware/internalServiceAuth.test.ts +0 -173
- package/src/middleware/internalServiceAuth.ts +0 -96
- package/src/middleware/requestLogger.test.ts +0 -81
- package/src/middleware/requestLogger.ts +0 -48
- package/src/middleware/security.test.ts +0 -45
- package/src/middleware/security.ts +0 -43
- package/src/middleware/validate.test.ts +0 -72
- package/src/middleware/validate.ts +0 -23
- package/src/oauth/index.ts +0 -29
- package/src/oauth/models.ts +0 -164
- package/src/oauth/service.test.ts +0 -432
- package/src/oauth/service.ts +0 -299
- package/src/oauth/tokens.test.ts +0 -146
- package/src/oauth/tokens.ts +0 -186
- package/src/testing/serviceTestSetup.ts +0 -17
- package/src/tracing/index.test.ts +0 -272
- package/src/tracing/index.ts +0 -110
- package/src/types/auth.ts +0 -45
- package/src/utils/correlation.test.ts +0 -47
- package/src/utils/correlation.ts +0 -22
- package/src/utils/permissionUtils.test.ts +0 -47
- package/src/utils/permissionUtils.ts +0 -68
- package/src/utils/response.test.ts +0 -64
- package/src/utils/response.ts +0 -60
- package/tsconfig.build.json +0 -7
- package/tsconfig.json +0 -23
|
@@ -1,401 +0,0 @@
|
|
|
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 { PERSONAL_TENANT } from './types';
|
|
15
|
-
import {
|
|
16
|
-
createApiKey,
|
|
17
|
-
listApiKeys,
|
|
18
|
-
revealApiKey,
|
|
19
|
-
revokeApiKey,
|
|
20
|
-
deleteApiKey,
|
|
21
|
-
verifyApiKey
|
|
22
|
-
} from './service';
|
|
23
|
-
import { encryptSecret, generateKey, parseKey, resetEncryptionKeyCache, sha256 } from './crypto';
|
|
24
|
-
|
|
25
|
-
const mockedCreate = ApiKey.create as unknown as jest.Mock;
|
|
26
|
-
const mockedFind = ApiKey.find as unknown as jest.Mock;
|
|
27
|
-
const mockedFindOne = ApiKey.findOne as unknown as jest.Mock;
|
|
28
|
-
const mockedUpdateOne = ApiKey.updateOne as unknown as jest.Mock;
|
|
29
|
-
const mockedDeleteOne = ApiKey.deleteOne as unknown as jest.Mock;
|
|
30
|
-
|
|
31
|
-
const CREATED_AT = new Date('2026-09-01T00:00:00.000Z');
|
|
32
|
-
|
|
33
|
-
/** A stored key document, with the save()/field mutation the service relies on. */
|
|
34
|
-
function storedKey(over: Record<string, unknown> = {}) {
|
|
35
|
-
return {
|
|
36
|
-
_id: 'k1',
|
|
37
|
-
userId: 'u1',
|
|
38
|
-
userEmail: 'u1@example.com',
|
|
39
|
-
userName: 'Tester',
|
|
40
|
-
name: 'Claude',
|
|
41
|
-
keyId: 'abcdef123456',
|
|
42
|
-
hash: sha256('a-secret-value-long-enough'),
|
|
43
|
-
sealedCiphertext: 'ct',
|
|
44
|
-
sealedIv: 'iv',
|
|
45
|
-
sealedTag: 'tag',
|
|
46
|
-
scopes: ['relationship:read'],
|
|
47
|
-
tenants: [PERSONAL_TENANT],
|
|
48
|
-
defaultTenant: PERSONAL_TENANT,
|
|
49
|
-
revealCount: 0,
|
|
50
|
-
lastRevealedAt: undefined as Date | undefined,
|
|
51
|
-
createdAt: CREATED_AT,
|
|
52
|
-
lastUsedAt: undefined,
|
|
53
|
-
expiresAt: undefined,
|
|
54
|
-
revokedAt: undefined,
|
|
55
|
-
save: jest.fn().mockResolvedValue(undefined),
|
|
56
|
-
...over
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
beforeEach(() => {
|
|
61
|
-
jest.clearAllMocks();
|
|
62
|
-
process.env = { ...ORIGINAL_ENV, API_KEY_ENCRYPTION_SECRET: 'test-encryption-secret' };
|
|
63
|
-
resetEncryptionKeyCache();
|
|
64
|
-
mockedUpdateOne.mockResolvedValue({});
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
afterEach(() => {
|
|
68
|
-
process.env = ORIGINAL_ENV;
|
|
69
|
-
resetEncryptionKeyCache();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
describe('createApiKey', () => {
|
|
73
|
-
beforeEach(() => mockedCreate.mockImplementation(async (doc) => ({ ...doc, _id: 'k1', createdAt: CREATED_AT })));
|
|
74
|
-
|
|
75
|
-
const input = {
|
|
76
|
-
userId: 'u1',
|
|
77
|
-
userEmail: 'u1@example.com',
|
|
78
|
-
userName: 'Tester',
|
|
79
|
-
name: 'Claude',
|
|
80
|
-
scopes: ['relationship:read' as const]
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
it('returns a usable token and stores only its digest', async () => {
|
|
84
|
-
const { token } = await createApiKey(input);
|
|
85
|
-
const [stored] = mockedCreate.mock.calls[0];
|
|
86
|
-
|
|
87
|
-
// The plaintext must never reach the hash column — that is the whole point
|
|
88
|
-
// of keeping a digest alongside the reversible copy.
|
|
89
|
-
expect(stored.hash).not.toContain(token);
|
|
90
|
-
expect(stored.hash).toBe(sha256(parseKey(token)!.secret));
|
|
91
|
-
expect(stored.keyId).toBe(parseKey(token)!.id);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it('stores the token encrypted, recoverable only with the secret', async () => {
|
|
95
|
-
const { token } = await createApiKey(input);
|
|
96
|
-
const [stored] = mockedCreate.mock.calls[0];
|
|
97
|
-
|
|
98
|
-
expect(stored.sealedCiphertext).not.toContain(token);
|
|
99
|
-
expect(stored.sealedIv).toBeTruthy();
|
|
100
|
-
expect(stored.sealedTag).toBeTruthy();
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('defaults a key with no stated tenants to the owner’s personal data', async () => {
|
|
104
|
-
await createApiKey(input);
|
|
105
|
-
expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT]);
|
|
106
|
-
expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe(PERSONAL_TENANT);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('stores every tenant a key was granted', async () => {
|
|
110
|
-
await createApiKey({ ...input, tenants: [PERSONAL_TENANT, 'g1'], defaultTenant: 'g1' });
|
|
111
|
-
|
|
112
|
-
expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT, 'g1']);
|
|
113
|
-
expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe('g1');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('refuses to store a default outside the grant', async () => {
|
|
117
|
-
// A key acting by default in something it cannot reach would authenticate
|
|
118
|
-
// and then fail every call that did not name a tenant explicitly.
|
|
119
|
-
await createApiKey({ ...input, tenants: ['g1'], defaultTenant: 'g2' });
|
|
120
|
-
|
|
121
|
-
expect(mockedCreate.mock.calls[0][0].defaultTenant).toBe('g1');
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it('reads an empty grant as the safest non-empty one', async () => {
|
|
125
|
-
await createApiKey({ ...input, tenants: [] });
|
|
126
|
-
expect(mockedCreate.mock.calls[0][0].tenants).toEqual([PERSONAL_TENANT]);
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('snapshots the owner so verification needs no second query', async () => {
|
|
130
|
-
await createApiKey(input);
|
|
131
|
-
expect(mockedCreate.mock.calls[0][0]).toMatchObject({
|
|
132
|
-
userEmail: 'u1@example.com',
|
|
133
|
-
userName: 'Tester'
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
it('never returns the secret material in the summary', async () => {
|
|
138
|
-
const { summary } = await createApiKey(input);
|
|
139
|
-
expect(summary).not.toHaveProperty('hash');
|
|
140
|
-
expect(summary).not.toHaveProperty('sealedCiphertext');
|
|
141
|
-
expect(summary.prefix).toBe(`tmb_live_${mockedCreate.mock.calls[0][0].keyId}`);
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
describe('listApiKeys', () => {
|
|
146
|
-
it('lists the owner’s keys newest first, carrying no secrets', async () => {
|
|
147
|
-
const sort = jest.fn().mockResolvedValue([storedKey(), storedKey({ _id: 'k2', name: 'Other' })]);
|
|
148
|
-
mockedFind.mockReturnValue({ sort });
|
|
149
|
-
|
|
150
|
-
const keys = await listApiKeys('u1');
|
|
151
|
-
|
|
152
|
-
expect(mockedFind).toHaveBeenCalledWith({ userId: 'u1' });
|
|
153
|
-
expect(sort).toHaveBeenCalledWith({ createdAt: -1 });
|
|
154
|
-
for (const key of keys) {
|
|
155
|
-
expect(key).not.toHaveProperty('hash');
|
|
156
|
-
expect(key).not.toHaveProperty('sealedCiphertext');
|
|
157
|
-
expect(JSON.stringify(key)).not.toContain('ct');
|
|
158
|
-
}
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it('renders absent timestamps as null rather than undefined', async () => {
|
|
162
|
-
mockedFind.mockReturnValue({ sort: jest.fn().mockResolvedValue([storedKey()]) });
|
|
163
|
-
const [key] = await listApiKeys('u1');
|
|
164
|
-
|
|
165
|
-
expect(key.lastUsedAt).toBeNull();
|
|
166
|
-
expect(key.expiresAt).toBeNull();
|
|
167
|
-
expect(key.revokedAt).toBeNull();
|
|
168
|
-
});
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
describe('revealApiKey', () => {
|
|
172
|
-
it('decrypts the stored token for its owner', async () => {
|
|
173
|
-
const { token } = generateKey();
|
|
174
|
-
const sealed = encryptSecret(token);
|
|
175
|
-
const key = storedKey({
|
|
176
|
-
sealedCiphertext: sealed.ciphertext,
|
|
177
|
-
sealedIv: sealed.iv,
|
|
178
|
-
sealedTag: sealed.tag
|
|
179
|
-
});
|
|
180
|
-
mockedFindOne.mockResolvedValue(key);
|
|
181
|
-
|
|
182
|
-
await expect(revealApiKey('u1', 'k1')).resolves.toBe(token);
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it('scopes the lookup to the owner, so another user’s key is simply not found', async () => {
|
|
186
|
-
mockedFindOne.mockResolvedValue(null);
|
|
187
|
-
|
|
188
|
-
await expect(revealApiKey('someone-else', 'k1')).resolves.toBeNull();
|
|
189
|
-
// Ownership is part of the query rather than a check afterwards — there is
|
|
190
|
-
// no path where a mismatched user still reaches the decrypt.
|
|
191
|
-
expect(mockedFindOne).toHaveBeenCalledWith({ _id: 'k1', userId: 'someone-else' });
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it('counts and timestamps every reveal', async () => {
|
|
195
|
-
const sealed = encryptSecret(generateKey().token);
|
|
196
|
-
const key = storedKey({
|
|
197
|
-
sealedCiphertext: sealed.ciphertext,
|
|
198
|
-
sealedIv: sealed.iv,
|
|
199
|
-
sealedTag: sealed.tag,
|
|
200
|
-
revealCount: 2
|
|
201
|
-
});
|
|
202
|
-
mockedFindOne.mockResolvedValue(key);
|
|
203
|
-
|
|
204
|
-
await revealApiKey('u1', 'k1');
|
|
205
|
-
|
|
206
|
-
expect(key.revealCount).toBe(3);
|
|
207
|
-
expect(key.lastRevealedAt).toBeInstanceOf(Date);
|
|
208
|
-
expect(key.save).toHaveBeenCalled();
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it('still reveals a revoked key, so the owner can see what they turned off', async () => {
|
|
212
|
-
const { token } = generateKey();
|
|
213
|
-
const sealed = encryptSecret(token);
|
|
214
|
-
mockedFindOne.mockResolvedValue(
|
|
215
|
-
storedKey({
|
|
216
|
-
sealedCiphertext: sealed.ciphertext,
|
|
217
|
-
sealedIv: sealed.iv,
|
|
218
|
-
sealedTag: sealed.tag,
|
|
219
|
-
revokedAt: new Date()
|
|
220
|
-
})
|
|
221
|
-
);
|
|
222
|
-
|
|
223
|
-
await expect(revealApiKey('u1', 'k1')).resolves.toBe(token);
|
|
224
|
-
});
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
describe('revokeApiKey', () => {
|
|
228
|
-
it('stamps the key revoked', async () => {
|
|
229
|
-
const key = storedKey();
|
|
230
|
-
mockedFindOne.mockResolvedValue(key);
|
|
231
|
-
|
|
232
|
-
const summary = await revokeApiKey('u1', 'k1');
|
|
233
|
-
|
|
234
|
-
expect(key.revokedAt).toBeInstanceOf(Date);
|
|
235
|
-
expect(key.save).toHaveBeenCalled();
|
|
236
|
-
expect(summary?.revokedAt).not.toBeNull();
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it('leaves an already-revoked key’s original timestamp alone', async () => {
|
|
240
|
-
const revokedAt = new Date('2026-01-01T00:00:00.000Z');
|
|
241
|
-
const key = storedKey({ revokedAt });
|
|
242
|
-
mockedFindOne.mockResolvedValue(key);
|
|
243
|
-
|
|
244
|
-
await revokeApiKey('u1', 'k1');
|
|
245
|
-
|
|
246
|
-
expect(key.revokedAt).toBe(revokedAt);
|
|
247
|
-
expect(key.save).not.toHaveBeenCalled();
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it('returns null for a key that is not the caller’s', async () => {
|
|
251
|
-
mockedFindOne.mockResolvedValue(null);
|
|
252
|
-
await expect(revokeApiKey('u2', 'k1')).resolves.toBeNull();
|
|
253
|
-
});
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
describe('deleteApiKey', () => {
|
|
257
|
-
it('deletes only within the owner’s keys', async () => {
|
|
258
|
-
mockedDeleteOne.mockResolvedValue({ deletedCount: 1 });
|
|
259
|
-
|
|
260
|
-
await expect(deleteApiKey('u1', 'k1')).resolves.toBe(true);
|
|
261
|
-
expect(mockedDeleteOne).toHaveBeenCalledWith({ _id: 'k1', userId: 'u1' });
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
it('reports false when nothing matched', async () => {
|
|
265
|
-
mockedDeleteOne.mockResolvedValue({ deletedCount: 0 });
|
|
266
|
-
await expect(deleteApiKey('u2', 'k1')).resolves.toBe(false);
|
|
267
|
-
});
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
describe('verifyApiKey', () => {
|
|
271
|
-
/** A stored record that will actually accept `token`. */
|
|
272
|
-
const acceptingKey = (token: string, over: Record<string, unknown> = {}) =>
|
|
273
|
-
storedKey({
|
|
274
|
-
keyId: parseKey(token)!.id,
|
|
275
|
-
hash: sha256(parseKey(token)!.secret),
|
|
276
|
-
...over
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it('rejects a malformed token without touching the database', async () => {
|
|
280
|
-
await expect(verifyApiKey('not-a-key')).resolves.toEqual({ ok: false, rejection: 'malformed' });
|
|
281
|
-
expect(mockedFindOne).not.toHaveBeenCalled();
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it('rejects a token for a key that does not exist', async () => {
|
|
285
|
-
mockedFindOne.mockResolvedValue(null);
|
|
286
|
-
const { token } = generateKey();
|
|
287
|
-
|
|
288
|
-
await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'unknown' });
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
it('rejects a revoked key', async () => {
|
|
292
|
-
const { token } = generateKey();
|
|
293
|
-
mockedFindOne.mockResolvedValue(acceptingKey(token, { revokedAt: new Date() }));
|
|
294
|
-
|
|
295
|
-
await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'revoked' });
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
it('rejects a key whose expiry has passed', async () => {
|
|
299
|
-
const { token } = generateKey();
|
|
300
|
-
mockedFindOne.mockResolvedValue(acceptingKey(token, { expiresAt: new Date(Date.now() - 1000) }));
|
|
301
|
-
|
|
302
|
-
await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'expired' });
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it('accepts a key whose expiry is still ahead', async () => {
|
|
306
|
-
const { token } = generateKey();
|
|
307
|
-
mockedFindOne.mockResolvedValue(acceptingKey(token, { expiresAt: new Date(Date.now() + 60_000) }));
|
|
308
|
-
|
|
309
|
-
await expect(verifyApiKey(token)).resolves.toMatchObject({ ok: true });
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it('rejects a right-shaped token whose secret is wrong', async () => {
|
|
313
|
-
const { token } = generateKey();
|
|
314
|
-
const other = generateKey();
|
|
315
|
-
// Same public id, a different secret's digest — the id half is not a credential.
|
|
316
|
-
mockedFindOne.mockResolvedValue(
|
|
317
|
-
storedKey({ keyId: parseKey(token)!.id, hash: sha256(parseKey(other.token)!.secret) })
|
|
318
|
-
);
|
|
319
|
-
|
|
320
|
-
await expect(verifyApiKey(token)).resolves.toEqual({ ok: false, rejection: 'bad-secret' });
|
|
321
|
-
});
|
|
322
|
-
|
|
323
|
-
it('looks a key up by its public id alone', async () => {
|
|
324
|
-
const { token } = generateKey();
|
|
325
|
-
mockedFindOne.mockResolvedValue(acceptingKey(token));
|
|
326
|
-
|
|
327
|
-
await verifyApiKey(token);
|
|
328
|
-
|
|
329
|
-
expect(mockedFindOne).toHaveBeenCalledWith({ keyId: parseKey(token)!.id });
|
|
330
|
-
});
|
|
331
|
-
|
|
332
|
-
it('returns the identity, scopes and tenants a valid key carries', async () => {
|
|
333
|
-
const { token } = generateKey();
|
|
334
|
-
mockedFindOne.mockResolvedValue(
|
|
335
|
-
acceptingKey(token, {
|
|
336
|
-
scopes: ['relationship:read', 'album:write'],
|
|
337
|
-
tenants: [PERSONAL_TENANT, 'g1'],
|
|
338
|
-
defaultTenant: 'g1'
|
|
339
|
-
})
|
|
340
|
-
);
|
|
341
|
-
|
|
342
|
-
await expect(verifyApiKey(token)).resolves.toEqual({
|
|
343
|
-
ok: true,
|
|
344
|
-
userId: 'u1',
|
|
345
|
-
userEmail: 'u1@example.com',
|
|
346
|
-
userName: 'Tester',
|
|
347
|
-
keyId: 'k1',
|
|
348
|
-
scopes: ['relationship:read', 'album:write'],
|
|
349
|
-
// The key's own name, so an audit trail reads "Claude" and not "k1".
|
|
350
|
-
label: 'Claude',
|
|
351
|
-
tenants: { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' }
|
|
352
|
-
});
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
it('reads a key issued before tenants were a set', async () => {
|
|
356
|
-
// Keys stored under the single-tenant model carry `groupId` and no array.
|
|
357
|
-
// Without this fallback every connected assistant would break at once on
|
|
358
|
-
// the deploy that introduced the set.
|
|
359
|
-
const { token } = generateKey();
|
|
360
|
-
mockedFindOne.mockResolvedValue(
|
|
361
|
-
acceptingKey(token, { tenants: undefined, defaultTenant: undefined, groupId: 'g1' })
|
|
362
|
-
);
|
|
363
|
-
|
|
364
|
-
await expect(verifyApiKey(token)).resolves.toMatchObject({
|
|
365
|
-
tenants: { allowed: ['g1'], default: 'g1' }
|
|
366
|
-
});
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
it('reads an old personal key as the personal tenant, not as no tenant', async () => {
|
|
370
|
-
const { token } = generateKey();
|
|
371
|
-
mockedFindOne.mockResolvedValue(
|
|
372
|
-
acceptingKey(token, { tenants: undefined, defaultTenant: undefined, groupId: null })
|
|
373
|
-
);
|
|
374
|
-
|
|
375
|
-
await expect(verifyApiKey(token)).resolves.toMatchObject({
|
|
376
|
-
tenants: { allowed: [PERSONAL_TENANT], default: PERSONAL_TENANT }
|
|
377
|
-
});
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
it('records that the key was used', async () => {
|
|
381
|
-
const { token } = generateKey();
|
|
382
|
-
mockedFindOne.mockResolvedValue(acceptingKey(token));
|
|
383
|
-
|
|
384
|
-
await verifyApiKey(token);
|
|
385
|
-
|
|
386
|
-
expect(mockedUpdateOne).toHaveBeenCalledWith(
|
|
387
|
-
{ _id: 'k1' },
|
|
388
|
-
{ $set: { lastUsedAt: expect.any(Date) } }
|
|
389
|
-
);
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
it('still authenticates when recording the usage timestamp fails', async () => {
|
|
393
|
-
const { token } = generateKey();
|
|
394
|
-
mockedFindOne.mockResolvedValue(acceptingKey(token));
|
|
395
|
-
mockedUpdateOne.mockRejectedValue(new Error('write concern failed'));
|
|
396
|
-
|
|
397
|
-
// The timestamp is a convenience for the listing UI; losing it must never
|
|
398
|
-
// cost an otherwise valid request.
|
|
399
|
-
await expect(verifyApiKey(token)).resolves.toMatchObject({ ok: true });
|
|
400
|
-
});
|
|
401
|
-
});
|
package/src/apiKeys/service.ts
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import { ApiKey, IApiKey } from './ApiKey';
|
|
2
|
-
import {
|
|
3
|
-
decryptSecret,
|
|
4
|
-
displayPrefix,
|
|
5
|
-
encryptSecret,
|
|
6
|
-
generateKey,
|
|
7
|
-
parseKey,
|
|
8
|
-
secretMatches
|
|
9
|
-
} from './crypto';
|
|
10
|
-
import {
|
|
11
|
-
PERSONAL_TENANT,
|
|
12
|
-
readTenants,
|
|
13
|
-
type ApiKeyScope,
|
|
14
|
-
type ApiKeySummary,
|
|
15
|
-
type ApiKeyVerification,
|
|
16
|
-
type Tenant
|
|
17
|
-
} from './types';
|
|
18
|
-
|
|
19
|
-
export interface CreateApiKeyInput {
|
|
20
|
-
userId: string;
|
|
21
|
-
userEmail: string;
|
|
22
|
-
userName: string;
|
|
23
|
-
name: string;
|
|
24
|
-
scopes: ApiKeyScope[];
|
|
25
|
-
/** every tenant the key may act in; defaults to the user's own data alone */
|
|
26
|
-
tenants?: Tenant[];
|
|
27
|
-
/** which of them it acts in when a request names none */
|
|
28
|
-
defaultTenant?: Tenant;
|
|
29
|
-
expiresAt?: Date | null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface CreatedApiKey {
|
|
33
|
-
summary: ApiKeySummary;
|
|
34
|
-
/** the full token — the only time it is returned without an explicit reveal */
|
|
35
|
-
token: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const toSummary = (key: IApiKey): ApiKeySummary => ({
|
|
39
|
-
id: String(key._id),
|
|
40
|
-
name: key.name,
|
|
41
|
-
prefix: displayPrefix(key.keyId),
|
|
42
|
-
scopes: key.scopes,
|
|
43
|
-
...(({ allowed, default: fallback }) => ({ tenants: allowed, defaultTenant: fallback }))(
|
|
44
|
-
readTenants(key)
|
|
45
|
-
),
|
|
46
|
-
createdAt: key.createdAt.toISOString(),
|
|
47
|
-
lastUsedAt: key.lastUsedAt?.toISOString() ?? null,
|
|
48
|
-
expiresAt: key.expiresAt?.toISOString() ?? null,
|
|
49
|
-
revokedAt: key.revokedAt?.toISOString() ?? null
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
export const createApiKey = async (input: CreateApiKeyInput): Promise<CreatedApiKey> => {
|
|
53
|
-
// A key that reaches nowhere would authenticate and then fail every call, so
|
|
54
|
-
// an empty grant is read as the safest non-empty one rather than stored.
|
|
55
|
-
const tenants = input.tenants?.length ? input.tenants : [PERSONAL_TENANT];
|
|
56
|
-
|
|
57
|
-
const { token, id, hash } = generateKey();
|
|
58
|
-
const sealed = encryptSecret(token);
|
|
59
|
-
|
|
60
|
-
const key = await ApiKey.create({
|
|
61
|
-
userId: input.userId,
|
|
62
|
-
userEmail: input.userEmail,
|
|
63
|
-
userName: input.userName,
|
|
64
|
-
name: input.name,
|
|
65
|
-
keyId: id,
|
|
66
|
-
hash,
|
|
67
|
-
sealedCiphertext: sealed.ciphertext,
|
|
68
|
-
sealedIv: sealed.iv,
|
|
69
|
-
sealedTag: sealed.tag,
|
|
70
|
-
scopes: input.scopes,
|
|
71
|
-
tenants,
|
|
72
|
-
defaultTenant: tenants.includes(input.defaultTenant ?? '')
|
|
73
|
-
? input.defaultTenant
|
|
74
|
-
: (tenants[0] as Tenant),
|
|
75
|
-
expiresAt: input.expiresAt ?? undefined
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
return { summary: toSummary(key), token };
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
export const listApiKeys = async (userId: string): Promise<ApiKeySummary[]> => {
|
|
82
|
-
const keys = await ApiKey.find({ userId }).sort({ createdAt: -1 });
|
|
83
|
-
return keys.map(toSummary);
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Read a key back in the clear.
|
|
88
|
-
*
|
|
89
|
-
* Scoped to the owner by query rather than by a check afterwards, so there is no
|
|
90
|
-
* path where a mismatched userId still reaches the decrypt. Revoked keys are
|
|
91
|
-
* still revealable — the user may need to see which key they just turned off.
|
|
92
|
-
*/
|
|
93
|
-
export const revealApiKey = async (userId: string, id: string): Promise<string | null> => {
|
|
94
|
-
const key = await ApiKey.findOne({ _id: id, userId });
|
|
95
|
-
if (!key) return null;
|
|
96
|
-
|
|
97
|
-
const token = decryptSecret({
|
|
98
|
-
ciphertext: key.sealedCiphertext,
|
|
99
|
-
iv: key.sealedIv,
|
|
100
|
-
tag: key.sealedTag
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
key.revealCount += 1;
|
|
104
|
-
key.lastRevealedAt = new Date();
|
|
105
|
-
await key.save();
|
|
106
|
-
|
|
107
|
-
return token;
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
/** Revoking is a tombstone, not a delete: the audit trail outlives the key. */
|
|
111
|
-
export const revokeApiKey = async (userId: string, id: string): Promise<ApiKeySummary | null> => {
|
|
112
|
-
const key = await ApiKey.findOne({ _id: id, userId });
|
|
113
|
-
if (!key) return null;
|
|
114
|
-
|
|
115
|
-
if (!key.revokedAt) {
|
|
116
|
-
key.revokedAt = new Date();
|
|
117
|
-
await key.save();
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return toSummary(key);
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
export const deleteApiKey = async (userId: string, id: string): Promise<boolean> => {
|
|
124
|
-
const { deletedCount } = await ApiKey.deleteOne({ _id: id, userId });
|
|
125
|
-
return deletedCount > 0;
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Check a presented token.
|
|
130
|
-
*
|
|
131
|
-
* Every failure returns the same shape and the caller answers all of them with
|
|
132
|
-
* one message: distinguishing "no such key" from "wrong secret" to the client
|
|
133
|
-
* would confirm which half of a guess was right. The `rejection` field exists
|
|
134
|
-
* for the server's own logs.
|
|
135
|
-
*
|
|
136
|
-
* `lastUsedAt` is written on the side and never awaited — it is a convenience
|
|
137
|
-
* for the listing UI, and making every authenticated request wait on a write to
|
|
138
|
-
* maintain it would be a poor trade.
|
|
139
|
-
*/
|
|
140
|
-
export const verifyApiKey = async (token: unknown): Promise<ApiKeyVerification> => {
|
|
141
|
-
const parsed = parseKey(token);
|
|
142
|
-
if (!parsed) return { ok: false, rejection: 'malformed' };
|
|
143
|
-
|
|
144
|
-
const key = await ApiKey.findOne({ keyId: parsed.id });
|
|
145
|
-
if (!key) return { ok: false, rejection: 'unknown' };
|
|
146
|
-
if (key.revokedAt) return { ok: false, rejection: 'revoked' };
|
|
147
|
-
if (key.expiresAt && key.expiresAt.getTime() <= Date.now()) {
|
|
148
|
-
return { ok: false, rejection: 'expired' };
|
|
149
|
-
}
|
|
150
|
-
if (!secretMatches(parsed.secret, key.hash)) {
|
|
151
|
-
return { ok: false, rejection: 'bad-secret' };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
void ApiKey.updateOne({ _id: key._id }, { $set: { lastUsedAt: new Date() } }).catch(() => {
|
|
155
|
-
// A missed usage timestamp must never fail an otherwise valid request.
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
return {
|
|
159
|
-
ok: true,
|
|
160
|
-
userId: key.userId,
|
|
161
|
-
userEmail: key.userEmail,
|
|
162
|
-
userName: key.userName,
|
|
163
|
-
keyId: String(key._id),
|
|
164
|
-
label: key.name,
|
|
165
|
-
scopes: key.scopes,
|
|
166
|
-
tenants: readTenants(key)
|
|
167
|
-
};
|
|
168
|
-
};
|
|
@@ -1,41 +0,0 @@
|
|
|
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
|
-
});
|