@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.
- package/dist/apiKeys/ApiKey.d.ts +12 -3
- package/dist/apiKeys/ApiKey.d.ts.map +1 -1
- package/dist/apiKeys/ApiKey.js +5 -1
- package/dist/apiKeys/ApiKey.js.map +1 -1
- package/dist/apiKeys/index.d.ts +2 -2
- package/dist/apiKeys/index.d.ts.map +1 -1
- package/dist/apiKeys/index.js +5 -1
- package/dist/apiKeys/index.js.map +1 -1
- package/dist/apiKeys/middleware.d.ts +9 -5
- package/dist/apiKeys/middleware.d.ts.map +1 -1
- package/dist/apiKeys/middleware.js +125 -59
- package/dist/apiKeys/middleware.js.map +1 -1
- package/dist/apiKeys/service.d.ts +5 -3
- package/dist/apiKeys/service.d.ts.map +1 -1
- package/dist/apiKeys/service.js +10 -3
- package/dist/apiKeys/service.js.map +1 -1
- package/dist/apiKeys/types.d.ts +41 -8
- package/dist/apiKeys/types.d.ts.map +1 -1
- package/dist/apiKeys/types.js +35 -1
- package/dist/apiKeys/types.js.map +1 -1
- package/dist/middleware/authMiddleware.d.ts +7 -0
- package/dist/middleware/authMiddleware.d.ts.map +1 -1
- package/dist/middleware/authMiddleware.js +25 -4
- package/dist/middleware/authMiddleware.js.map +1 -1
- package/dist/oauth/index.d.ts +1 -1
- package/dist/oauth/index.d.ts.map +1 -1
- package/dist/oauth/index.js +3 -1
- package/dist/oauth/index.js.map +1 -1
- package/dist/oauth/models.d.ts +13 -2
- package/dist/oauth/models.d.ts.map +1 -1
- package/dist/oauth/models.js +9 -0
- package/dist/oauth/models.js.map +1 -1
- package/dist/oauth/service.d.ts +15 -8
- package/dist/oauth/service.d.ts.map +1 -1
- package/dist/oauth/service.js +10 -3
- package/dist/oauth/service.js.map +1 -1
- package/dist/oauth/tokens.d.ts +34 -8
- package/dist/oauth/tokens.d.ts.map +1 -1
- package/dist/oauth/tokens.js +22 -6
- package/dist/oauth/tokens.js.map +1 -1
- package/package.json +1 -1
- package/src/apiKeys/ApiKey.ts +16 -4
- package/src/apiKeys/index.ts +16 -2
- package/src/apiKeys/middleware.test.ts +207 -16
- package/src/apiKeys/middleware.ts +159 -66
- package/src/apiKeys/service.test.ts +56 -9
- package/src/apiKeys/service.ts +24 -6
- package/src/apiKeys/types.ts +61 -8
- package/src/middleware/authMiddleware.test.ts +52 -0
- package/src/middleware/authMiddleware.ts +26 -4
- package/src/oauth/index.ts +7 -1
- package/src/oauth/models.ts +21 -2
- package/src/oauth/service.test.ts +58 -6
- package/src/oauth/service.ts +25 -7
- package/src/oauth/tokens.test.ts +20 -5
- package/src/oauth/tokens.ts +51 -11
package/src/apiKeys/ApiKey.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import mongoose, { Document, Schema } from 'mongoose';
|
|
2
|
-
import type
|
|
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
|
-
/**
|
|
35
|
-
|
|
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
|
-
//
|
|
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 },
|
package/src/apiKeys/index.ts
CHANGED
|
@@ -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 {
|
|
15
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
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({
|
|
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({
|
|
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
|
|
211
|
-
mockedVerify.mockResolvedValue(okVerification({
|
|
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({
|
|
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({
|
|
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({
|
|
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
|
|
256
|
-
mockedVerify.mockResolvedValue(okVerification({
|
|
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({
|
|
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({
|
|
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
|
+
});
|