@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,651 +0,0 @@
|
|
|
1
|
-
import { Request, Response } from 'express';
|
|
2
|
-
|
|
3
|
-
jest.mock('./service', () => ({ verifyApiKey: jest.fn() }));
|
|
4
|
-
jest.mock('../logging/logger', () => ({
|
|
5
|
-
__esModule: true,
|
|
6
|
-
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() }
|
|
7
|
-
}));
|
|
8
|
-
|
|
9
|
-
import jwt from 'jsonwebtoken';
|
|
10
|
-
import { verifyApiKey } from './service';
|
|
11
|
-
import { mintAccessToken, type MintAccessTokenInput } from '../oauth/tokens';
|
|
12
|
-
import { PERSONAL_TENANT } from './types';
|
|
13
|
-
import { authenticateAgent, requireScope, denyApiKeys } from './middleware';
|
|
14
|
-
import { overrideSessionCheckForTests } from '../auth/session';
|
|
15
|
-
import { overrideMembershipLookupForTests } from '../groups/membership';
|
|
16
|
-
|
|
17
|
-
const mockedVerify = verifyApiKey as jest.Mock;
|
|
18
|
-
const ORIGINAL_ENV = process.env;
|
|
19
|
-
|
|
20
|
-
function mockRes(): Response {
|
|
21
|
-
const res: Partial<Response> = {};
|
|
22
|
-
res.status = jest.fn().mockReturnValue(res);
|
|
23
|
-
res.json = jest.fn().mockReturnValue(res);
|
|
24
|
-
return res as Response;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const req = (over: Partial<Request> = {}): Request =>
|
|
28
|
-
({ query: {}, body: {}, headers: {}, path: '/activities', ...over }) as unknown as Request;
|
|
29
|
-
|
|
30
|
-
const withKey = (token = 'tmb_live_abcdef123456_secretsecretsecret', over: Partial<Request> = {}) =>
|
|
31
|
-
req({ headers: { authorization: `Bearer ${token}` }, ...over });
|
|
32
|
-
|
|
33
|
-
/** A grant of one or more tenants; the first is the default unless said otherwise. */
|
|
34
|
-
const grant = (...allowed: string[]) => ({ allowed, default: allowed[0] });
|
|
35
|
-
|
|
36
|
-
const okVerification = (over: Record<string, unknown> = {}) => ({
|
|
37
|
-
ok: true,
|
|
38
|
-
userId: 'u1',
|
|
39
|
-
userEmail: 'u1@example.com',
|
|
40
|
-
userName: 'Tester',
|
|
41
|
-
keyId: 'k1',
|
|
42
|
-
scopes: ['relationship:read', 'relationship:write'],
|
|
43
|
-
tenants: grant(PERSONAL_TENANT),
|
|
44
|
-
label: 'Claude Code',
|
|
45
|
-
...over
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
// Unless a test says otherwise, the owner still belongs to every group used here.
|
|
49
|
-
let memberOf: string[];
|
|
50
|
-
|
|
51
|
-
beforeEach(() => {
|
|
52
|
-
jest.clearAllMocks();
|
|
53
|
-
process.env = { ...ORIGINAL_ENV, JWT_SECRET: 'test-jwt-secret' };
|
|
54
|
-
memberOf = ['g1', 'g2'];
|
|
55
|
-
overrideMembershipLookupForTests(async () => memberOf);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
afterAll(() => overrideMembershipLookupForTests());
|
|
59
|
-
|
|
60
|
-
afterEach(() => {
|
|
61
|
-
process.env = ORIGINAL_ENV;
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe('authenticateAgent — no credentials', () => {
|
|
65
|
-
it('rejects a request with no token', async () => {
|
|
66
|
-
const res = mockRes();
|
|
67
|
-
const next = jest.fn();
|
|
68
|
-
|
|
69
|
-
await authenticateAgent()(req(), res, next);
|
|
70
|
-
|
|
71
|
-
expect(res.status).toHaveBeenCalledWith(401);
|
|
72
|
-
expect(next).not.toHaveBeenCalled();
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
describe('authenticateAgent — session tokens', () => {
|
|
77
|
-
beforeEach(() => overrideSessionCheckForTests(async () => true));
|
|
78
|
-
afterAll(() => overrideSessionCheckForTests());
|
|
79
|
-
|
|
80
|
-
it('rejects a validly signed JWT whose session has ended', async () => {
|
|
81
|
-
overrideSessionCheckForTests(async () => false);
|
|
82
|
-
const token = jwt.sign({ id: 'u1', email: 'a@b.c', name: 'A', sid: 'a'.repeat(24) }, 'test-jwt-secret');
|
|
83
|
-
const res = mockRes();
|
|
84
|
-
const next = jest.fn();
|
|
85
|
-
|
|
86
|
-
await authenticateAgent()(req({ headers: { authorization: `Bearer ${token}` } }), res, next);
|
|
87
|
-
|
|
88
|
-
expect(res.status).toHaveBeenCalledWith(401);
|
|
89
|
-
expect(next).not.toHaveBeenCalled();
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("takes the person's groups from group-service, not from the token", async () => {
|
|
93
|
-
memberOf = ['g2'];
|
|
94
|
-
const token = jwt.sign({ id: 'u1', email: 'a@b.c', name: 'A', groups: ['g1'] }, 'test-jwt-secret');
|
|
95
|
-
const request = req({ headers: { authorization: `Bearer ${token}` } });
|
|
96
|
-
|
|
97
|
-
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
98
|
-
|
|
99
|
-
expect(request.userGroups).toEqual(['g2']);
|
|
100
|
-
expect(request.user?.groups).toEqual(['g2']);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('accepts a valid JWT and grants the full session, with no apiKey context', async () => {
|
|
104
|
-
const token = jwt.sign({ id: 'u1', email: 'a@b.c', name: 'A', groups: ['g1', 'g2'] }, 'test-jwt-secret');
|
|
105
|
-
const request = req({ headers: { authorization: `Bearer ${token}` } });
|
|
106
|
-
const next = jest.fn();
|
|
107
|
-
|
|
108
|
-
await authenticateAgent('relationship:write')(request, mockRes(), next);
|
|
109
|
-
|
|
110
|
-
expect(next).toHaveBeenCalled();
|
|
111
|
-
expect(request.user?.id).toBe('u1');
|
|
112
|
-
// A person keeps every group they belong to — scopes constrain keys, not sessions.
|
|
113
|
-
expect(request.userGroups).toEqual(['g1', 'g2']);
|
|
114
|
-
expect(request.apiKey).toBeUndefined();
|
|
115
|
-
expect(mockedVerify).not.toHaveBeenCalled();
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it('reads the token from a cookie as well as the header', async () => {
|
|
119
|
-
const token = jwt.sign({ id: 'u1', email: 'a@b.c', name: 'A' }, 'test-jwt-secret');
|
|
120
|
-
const request = req({ cookies: { access_token: token } } as Partial<Request>);
|
|
121
|
-
const next = jest.fn();
|
|
122
|
-
|
|
123
|
-
await authenticateAgent()(request, mockRes(), next);
|
|
124
|
-
|
|
125
|
-
expect(next).toHaveBeenCalled();
|
|
126
|
-
expect(request.user?.id).toBe('u1');
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it('rejects a JWT signed with the wrong secret', async () => {
|
|
130
|
-
const token = jwt.sign({ id: 'u1' }, 'not-the-secret');
|
|
131
|
-
const res = mockRes();
|
|
132
|
-
const next = jest.fn();
|
|
133
|
-
|
|
134
|
-
await authenticateAgent()(req({ headers: { authorization: `Bearer ${token}` } }), res, next);
|
|
135
|
-
|
|
136
|
-
expect(res.status).toHaveBeenCalledWith(401);
|
|
137
|
-
expect(next).not.toHaveBeenCalled();
|
|
138
|
-
});
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
describe('authenticateAgent — API keys', () => {
|
|
142
|
-
it('accepts a valid key and populates the request as a session would', async () => {
|
|
143
|
-
mockedVerify.mockResolvedValue(okVerification());
|
|
144
|
-
const request = withKey();
|
|
145
|
-
const next = jest.fn();
|
|
146
|
-
|
|
147
|
-
await authenticateAgent('relationship:read')(request, mockRes(), next);
|
|
148
|
-
|
|
149
|
-
expect(next).toHaveBeenCalled();
|
|
150
|
-
expect(request.user).toEqual({ id: 'u1', email: 'u1@example.com', name: 'Tester' });
|
|
151
|
-
expect(request.apiKey).toEqual({
|
|
152
|
-
keyId: 'k1',
|
|
153
|
-
scopes: ['relationship:read', 'relationship:write'],
|
|
154
|
-
tenants: grant(PERSONAL_TENANT),
|
|
155
|
-
actingAs: PERSONAL_TENANT,
|
|
156
|
-
kind: 'api_key',
|
|
157
|
-
// What the owner called it, so a trail reads "Claude Code" not an id.
|
|
158
|
-
label: 'Claude Code',
|
|
159
|
-
credentialId: 'k1'
|
|
160
|
-
});
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it.each([
|
|
164
|
-
['unknown'], ['revoked'], ['expired'], ['bad-secret'], ['malformed']
|
|
165
|
-
])('answers a %s key with the same opaque 401', async (rejection) => {
|
|
166
|
-
mockedVerify.mockResolvedValue({ ok: false, rejection });
|
|
167
|
-
const res = mockRes();
|
|
168
|
-
const next = jest.fn();
|
|
169
|
-
|
|
170
|
-
await authenticateAgent()(withKey(), res, next);
|
|
171
|
-
|
|
172
|
-
expect(res.status).toHaveBeenCalledWith(401);
|
|
173
|
-
// Nothing in the body distinguishes the five cases — that difference would
|
|
174
|
-
// tell someone guessing which half of the guess was right.
|
|
175
|
-
expect(res.json).toHaveBeenCalledWith({
|
|
176
|
-
success: false,
|
|
177
|
-
message: 'Invalid or expired credentials'
|
|
178
|
-
});
|
|
179
|
-
expect(next).not.toHaveBeenCalled();
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('refuses a key missing a required scope, and names what is missing', async () => {
|
|
183
|
-
mockedVerify.mockResolvedValue(okVerification({ scopes: ['relationship:read'] }));
|
|
184
|
-
const res = mockRes();
|
|
185
|
-
const next = jest.fn();
|
|
186
|
-
|
|
187
|
-
await authenticateAgent('relationship:write')(withKey(), res, next);
|
|
188
|
-
|
|
189
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
190
|
-
expect(res.json).toHaveBeenCalledWith(
|
|
191
|
-
expect.objectContaining({ message: expect.stringContaining('relationship:write') })
|
|
192
|
-
);
|
|
193
|
-
expect(next).not.toHaveBeenCalled();
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it('requires every listed scope, not just one of them', async () => {
|
|
197
|
-
mockedVerify.mockResolvedValue(okVerification({ scopes: ['relationship:read'] }));
|
|
198
|
-
const next = jest.fn();
|
|
199
|
-
|
|
200
|
-
await authenticateAgent('relationship:read', 'album:write')(withKey(), mockRes(), next);
|
|
201
|
-
|
|
202
|
-
expect(next).not.toHaveBeenCalled();
|
|
203
|
-
});
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
it('authenticates a key stored before the owner snapshot existed', async () => {
|
|
207
|
-
// Records written by an earlier version carry no userEmail/userName. Such a
|
|
208
|
-
// key must still work — the id is what every handler actually reads — rather
|
|
209
|
-
// than 500 on a missing field.
|
|
210
|
-
mockedVerify.mockResolvedValue({
|
|
211
|
-
ok: true,
|
|
212
|
-
userId: 'u1',
|
|
213
|
-
keyId: 'k1',
|
|
214
|
-
scopes: ['relationship:read'],
|
|
215
|
-
tenants: grant(PERSONAL_TENANT)
|
|
216
|
-
});
|
|
217
|
-
const request = withKey();
|
|
218
|
-
const next = jest.fn();
|
|
219
|
-
|
|
220
|
-
await authenticateAgent('relationship:read')(request, mockRes(), next);
|
|
221
|
-
|
|
222
|
-
expect(next).toHaveBeenCalled();
|
|
223
|
-
expect(request.user).toEqual({ id: 'u1', email: '', name: '' });
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
it('treats a verification result with no scopes as granting nothing', async () => {
|
|
227
|
-
mockedVerify.mockResolvedValue({ ok: true, userId: 'u1', keyId: 'k1', tenants: grant(PERSONAL_TENANT) });
|
|
228
|
-
const res = mockRes();
|
|
229
|
-
const next = jest.fn();
|
|
230
|
-
|
|
231
|
-
await authenticateAgent('relationship:read')(withKey(), res, next);
|
|
232
|
-
|
|
233
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
234
|
-
expect(next).not.toHaveBeenCalled();
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
describe('authenticateAgent — a single-tenant grant', () => {
|
|
238
|
-
/**
|
|
239
|
-
* The behaviour a pinned credential always had, kept intact now that a grant
|
|
240
|
-
* is a set. An agent given one tenant should never learn that a tenant is a
|
|
241
|
-
* thing it could name — nothing to choose, nothing to get wrong.
|
|
242
|
-
*/
|
|
243
|
-
it('grants a personal key no groups at all', async () => {
|
|
244
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: grant(PERSONAL_TENANT) }));
|
|
245
|
-
const request = withKey();
|
|
246
|
-
|
|
247
|
-
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
248
|
-
|
|
249
|
-
expect(request.userGroups).toEqual([]);
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
it('grants a group key exactly its own group, not the owner’s whole membership', async () => {
|
|
253
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
|
|
254
|
-
const request = withKey();
|
|
255
|
-
|
|
256
|
-
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
257
|
-
|
|
258
|
-
expect(request.userGroups).toEqual(['g1']);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it('fills in the group so the caller never has to know it', async () => {
|
|
262
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
|
|
263
|
-
const request = withKey();
|
|
264
|
-
const next = jest.fn();
|
|
265
|
-
|
|
266
|
-
await authenticateAgent()(request, mockRes(), next);
|
|
267
|
-
|
|
268
|
-
expect(next).toHaveBeenCalled();
|
|
269
|
-
expect(request.query.groupId).toBe('g1');
|
|
270
|
-
expect((request.body as Record<string, unknown>).groupId).toBe('g1');
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it('refuses a query that names a different group', async () => {
|
|
274
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
|
|
275
|
-
const res = mockRes();
|
|
276
|
-
const next = jest.fn();
|
|
277
|
-
|
|
278
|
-
await authenticateAgent()(withKey(undefined, { query: { groupId: 'g2' } }), res, next);
|
|
279
|
-
|
|
280
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
281
|
-
expect(next).not.toHaveBeenCalled();
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
it('refuses a body that names a different group', async () => {
|
|
285
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
|
|
286
|
-
const res = mockRes();
|
|
287
|
-
const next = jest.fn();
|
|
288
|
-
|
|
289
|
-
await authenticateAgent()(withKey(undefined, { body: { groupId: 'g2' } }), res, next);
|
|
290
|
-
|
|
291
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
292
|
-
expect(next).not.toHaveBeenCalled();
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it('refuses a personal key that tries to reach into a group', async () => {
|
|
296
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: grant(PERSONAL_TENANT) }));
|
|
297
|
-
const res = mockRes();
|
|
298
|
-
const next = jest.fn();
|
|
299
|
-
|
|
300
|
-
await authenticateAgent()(withKey(undefined, { query: { groupId: 'g1' } }), res, next);
|
|
301
|
-
|
|
302
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
303
|
-
expect(next).not.toHaveBeenCalled();
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it('allows a request that names the granted group explicitly', async () => {
|
|
307
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
|
|
308
|
-
const next = jest.fn();
|
|
309
|
-
|
|
310
|
-
await authenticateAgent()(withKey(undefined, { query: { groupId: 'g1' } }), mockRes(), next);
|
|
311
|
-
|
|
312
|
-
expect(next).toHaveBeenCalled();
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
describe('authenticateAgent — a multi-tenant grant', () => {
|
|
317
|
-
/**
|
|
318
|
-
* What the set buys: one connection reaching a shared journal and private data
|
|
319
|
-
* without reconnecting. The boundary is unchanged — a tenant outside the grant
|
|
320
|
-
* is still refused — so these tests are about the choice *inside* it.
|
|
321
|
-
*/
|
|
322
|
-
const both = { allowed: [PERSONAL_TENANT, 'g1'], default: 'g1' };
|
|
323
|
-
|
|
324
|
-
it('acts in the default when the request names nothing', async () => {
|
|
325
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
|
|
326
|
-
const request = withKey();
|
|
327
|
-
|
|
328
|
-
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
329
|
-
|
|
330
|
-
expect(request.query.groupId).toBe('g1');
|
|
331
|
-
expect(request.apiKey?.actingAs).toBe('g1');
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
it('acts in another granted tenant when the request names it', async () => {
|
|
335
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
|
|
336
|
-
const request = withKey(undefined, { query: { groupId: PERSONAL_TENANT } });
|
|
337
|
-
|
|
338
|
-
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
339
|
-
|
|
340
|
-
// `personal` is erased rather than passed on: handlers have always read the
|
|
341
|
-
// personal tenant as an absent groupId, and a literal would reach a query as
|
|
342
|
-
// a group that cannot exist.
|
|
343
|
-
expect(request.query.groupId).toBeUndefined();
|
|
344
|
-
expect(request.apiKey?.actingAs).toBe(PERSONAL_TENANT);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it('erases a stale groupId from the body too, not only the query', async () => {
|
|
348
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
|
|
349
|
-
const request = withKey(undefined, { body: { groupId: PERSONAL_TENANT } });
|
|
350
|
-
|
|
351
|
-
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
352
|
-
|
|
353
|
-
expect((request.body as Record<string, unknown>).groupId).toBeUndefined();
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
it('still refuses a tenant outside the grant', async () => {
|
|
357
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: both }));
|
|
358
|
-
const res = mockRes();
|
|
359
|
-
const next = jest.fn();
|
|
360
|
-
|
|
361
|
-
await authenticateAgent()(withKey(undefined, { query: { groupId: 'g2' } }), res, next);
|
|
362
|
-
|
|
363
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
364
|
-
expect(next).not.toHaveBeenCalled();
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
it('carries every granted group into userGroups, and only those', async () => {
|
|
368
|
-
mockedVerify.mockResolvedValue(
|
|
369
|
-
okVerification({ tenants: { allowed: [PERSONAL_TENANT, 'g1', 'g2'], default: 'g1' } })
|
|
370
|
-
);
|
|
371
|
-
const request = withKey();
|
|
372
|
-
|
|
373
|
-
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
374
|
-
|
|
375
|
-
// The personal tenant is not a group and must not appear here, or it would
|
|
376
|
-
// reach a `groupId: { $in: ... }` clause as a group nobody belongs to.
|
|
377
|
-
expect(request.userGroups).toEqual(['g1', 'g2']);
|
|
378
|
-
});
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
describe('authenticateAgent — groups the owner has since left', () => {
|
|
382
|
-
it('drops a granted group the owner no longer belongs to', async () => {
|
|
383
|
-
memberOf = ['g2'];
|
|
384
|
-
mockedVerify.mockResolvedValue(
|
|
385
|
-
okVerification({ tenants: { allowed: [PERSONAL_TENANT, 'g1', 'g2'], default: PERSONAL_TENANT } })
|
|
386
|
-
);
|
|
387
|
-
const request = withKey();
|
|
388
|
-
|
|
389
|
-
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
390
|
-
|
|
391
|
-
expect(request.userGroups).toEqual(['g2']);
|
|
392
|
-
expect(request.apiKey?.tenants.allowed).toEqual([PERSONAL_TENANT, 'g2']);
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
it('refuses a request for a group the owner has left, although the key was granted it', async () => {
|
|
396
|
-
memberOf = [];
|
|
397
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: grant(PERSONAL_TENANT, 'g1') }));
|
|
398
|
-
const res = mockRes();
|
|
399
|
-
const next = jest.fn();
|
|
400
|
-
|
|
401
|
-
await authenticateAgent()(withKey(undefined, { query: { groupId: 'g1' } } as Partial<Request>), res, next);
|
|
402
|
-
|
|
403
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
404
|
-
expect(next).not.toHaveBeenCalled();
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
it('refuses a key whose only group the owner has left, rather than acting somewhere else', async () => {
|
|
408
|
-
memberOf = [];
|
|
409
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
|
|
410
|
-
const res = mockRes();
|
|
411
|
-
const next = jest.fn();
|
|
412
|
-
|
|
413
|
-
await authenticateAgent()(withKey(), res, next);
|
|
414
|
-
|
|
415
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
416
|
-
expect(next).not.toHaveBeenCalled();
|
|
417
|
-
});
|
|
418
|
-
|
|
419
|
-
it('asks about the key owner, by email', async () => {
|
|
420
|
-
const lookup = jest.fn(async () => ['g1']);
|
|
421
|
-
overrideMembershipLookupForTests(lookup);
|
|
422
|
-
mockedVerify.mockResolvedValue(okVerification({ tenants: grant('g1') }));
|
|
423
|
-
|
|
424
|
-
await authenticateAgent()(withKey(), mockRes(), jest.fn());
|
|
425
|
-
|
|
426
|
-
expect(lookup).toHaveBeenCalledWith({ email: 'u1@example.com' });
|
|
427
|
-
});
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
describe('requireScope', () => {
|
|
431
|
-
const keyReq = (scopes: string[]) =>
|
|
432
|
-
req({
|
|
433
|
-
apiKey: {
|
|
434
|
-
keyId: 'k1',
|
|
435
|
-
scopes,
|
|
436
|
-
tenants: grant(PERSONAL_TENANT),
|
|
437
|
-
actingAs: PERSONAL_TENANT,
|
|
438
|
-
kind: 'api_key' as const,
|
|
439
|
-
label: 'A key',
|
|
440
|
-
credentialId: 'k1'
|
|
441
|
-
}
|
|
442
|
-
} as Partial<Request>);
|
|
443
|
-
|
|
444
|
-
it('lets a session through unconditionally', () => {
|
|
445
|
-
// Scopes narrow what software may do on a person's behalf, not what the
|
|
446
|
-
// person may do themselves — a session carries no apiKey context at all.
|
|
447
|
-
const next = jest.fn();
|
|
448
|
-
requireScope('relationship:write')(req(), mockRes(), next);
|
|
449
|
-
expect(next).toHaveBeenCalled();
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
it('lets a key with the required scope through', () => {
|
|
453
|
-
const next = jest.fn();
|
|
454
|
-
requireScope('relationship:read')(keyReq(['relationship:read']), mockRes(), next);
|
|
455
|
-
expect(next).toHaveBeenCalled();
|
|
456
|
-
});
|
|
457
|
-
|
|
458
|
-
it('refuses a key without it, naming what is missing', () => {
|
|
459
|
-
const res = mockRes();
|
|
460
|
-
const next = jest.fn();
|
|
461
|
-
|
|
462
|
-
requireScope('relationship:write')(keyReq(['relationship:read']), res, next);
|
|
463
|
-
|
|
464
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
465
|
-
expect(res.json).toHaveBeenCalledWith(
|
|
466
|
-
expect.objectContaining({ message: expect.stringContaining('relationship:write') })
|
|
467
|
-
);
|
|
468
|
-
expect(next).not.toHaveBeenCalled();
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
it('does not let a read scope stand in for a write', () => {
|
|
472
|
-
const next = jest.fn();
|
|
473
|
-
requireScope('album:write')(keyReq(['album:read']), mockRes(), next);
|
|
474
|
-
expect(next).not.toHaveBeenCalled();
|
|
475
|
-
});
|
|
476
|
-
|
|
477
|
-
it('does not let one area’s scope stand in for another’s', () => {
|
|
478
|
-
const next = jest.fn();
|
|
479
|
-
requireScope('finance:read')(keyReq(['relationship:read', 'album:read']), mockRes(), next);
|
|
480
|
-
expect(next).not.toHaveBeenCalled();
|
|
481
|
-
});
|
|
482
|
-
|
|
483
|
-
it('requires every named scope, not just one of them', () => {
|
|
484
|
-
const res = mockRes();
|
|
485
|
-
const next = jest.fn();
|
|
486
|
-
|
|
487
|
-
requireScope('relationship:read', 'album:write')(keyReq(['relationship:read']), res, next);
|
|
488
|
-
|
|
489
|
-
expect(next).not.toHaveBeenCalled();
|
|
490
|
-
expect(res.json).toHaveBeenCalledWith(
|
|
491
|
-
expect.objectContaining({ message: expect.stringContaining('album:write') })
|
|
492
|
-
);
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
it('refuses a key with no scopes at all', () => {
|
|
496
|
-
const next = jest.fn();
|
|
497
|
-
requireScope('relationship:read')(keyReq([]), mockRes(), next);
|
|
498
|
-
expect(next).not.toHaveBeenCalled();
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
it('lets any authenticated caller through when no scope is named', () => {
|
|
502
|
-
const next = jest.fn();
|
|
503
|
-
requireScope()(keyReq([]), mockRes(), next);
|
|
504
|
-
expect(next).toHaveBeenCalled();
|
|
505
|
-
});
|
|
506
|
-
});
|
|
507
|
-
|
|
508
|
-
describe('denyApiKeys', () => {
|
|
509
|
-
it('lets a session through', () => {
|
|
510
|
-
const next = jest.fn();
|
|
511
|
-
denyApiKeys(req(), mockRes(), next);
|
|
512
|
-
expect(next).toHaveBeenCalled();
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
it('blocks a request that arrived on a key', () => {
|
|
516
|
-
const res = mockRes();
|
|
517
|
-
const next = jest.fn();
|
|
518
|
-
|
|
519
|
-
denyApiKeys(
|
|
520
|
-
req({
|
|
521
|
-
apiKey: {
|
|
522
|
-
keyId: 'k1',
|
|
523
|
-
scopes: [],
|
|
524
|
-
tenants: grant(PERSONAL_TENANT),
|
|
525
|
-
actingAs: PERSONAL_TENANT,
|
|
526
|
-
kind: 'api_key' as const,
|
|
527
|
-
label: 'A key',
|
|
528
|
-
credentialId: 'k1'
|
|
529
|
-
}
|
|
530
|
-
}) as Request,
|
|
531
|
-
res,
|
|
532
|
-
next
|
|
533
|
-
);
|
|
534
|
-
|
|
535
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
536
|
-
expect(next).not.toHaveBeenCalled();
|
|
537
|
-
});
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
describe('authenticateAgent — OAuth access tokens', () => {
|
|
541
|
-
/**
|
|
542
|
-
* Minted by the real thing rather than hand-signed. The bug this covers was
|
|
543
|
-
* that an access token verified as a session — same secret, same header — and
|
|
544
|
-
* its claims were then read as a session's: `sub` instead of `id`, no
|
|
545
|
-
* `groups`. Every one of those details has to come from the code that issues
|
|
546
|
-
* them, or the test simply re-encodes whatever the middleware happens to do.
|
|
547
|
-
*/
|
|
548
|
-
const mint = (over: Partial<MintAccessTokenInput> = {}) =>
|
|
549
|
-
mintAccessToken({
|
|
550
|
-
userId: 'u1',
|
|
551
|
-
email: 'u1@example.com',
|
|
552
|
-
name: 'Tester',
|
|
553
|
-
resource: 'https://mcp.example.com',
|
|
554
|
-
issuer: 'https://auth.example.com',
|
|
555
|
-
scopes: ['relationship:read', 'relationship:write'],
|
|
556
|
-
tenants: [PERSONAL_TENANT],
|
|
557
|
-
defaultTenant: PERSONAL_TENANT,
|
|
558
|
-
tenantNames: { [PERSONAL_TENANT]: 'My own data' },
|
|
559
|
-
clientId: 'client-1',
|
|
560
|
-
clientName: 'Claude',
|
|
561
|
-
...over
|
|
562
|
-
}).accessToken;
|
|
563
|
-
|
|
564
|
-
const withToken = (token: string, over: Partial<Request> = {}) =>
|
|
565
|
-
req({ headers: { authorization: `Bearer ${token}` }, ...over });
|
|
566
|
-
|
|
567
|
-
it('names the user, so scoped queries are bounded', async () => {
|
|
568
|
-
const request = withToken(mint());
|
|
569
|
-
const next = jest.fn();
|
|
570
|
-
|
|
571
|
-
await authenticateAgent('relationship:write')(request, mockRes(), next);
|
|
572
|
-
|
|
573
|
-
expect(next).toHaveBeenCalled();
|
|
574
|
-
// Read as a session this was `undefined`, which reached handlers as "no
|
|
575
|
-
// user" rather than as a refusal: writes were rejected as inaccessible and
|
|
576
|
-
// reads fell through to an unscoped query.
|
|
577
|
-
expect(request.user?.id).toBe('u1');
|
|
578
|
-
expect(request.user?.email).toBe('u1@example.com');
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
it('is held to its scopes, exactly as a key is', async () => {
|
|
582
|
-
const res = mockRes();
|
|
583
|
-
const next = jest.fn();
|
|
584
|
-
const request = withToken(mint({ scopes: ['relationship:read'] }));
|
|
585
|
-
|
|
586
|
-
await authenticateAgent('relationship:write')(request, res, next);
|
|
587
|
-
|
|
588
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
589
|
-
expect(next).not.toHaveBeenCalled();
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
it('carries an apiKey context, so requireScope applies downstream', async () => {
|
|
593
|
-
const request = withToken(mint({ scopes: ['relationship:read'] }));
|
|
594
|
-
|
|
595
|
-
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
596
|
-
|
|
597
|
-
// Without this the token looked like a session and every requireScope on
|
|
598
|
-
// every route waved it through.
|
|
599
|
-
expect(request.apiKey?.scopes).toEqual(['relationship:read']);
|
|
600
|
-
|
|
601
|
-
const res = mockRes();
|
|
602
|
-
const next = jest.fn();
|
|
603
|
-
requireScope('relationship:write')(request, res, next);
|
|
604
|
-
|
|
605
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
606
|
-
expect(next).not.toHaveBeenCalled();
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
it('acts in the tenant chosen at consent', async () => {
|
|
610
|
-
const request = withToken(mint({ tenants: ['g1'], defaultTenant: 'g1' }));
|
|
611
|
-
const next = jest.fn();
|
|
612
|
-
|
|
613
|
-
await authenticateAgent()(request, mockRes(), next);
|
|
614
|
-
|
|
615
|
-
expect(next).toHaveBeenCalled();
|
|
616
|
-
expect(request.userGroups).toEqual(['g1']);
|
|
617
|
-
// Written in for a caller that named none, so a handler's group branch
|
|
618
|
-
// lands where the person who approved the connection intended.
|
|
619
|
-
expect(request.query.groupId).toBe('g1');
|
|
620
|
-
});
|
|
621
|
-
|
|
622
|
-
it('refuses a request naming a different tenant', async () => {
|
|
623
|
-
const res = mockRes();
|
|
624
|
-
const next = jest.fn();
|
|
625
|
-
const request = withToken(mint({ tenants: ['g1'], defaultTenant: 'g1' }), {
|
|
626
|
-
query: { groupId: 'g2' }
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
await authenticateAgent()(request, res, next);
|
|
630
|
-
|
|
631
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
632
|
-
expect(next).not.toHaveBeenCalled();
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
it('is refused by denyApiKeys, like any other credential software holds', async () => {
|
|
636
|
-
const request = withToken(mint());
|
|
637
|
-
await authenticateAgent()(request, mockRes(), jest.fn());
|
|
638
|
-
|
|
639
|
-
const res = mockRes();
|
|
640
|
-
const next = jest.fn();
|
|
641
|
-
denyApiKeys(request, res, next);
|
|
642
|
-
|
|
643
|
-
expect(res.status).toHaveBeenCalledWith(403);
|
|
644
|
-
expect(next).not.toHaveBeenCalled();
|
|
645
|
-
});
|
|
646
|
-
|
|
647
|
-
it('never consults the API key store', async () => {
|
|
648
|
-
await authenticateAgent()(withToken(mint()), mockRes(), jest.fn());
|
|
649
|
-
expect(mockedVerify).not.toHaveBeenCalled();
|
|
650
|
-
});
|
|
651
|
-
});
|