@tumbaland/backend-core 1.36.0 → 1.38.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.
Files changed (56) hide show
  1. package/dist/apiKeys/ApiKey.d.ts +12 -3
  2. package/dist/apiKeys/ApiKey.d.ts.map +1 -1
  3. package/dist/apiKeys/ApiKey.js +5 -1
  4. package/dist/apiKeys/ApiKey.js.map +1 -1
  5. package/dist/apiKeys/index.d.ts +2 -2
  6. package/dist/apiKeys/index.d.ts.map +1 -1
  7. package/dist/apiKeys/index.js +5 -1
  8. package/dist/apiKeys/index.js.map +1 -1
  9. package/dist/apiKeys/middleware.d.ts +9 -5
  10. package/dist/apiKeys/middleware.d.ts.map +1 -1
  11. package/dist/apiKeys/middleware.js +125 -59
  12. package/dist/apiKeys/middleware.js.map +1 -1
  13. package/dist/apiKeys/service.d.ts +5 -3
  14. package/dist/apiKeys/service.d.ts.map +1 -1
  15. package/dist/apiKeys/service.js +10 -3
  16. package/dist/apiKeys/service.js.map +1 -1
  17. package/dist/apiKeys/types.d.ts +41 -8
  18. package/dist/apiKeys/types.d.ts.map +1 -1
  19. package/dist/apiKeys/types.js +35 -1
  20. package/dist/apiKeys/types.js.map +1 -1
  21. package/dist/middleware/authMiddleware.d.ts +7 -0
  22. package/dist/middleware/authMiddleware.d.ts.map +1 -1
  23. package/dist/middleware/authMiddleware.js +25 -4
  24. package/dist/middleware/authMiddleware.js.map +1 -1
  25. package/dist/oauth/index.d.ts +1 -1
  26. package/dist/oauth/index.d.ts.map +1 -1
  27. package/dist/oauth/index.js +3 -1
  28. package/dist/oauth/index.js.map +1 -1
  29. package/dist/oauth/models.d.ts +13 -2
  30. package/dist/oauth/models.d.ts.map +1 -1
  31. package/dist/oauth/models.js +9 -0
  32. package/dist/oauth/models.js.map +1 -1
  33. package/dist/oauth/service.d.ts +15 -8
  34. package/dist/oauth/service.d.ts.map +1 -1
  35. package/dist/oauth/service.js +10 -3
  36. package/dist/oauth/service.js.map +1 -1
  37. package/dist/oauth/tokens.d.ts +34 -8
  38. package/dist/oauth/tokens.d.ts.map +1 -1
  39. package/dist/oauth/tokens.js +22 -6
  40. package/dist/oauth/tokens.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/apiKeys/ApiKey.ts +16 -4
  43. package/src/apiKeys/index.ts +16 -2
  44. package/src/apiKeys/middleware.test.ts +207 -16
  45. package/src/apiKeys/middleware.ts +159 -66
  46. package/src/apiKeys/service.test.ts +56 -9
  47. package/src/apiKeys/service.ts +24 -6
  48. package/src/apiKeys/types.ts +61 -8
  49. package/src/middleware/authMiddleware.test.ts +52 -0
  50. package/src/middleware/authMiddleware.ts +26 -4
  51. package/src/oauth/index.ts +7 -1
  52. package/src/oauth/models.ts +21 -2
  53. package/src/oauth/service.test.ts +58 -6
  54. package/src/oauth/service.ts +25 -7
  55. package/src/oauth/tokens.test.ts +20 -5
  56. package/src/oauth/tokens.ts +51 -11
@@ -1,5 +1,5 @@
1
1
  import mongoose, { Document, Schema } from 'mongoose';
2
- import type { ApiKeyScope } from './types';
2
+ import { PERSONAL_TENANT, type ApiKeyScope, type Tenant } from './types';
3
3
 
4
4
  /**
5
5
  * One API key. Lives in backend-core rather than auth-service because both
@@ -31,8 +31,17 @@ export interface IApiKey extends Document {
31
31
  sealedIv: string;
32
32
  sealedTag: string;
33
33
  scopes: ApiKeyScope[];
34
- /** the tenant this key acts in; null means the user's own non-group data */
35
- groupId: string | null;
34
+ /** every tenant this key may act in; see `Tenant` */
35
+ tenants: Tenant[];
36
+ /** the one it acts in when a request names none; always a member of `tenants` */
37
+ defaultTenant: Tenant;
38
+ /**
39
+ * The single tenant this key was pinned to, before tenants were a set.
40
+ *
41
+ * Kept so keys issued under the old model keep working; `readTenants` falls
42
+ * back to it. Nothing writes it any more.
43
+ */
44
+ groupId?: string | null;
36
45
  lastUsedAt?: Date;
37
46
  expiresAt?: Date;
38
47
  revokedAt?: Date;
@@ -55,8 +64,11 @@ const ApiKeySchema = new Schema<IApiKey>(
55
64
  sealedIv: { type: String, required: true },
56
65
  sealedTag: { type: String, required: true },
57
66
  scopes: { type: [String], default: [] },
58
- // Explicitly nullable rather than optional: "personal scope" is a decision the
67
+ // Defaulted rather than optional: which data a key reaches is a decision the
59
68
  // creator made, and it must not be indistinguishable from a field nobody set.
69
+ tenants: { type: [String], default: () => [PERSONAL_TENANT] },
70
+ defaultTenant: { type: String, default: PERSONAL_TENANT },
71
+ // Written by the single-tenant model this replaced; read-only now.
60
72
  groupId: { type: String, default: null },
61
73
  lastUsedAt: { type: Date },
62
74
  expiresAt: { type: Date },
@@ -11,8 +11,22 @@ export {
11
11
  export type { CreateApiKeyInput, CreatedApiKey } from './service';
12
12
  export { authenticateAgent, requireScope, denyApiKeys } from './middleware';
13
13
  export type { ApiKeyContext } from './middleware';
14
- export { API_KEY_SCOPES, isApiKeyScope } from './types';
15
- export type { ApiKeyScope, ApiKeySummary, ApiKeyVerification, ApiKeyRejection } from './types';
14
+ export {
15
+ API_KEY_SCOPES,
16
+ isApiKeyScope,
17
+ PERSONAL_TENANT,
18
+ groupIdOf,
19
+ groupIdsOf,
20
+ readTenants
21
+ } from './types';
22
+ export type {
23
+ ApiKeyScope,
24
+ ApiKeySummary,
25
+ ApiKeyVerification,
26
+ ApiKeyRejection,
27
+ ApiKeyTenant,
28
+ Tenant
29
+ } from './types';
16
30
  export {
17
31
  looksLikeApiKey,
18
32
  displayPrefix,
@@ -8,6 +8,8 @@ jest.mock('../logging/logger', () => ({
8
8
 
9
9
  import jwt from 'jsonwebtoken';
10
10
  import { verifyApiKey } from './service';
11
+ import { mintAccessToken, type MintAccessTokenInput } from '../oauth/tokens';
12
+ import { PERSONAL_TENANT } from './types';
11
13
  import { authenticateAgent, requireScope, denyApiKeys } from './middleware';
12
14
 
13
15
  const mockedVerify = verifyApiKey as jest.Mock;
@@ -26,6 +28,9 @@ const req = (over: Partial<Request> = {}): Request =>
26
28
  const withKey = (token = 'tmb_live_abcdef123456_secretsecretsecret', over: Partial<Request> = {}) =>
27
29
  req({ headers: { authorization: `Bearer ${token}` }, ...over });
28
30
 
31
+ /** A grant of one or more tenants; the first is the default unless said otherwise. */
32
+ const grant = (...allowed: string[]) => ({ allowed, default: allowed[0] });
33
+
29
34
  const okVerification = (over: Record<string, unknown> = {}) => ({
30
35
  ok: true,
31
36
  userId: 'u1',
@@ -33,7 +38,7 @@ const okVerification = (over: Record<string, unknown> = {}) => ({
33
38
  userName: 'Tester',
34
39
  keyId: 'k1',
35
40
  scopes: ['relationship:read', 'relationship:write'],
36
- groupId: null,
41
+ tenants: grant(PERSONAL_TENANT),
37
42
  ...over
38
43
  });
39
44
 
@@ -110,7 +115,8 @@ describe('authenticateAgent — API keys', () => {
110
115
  expect(request.apiKey).toEqual({
111
116
  keyId: 'k1',
112
117
  scopes: ['relationship:read', 'relationship:write'],
113
- groupId: null
118
+ tenants: grant(PERSONAL_TENANT),
119
+ actingAs: PERSONAL_TENANT
114
120
  });
115
121
  });
116
122
 
@@ -166,7 +172,7 @@ it('authenticates a key stored before the owner snapshot existed', async () => {
166
172
  userId: 'u1',
167
173
  keyId: 'k1',
168
174
  scopes: ['relationship:read'],
169
- groupId: null
175
+ tenants: grant(PERSONAL_TENANT)
170
176
  });
171
177
  const request = withKey();
172
178
  const next = jest.fn();
@@ -178,7 +184,7 @@ it('authenticates a key stored before the owner snapshot existed', async () => {
178
184
  });
179
185
 
180
186
  it('treats a verification result with no scopes as granting nothing', async () => {
181
- mockedVerify.mockResolvedValue({ ok: true, userId: 'u1', keyId: 'k1', groupId: null });
187
+ mockedVerify.mockResolvedValue({ ok: true, userId: 'u1', keyId: 'k1', tenants: grant(PERSONAL_TENANT) });
182
188
  const res = mockRes();
183
189
  const next = jest.fn();
184
190
 
@@ -188,9 +194,14 @@ it('treats a verification result with no scopes as granting nothing', async () =
188
194
  expect(next).not.toHaveBeenCalled();
189
195
  });
190
196
 
191
- describe('authenticateAgent — tenant pinning', () => {
197
+ describe('authenticateAgent — a single-tenant grant', () => {
198
+ /**
199
+ * The behaviour a pinned credential always had, kept intact now that a grant
200
+ * is a set. An agent given one tenant should never learn that a tenant is a
201
+ * thing it could name — nothing to choose, nothing to get wrong.
202
+ */
192
203
  it('grants a personal key no groups at all', async () => {
193
- mockedVerify.mockResolvedValue(okVerification({ groupId: null }));
204
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant(PERSONAL_TENANT) }));
194
205
  const request = withKey();
195
206
 
196
207
  await authenticateAgent()(request, mockRes(), jest.fn());
@@ -199,7 +210,7 @@ describe('authenticateAgent — tenant pinning', () => {
199
210
  });
200
211
 
201
212
  it('grants a group key exactly its own group, not the owner’s whole membership', async () => {
202
- mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
213
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
203
214
  const request = withKey();
204
215
 
205
216
  await authenticateAgent()(request, mockRes(), jest.fn());
@@ -207,8 +218,8 @@ describe('authenticateAgent — tenant pinning', () => {
207
218
  expect(request.userGroups).toEqual(['g1']);
208
219
  });
209
220
 
210
- it('fills in the pinned group so the caller never has to know it', async () => {
211
- mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
221
+ it('fills in the group so the caller never has to know it', async () => {
222
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
212
223
  const request = withKey();
213
224
  const next = jest.fn();
214
225
 
@@ -220,7 +231,7 @@ describe('authenticateAgent — tenant pinning', () => {
220
231
  });
221
232
 
222
233
  it('refuses a query that names a different group', async () => {
223
- mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
234
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
224
235
  const res = mockRes();
225
236
  const next = jest.fn();
226
237
 
@@ -231,7 +242,7 @@ describe('authenticateAgent — tenant pinning', () => {
231
242
  });
232
243
 
233
244
  it('refuses a body that names a different group', async () => {
234
- mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
245
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
235
246
  const res = mockRes();
236
247
  const next = jest.fn();
237
248
 
@@ -242,7 +253,7 @@ describe('authenticateAgent — tenant pinning', () => {
242
253
  });
243
254
 
244
255
  it('refuses a personal key that tries to reach into a group', async () => {
245
- mockedVerify.mockResolvedValue(okVerification({ groupId: null }));
256
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant(PERSONAL_TENANT) }));
246
257
  const res = mockRes();
247
258
  const next = jest.fn();
248
259
 
@@ -252,8 +263,8 @@ describe('authenticateAgent — tenant pinning', () => {
252
263
  expect(next).not.toHaveBeenCalled();
253
264
  });
254
265
 
255
- it('allows a request that names the pinned group explicitly', async () => {
256
- mockedVerify.mockResolvedValue(okVerification({ groupId: 'g1' }));
266
+ it('allows a request that names the granted group explicitly', async () => {
267
+ mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
257
268
  const next = jest.fn();
258
269
 
259
270
  await authenticateAgent()(withKey(undefined, { query: { groupId: 'g1' } }), mockRes(), next);
@@ -262,9 +273,76 @@ describe('authenticateAgent — tenant pinning', () => {
262
273
  });
263
274
  });
264
275
 
276
+ describe('authenticateAgent — a multi-tenant grant', () => {
277
+ /**
278
+ * What the set buys: one connection reaching a shared journal and private data
279
+ * without reconnecting. The boundary is unchanged — a tenant outside the grant
280
+ * is still refused — so these tests are about the choice *inside* it.
281
+ */
282
+ const both = { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' };
283
+
284
+ it('acts in the default when the request names nothing', async () => {
285
+ mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
286
+ const request = withKey();
287
+
288
+ await authenticateAgent()(request, mockRes(), jest.fn());
289
+
290
+ expect(request.query.groupId).toBe('g1');
291
+ expect(request.apiKey?.actingAs).toBe('g1');
292
+ });
293
+
294
+ it('acts in another granted tenant when the request names it', async () => {
295
+ mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
296
+ const request = withKey(undefined, { query: { groupId: PERSONAL_TENANT } });
297
+
298
+ await authenticateAgent()(request, mockRes(), jest.fn());
299
+
300
+ // `personal` is erased rather than passed on: handlers have always read the
301
+ // personal tenant as an absent groupId, and a literal would reach a query as
302
+ // a group that cannot exist.
303
+ expect(request.query.groupId).toBeUndefined();
304
+ expect(request.apiKey?.actingAs).toBe(PERSONAL_TENANT);
305
+ });
306
+
307
+ it('erases a stale groupId from the body too, not only the query', async () => {
308
+ mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
309
+ const request = withKey(undefined, { body: { groupId: PERSONAL_TENANT } });
310
+
311
+ await authenticateAgent()(request, mockRes(), jest.fn());
312
+
313
+ expect((request.body as Record<string, unknown>).groupId).toBeUndefined();
314
+ });
315
+
316
+ it('still refuses a tenant outside the grant', async () => {
317
+ mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
318
+ const res = mockRes();
319
+ const next = jest.fn();
320
+
321
+ await authenticateAgent()(withKey(undefined, { query: { groupId: 'g2' } }), res, next);
322
+
323
+ expect(res.status).toHaveBeenCalledWith(403);
324
+ expect(next).not.toHaveBeenCalled();
325
+ });
326
+
327
+ it('carries every granted group into userGroups, and only those', async () => {
328
+ mockedVerify.mockResolvedValue(
329
+ okVerification({ tenants: { allowed: [PERSONAL_TENANT, 'g1', 'g2'], default: 'g1' } })
330
+ );
331
+ const request = withKey();
332
+
333
+ await authenticateAgent()(request, mockRes(), jest.fn());
334
+
335
+ // The personal tenant is not a group and must not appear here, or it would
336
+ // reach a `groupId: { $in: ... }` clause as a group nobody belongs to.
337
+ expect(request.userGroups).toEqual(['g1', 'g2']);
338
+ });
339
+ });
340
+
265
341
  describe('requireScope', () => {
266
342
  const keyReq = (scopes: string[]) =>
267
- req({ apiKey: { keyId: 'k1', scopes, groupId: null } } as Partial<Request>);
343
+ req({
344
+ apiKey: { keyId: 'k1', scopes, tenants: grant(PERSONAL_TENANT), actingAs: PERSONAL_TENANT }
345
+ } as Partial<Request>);
268
346
 
269
347
  it('lets a session through unconditionally', () => {
270
348
  // Scopes narrow what software may do on a person's behalf, not what the
@@ -342,7 +420,9 @@ describe('denyApiKeys', () => {
342
420
  const next = jest.fn();
343
421
 
344
422
  denyApiKeys(
345
- req({ apiKey: { keyId: 'k1', scopes: [], groupId: null } }) as Request,
423
+ req({
424
+ apiKey: { keyId: 'k1', scopes: [], tenants: grant(PERSONAL_TENANT), actingAs: PERSONAL_TENANT }
425
+ }) as Request,
346
426
  res,
347
427
  next
348
428
  );
@@ -351,3 +431,114 @@ describe('denyApiKeys', () => {
351
431
  expect(next).not.toHaveBeenCalled();
352
432
  });
353
433
  });
434
+
435
+ describe('authenticateAgent — OAuth access tokens', () => {
436
+ /**
437
+ * Minted by the real thing rather than hand-signed. The bug this covers was
438
+ * that an access token verified as a session — same secret, same header — and
439
+ * its claims were then read as a session's: `sub` instead of `id`, no
440
+ * `groups`. Every one of those details has to come from the code that issues
441
+ * them, or the test simply re-encodes whatever the middleware happens to do.
442
+ */
443
+ const mint = (over: Partial<MintAccessTokenInput> = {}) =>
444
+ mintAccessToken({
445
+ userId: 'u1',
446
+ email: 'u1@example.com',
447
+ name: 'Tester',
448
+ resource: 'https://mcp.example.com',
449
+ issuer: 'https://auth.example.com',
450
+ scopes: ['relationship:read', 'relationship:write'],
451
+ tenants: [PERSONAL_TENANT],
452
+ defaultTenant: PERSONAL_TENANT,
453
+ tenantNames: { [PERSONAL_TENANT]: 'My own data' },
454
+ ...over
455
+ }).accessToken;
456
+
457
+ const withToken = (token: string, over: Partial<Request> = {}) =>
458
+ req({ headers: { authorization: `Bearer ${token}` }, ...over });
459
+
460
+ it('names the user, so scoped queries are bounded', async () => {
461
+ const request = withToken(mint());
462
+ const next = jest.fn();
463
+
464
+ await authenticateAgent('relationship:write')(request, mockRes(), next);
465
+
466
+ expect(next).toHaveBeenCalled();
467
+ // Read as a session this was `undefined`, which reached handlers as "no
468
+ // user" rather than as a refusal: writes were rejected as inaccessible and
469
+ // reads fell through to an unscoped query.
470
+ expect(request.user?.id).toBe('u1');
471
+ expect(request.user?.email).toBe('u1@example.com');
472
+ });
473
+
474
+ it('is held to its scopes, exactly as a key is', async () => {
475
+ const res = mockRes();
476
+ const next = jest.fn();
477
+ const request = withToken(mint({ scopes: ['relationship:read'] }));
478
+
479
+ await authenticateAgent('relationship:write')(request, res, next);
480
+
481
+ expect(res.status).toHaveBeenCalledWith(403);
482
+ expect(next).not.toHaveBeenCalled();
483
+ });
484
+
485
+ it('carries an apiKey context, so requireScope applies downstream', async () => {
486
+ const request = withToken(mint({ scopes: ['relationship:read'] }));
487
+
488
+ await authenticateAgent()(request, mockRes(), jest.fn());
489
+
490
+ // Without this the token looked like a session and every requireScope on
491
+ // every route waved it through.
492
+ expect(request.apiKey?.scopes).toEqual(['relationship:read']);
493
+
494
+ const res = mockRes();
495
+ const next = jest.fn();
496
+ requireScope('relationship:write')(request, res, next);
497
+
498
+ expect(res.status).toHaveBeenCalledWith(403);
499
+ expect(next).not.toHaveBeenCalled();
500
+ });
501
+
502
+ it('acts in the tenant chosen at consent', async () => {
503
+ const request = withToken(mint({ tenants: ['g1'], defaultTenant: 'g1' }));
504
+ const next = jest.fn();
505
+
506
+ await authenticateAgent()(request, mockRes(), next);
507
+
508
+ expect(next).toHaveBeenCalled();
509
+ expect(request.userGroups).toEqual(['g1']);
510
+ // Written in for a caller that named none, so a handler's group branch
511
+ // lands where the person who approved the connection intended.
512
+ expect(request.query.groupId).toBe('g1');
513
+ });
514
+
515
+ it('refuses a request naming a different tenant', async () => {
516
+ const res = mockRes();
517
+ const next = jest.fn();
518
+ const request = withToken(mint({ tenants: ['g1'], defaultTenant: 'g1' }), {
519
+ query: { groupId: 'g2' }
520
+ });
521
+
522
+ await authenticateAgent()(request, res, next);
523
+
524
+ expect(res.status).toHaveBeenCalledWith(403);
525
+ expect(next).not.toHaveBeenCalled();
526
+ });
527
+
528
+ it('is refused by denyApiKeys, like any other credential software holds', async () => {
529
+ const request = withToken(mint());
530
+ await authenticateAgent()(request, mockRes(), jest.fn());
531
+
532
+ const res = mockRes();
533
+ const next = jest.fn();
534
+ denyApiKeys(request, res, next);
535
+
536
+ expect(res.status).toHaveBeenCalledWith(403);
537
+ expect(next).not.toHaveBeenCalled();
538
+ });
539
+
540
+ it('never consults the API key store', async () => {
541
+ await authenticateAgent()(withToken(mint()), mockRes(), jest.fn());
542
+ expect(mockedVerify).not.toHaveBeenCalled();
543
+ });
544
+ });