@memberjunction/server 5.8.0 → 5.9.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/README.md +1 -0
- package/dist/apolloServer/index.d.ts +10 -2
- package/dist/apolloServer/index.d.ts.map +1 -1
- package/dist/apolloServer/index.js +22 -8
- package/dist/apolloServer/index.js.map +1 -1
- package/dist/config.d.ts +125 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +23 -0
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +17 -0
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +144 -62
- package/dist/context.js.map +1 -1
- package/dist/generated/generated.d.ts +207 -0
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +1112 -76
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/CacheInvalidationResolver.d.ts +32 -0
- package/dist/generic/CacheInvalidationResolver.d.ts.map +1 -0
- package/dist/generic/CacheInvalidationResolver.js +80 -0
- package/dist/generic/CacheInvalidationResolver.js.map +1 -0
- package/dist/generic/PubSubManager.d.ts +27 -0
- package/dist/generic/PubSubManager.d.ts.map +1 -0
- package/dist/generic/PubSubManager.js +42 -0
- package/dist/generic/PubSubManager.js.map +1 -0
- package/dist/generic/ResolverBase.d.ts +14 -0
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +50 -0
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/hooks.d.ts +65 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +14 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +172 -45
- package/dist/index.js.map +1 -1
- package/dist/multiTenancy/index.d.ts +47 -0
- package/dist/multiTenancy/index.d.ts.map +1 -0
- package/dist/multiTenancy/index.js +152 -0
- package/dist/multiTenancy/index.js.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +123 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js +624 -0
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -0
- package/dist/rest/RESTEndpointHandler.d.ts +3 -1
- package/dist/rest/RESTEndpointHandler.d.ts.map +1 -1
- package/dist/rest/RESTEndpointHandler.js +14 -33
- package/dist/rest/RESTEndpointHandler.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +61 -57
- package/src/__tests__/multiTenancy.security.test.ts +334 -0
- package/src/__tests__/multiTenancy.test.ts +225 -0
- package/src/__tests__/unifiedAuth.test.ts +416 -0
- package/src/apolloServer/index.ts +32 -16
- package/src/config.ts +25 -0
- package/src/context.ts +205 -98
- package/src/generated/generated.ts +736 -1
- package/src/generic/CacheInvalidationResolver.ts +66 -0
- package/src/generic/PubSubManager.ts +47 -0
- package/src/generic/ResolverBase.ts +53 -0
- package/src/hooks.ts +77 -0
- package/src/index.ts +198 -49
- package/src/multiTenancy/index.ts +183 -0
- package/src/resolvers/IntegrationDiscoveryResolver.ts +584 -0
- package/src/rest/RESTEndpointHandler.ts +23 -42
- package/src/types.ts +10 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the unified auth middleware (createUnifiedAuthMiddleware).
|
|
3
|
+
*
|
|
4
|
+
* Verifies: OPTIONS passthrough, token handling, userPayload attachment,
|
|
5
|
+
* 401 responses with proper error codes, and backward-compat properties.
|
|
6
|
+
*
|
|
7
|
+
* Because `createUnifiedAuthMiddleware` and `getUserPayload` live in the same
|
|
8
|
+
* module (context.ts), vi.mock cannot intercept the intra-module call.
|
|
9
|
+
* Instead we mock the LOWER-LEVEL dependencies that `getUserPayload` calls.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
12
|
+
|
|
13
|
+
// ─── Hoisted mocks (available inside vi.mock factories) ─────────────────────
|
|
14
|
+
const {
|
|
15
|
+
MockTokenExpiredError,
|
|
16
|
+
mockGetReadOnlyDS,
|
|
17
|
+
mockGetReadWriteDS,
|
|
18
|
+
mockJwtDecode,
|
|
19
|
+
mockJwtVerify,
|
|
20
|
+
mockVerifyUserRecord,
|
|
21
|
+
mockExtractUserInfo,
|
|
22
|
+
mockGetValidationOptions,
|
|
23
|
+
mockGetSigningKeys,
|
|
24
|
+
mockGetByIssuer,
|
|
25
|
+
} = vi.hoisted(() => {
|
|
26
|
+
class _MockTokenExpiredError extends Error {
|
|
27
|
+
constructor(public expiryDate: Date) {
|
|
28
|
+
super('Token expired');
|
|
29
|
+
this.name = 'TokenExpiredError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
MockTokenExpiredError: _MockTokenExpiredError,
|
|
35
|
+
mockGetReadOnlyDS: vi.fn(),
|
|
36
|
+
mockGetReadWriteDS: vi.fn(),
|
|
37
|
+
mockJwtDecode: vi.fn(),
|
|
38
|
+
mockJwtVerify: vi.fn(),
|
|
39
|
+
mockVerifyUserRecord: vi.fn(),
|
|
40
|
+
mockExtractUserInfo: vi.fn(),
|
|
41
|
+
mockGetValidationOptions: vi.fn(),
|
|
42
|
+
mockGetSigningKeys: vi.fn(),
|
|
43
|
+
mockGetByIssuer: vi.fn(),
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─── Module mocks ───────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
vi.mock('../util.js', () => ({
|
|
50
|
+
GetReadOnlyDataSource: mockGetReadOnlyDS,
|
|
51
|
+
GetReadWriteDataSource: mockGetReadWriteDS,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
vi.mock('jsonwebtoken', () => ({
|
|
55
|
+
default: {
|
|
56
|
+
decode: mockJwtDecode,
|
|
57
|
+
verify: mockJwtVerify,
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
vi.mock('../auth/index.js', () => ({
|
|
62
|
+
getSigningKeys: mockGetSigningKeys,
|
|
63
|
+
getSystemUser: vi.fn(),
|
|
64
|
+
getValidationOptions: mockGetValidationOptions,
|
|
65
|
+
verifyUserRecord: mockVerifyUserRecord,
|
|
66
|
+
extractUserInfoFromPayload: mockExtractUserInfo,
|
|
67
|
+
TokenExpiredError: MockTokenExpiredError,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
vi.mock('../cache.js', () => {
|
|
71
|
+
const map = new Map<string, boolean>();
|
|
72
|
+
return { authCache: map };
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
vi.mock('../config.js', () => ({
|
|
76
|
+
configInfo: {
|
|
77
|
+
baseUrl: 'http://localhost',
|
|
78
|
+
graphqlPort: 4001,
|
|
79
|
+
graphqlRootPath: '/',
|
|
80
|
+
databaseSettings: { metadataCacheRefreshInterval: 0 },
|
|
81
|
+
},
|
|
82
|
+
userEmailMap: {},
|
|
83
|
+
apiKey: 'test-api-key',
|
|
84
|
+
mj_core_schema: '__mj',
|
|
85
|
+
}));
|
|
86
|
+
|
|
87
|
+
vi.mock('../auth/AuthProviderFactory.js', () => ({
|
|
88
|
+
AuthProviderFactory: {
|
|
89
|
+
getInstance: () => ({ getByIssuer: mockGetByIssuer }),
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
vi.mock('@memberjunction/api-keys', () => ({
|
|
94
|
+
GetAPIKeyEngine: vi.fn(),
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
import { createUnifiedAuthMiddleware } from '../context.js';
|
|
98
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
99
|
+
import type { DataSourceInfo } from '../types.js';
|
|
100
|
+
|
|
101
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function makeMockReq(overrides: Partial<Request> = {}): Request {
|
|
104
|
+
return {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {},
|
|
107
|
+
path: '/',
|
|
108
|
+
url: '/',
|
|
109
|
+
ip: '127.0.0.1',
|
|
110
|
+
socket: { remoteAddress: '127.0.0.1' },
|
|
111
|
+
...overrides,
|
|
112
|
+
} as unknown as Request;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function makeMockRes(): Response & { _status: number; _json: Record<string, unknown> | null } {
|
|
116
|
+
const res = {
|
|
117
|
+
_status: 0,
|
|
118
|
+
_json: null as Record<string, unknown> | null,
|
|
119
|
+
status(code: number) {
|
|
120
|
+
res._status = code;
|
|
121
|
+
return res;
|
|
122
|
+
},
|
|
123
|
+
json(data: Record<string, unknown>) {
|
|
124
|
+
res._json = data;
|
|
125
|
+
return res;
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
return res as unknown as Response & { _status: number; _json: Record<string, unknown> | null };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const mockDataSources: DataSourceInfo[] = [];
|
|
132
|
+
const MOCK_POOL = {} as unknown as import('mssql').ConnectionPool;
|
|
133
|
+
|
|
134
|
+
const mockUserRecord = {
|
|
135
|
+
ID: 'user-1',
|
|
136
|
+
Name: 'Test User',
|
|
137
|
+
Email: 'test@example.com',
|
|
138
|
+
IsActive: true,
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/** Configure lower-level mocks so getUserPayload succeeds for a bearer token */
|
|
142
|
+
function setupSuccessfulAuth() {
|
|
143
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
144
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
145
|
+
// Decode returns a valid, non-expired JWT payload
|
|
146
|
+
mockJwtDecode.mockReturnValue({
|
|
147
|
+
iss: 'https://test-issuer.com',
|
|
148
|
+
sub: 'user-1',
|
|
149
|
+
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
|
150
|
+
});
|
|
151
|
+
// AuthProviderFactory finds the issuer
|
|
152
|
+
mockGetByIssuer.mockReturnValue({ name: 'test' });
|
|
153
|
+
// Validation options needed by verifyAsync
|
|
154
|
+
mockGetValidationOptions.mockReturnValue({ audience: ['test-audience'] });
|
|
155
|
+
// Signing keys callback
|
|
156
|
+
mockGetSigningKeys.mockReturnValue((_header: unknown, cb: (err: Error | null, key: string) => void) => {
|
|
157
|
+
cb(null, 'mock-key');
|
|
158
|
+
});
|
|
159
|
+
// jwt.verify calls the callback successfully
|
|
160
|
+
mockJwtVerify.mockImplementation(
|
|
161
|
+
(_token: string, _keyFn: unknown, _options: unknown, callback: (err: Error | null, decoded: unknown) => void) => {
|
|
162
|
+
callback(null, { iss: 'https://test-issuer.com', sub: 'user-1' });
|
|
163
|
+
}
|
|
164
|
+
);
|
|
165
|
+
// User info extraction
|
|
166
|
+
mockExtractUserInfo.mockReturnValue({
|
|
167
|
+
email: 'test@example.com',
|
|
168
|
+
firstName: 'Test',
|
|
169
|
+
lastName: 'User',
|
|
170
|
+
fullName: 'Test User',
|
|
171
|
+
preferredUsername: 'test@example.com',
|
|
172
|
+
});
|
|
173
|
+
// User record verification
|
|
174
|
+
mockVerifyUserRecord.mockResolvedValue(mockUserRecord);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ─── Tests ──────────────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
describe('createUnifiedAuthMiddleware', () => {
|
|
180
|
+
let middleware: ReturnType<typeof createUnifiedAuthMiddleware>;
|
|
181
|
+
|
|
182
|
+
beforeEach(() => {
|
|
183
|
+
vi.clearAllMocks();
|
|
184
|
+
middleware = createUnifiedAuthMiddleware(mockDataSources);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('OPTIONS passthrough', () => {
|
|
188
|
+
it('should call next() without auth for OPTIONS requests', async () => {
|
|
189
|
+
const req = makeMockReq({ method: 'OPTIONS' });
|
|
190
|
+
const res = makeMockRes();
|
|
191
|
+
const next = vi.fn();
|
|
192
|
+
|
|
193
|
+
await middleware(req, res, next);
|
|
194
|
+
|
|
195
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
196
|
+
expect(mockJwtDecode).not.toHaveBeenCalled();
|
|
197
|
+
expect(res._status).toBe(0); // no status set
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should not attach userPayload for OPTIONS requests', async () => {
|
|
201
|
+
const req = makeMockReq({ method: 'OPTIONS' });
|
|
202
|
+
const res = makeMockRes();
|
|
203
|
+
const next = vi.fn();
|
|
204
|
+
|
|
205
|
+
await middleware(req, res, next);
|
|
206
|
+
|
|
207
|
+
expect(req.userPayload).toBeUndefined();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe('Successful authentication', () => {
|
|
212
|
+
beforeEach(() => {
|
|
213
|
+
setupSuccessfulAuth();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should attach userPayload to req on successful auth', async () => {
|
|
217
|
+
const req = makeMockReq({
|
|
218
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
219
|
+
});
|
|
220
|
+
const res = makeMockRes();
|
|
221
|
+
const next = vi.fn();
|
|
222
|
+
|
|
223
|
+
await middleware(req, res, next);
|
|
224
|
+
|
|
225
|
+
expect(next).toHaveBeenCalledTimes(1);
|
|
226
|
+
expect(req.userPayload).toBeDefined();
|
|
227
|
+
expect(req.userPayload!.email).toBe('test@example.com');
|
|
228
|
+
expect(req.userPayload!.userRecord).toBe(mockUserRecord);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should set req.user for backward compatibility', async () => {
|
|
232
|
+
const req = makeMockReq({
|
|
233
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
234
|
+
});
|
|
235
|
+
const res = makeMockRes();
|
|
236
|
+
const next = vi.fn();
|
|
237
|
+
|
|
238
|
+
await middleware(req, res, next);
|
|
239
|
+
|
|
240
|
+
const reqRecord = req as unknown as Record<string, unknown>;
|
|
241
|
+
expect(reqRecord['user']).toBeDefined();
|
|
242
|
+
expect((reqRecord['user'] as { email: string }).email).toBe('test@example.com');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should set req.mjUser to the userRecord', async () => {
|
|
246
|
+
const req = makeMockReq({
|
|
247
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
248
|
+
});
|
|
249
|
+
const res = makeMockRes();
|
|
250
|
+
const next = vi.fn();
|
|
251
|
+
|
|
252
|
+
await middleware(req, res, next);
|
|
253
|
+
|
|
254
|
+
const reqRecord = req as unknown as Record<string, unknown>;
|
|
255
|
+
expect(reqRecord['mjUser']).toBe(mockUserRecord);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe('Authentication failures', () => {
|
|
260
|
+
it('should return 401 when no authorization header is provided', async () => {
|
|
261
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
262
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
263
|
+
// No auth header → empty bearer token → empty token after strip → "Missing token"
|
|
264
|
+
const req = makeMockReq({ headers: {} });
|
|
265
|
+
const res = makeMockRes();
|
|
266
|
+
const next = vi.fn();
|
|
267
|
+
|
|
268
|
+
await middleware(req, res, next);
|
|
269
|
+
|
|
270
|
+
expect(next).not.toHaveBeenCalled();
|
|
271
|
+
expect(res._status).toBe(401);
|
|
272
|
+
expect(res._json).toEqual({ error: 'Authentication failed' });
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should return 401 with JWT_EXPIRED code for expired tokens', async () => {
|
|
276
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
277
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
278
|
+
// jwt.decode returns a payload with expired timestamp
|
|
279
|
+
mockJwtDecode.mockReturnValue({
|
|
280
|
+
iss: 'https://test-issuer.com',
|
|
281
|
+
sub: 'user-1',
|
|
282
|
+
exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const req = makeMockReq({
|
|
286
|
+
headers: { authorization: 'Bearer expired-token' },
|
|
287
|
+
});
|
|
288
|
+
const res = makeMockRes();
|
|
289
|
+
const next = vi.fn();
|
|
290
|
+
|
|
291
|
+
await middleware(req, res, next);
|
|
292
|
+
|
|
293
|
+
expect(next).not.toHaveBeenCalled();
|
|
294
|
+
expect(res._status).toBe(401);
|
|
295
|
+
expect(res._json).toEqual({
|
|
296
|
+
errors: [{
|
|
297
|
+
message: 'Token expired',
|
|
298
|
+
extensions: { code: 'JWT_EXPIRED' }
|
|
299
|
+
}]
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should return 401 when jwt.decode returns null (corrupt token)', async () => {
|
|
304
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
305
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
306
|
+
mockJwtDecode.mockReturnValue(null);
|
|
307
|
+
|
|
308
|
+
const req = makeMockReq({
|
|
309
|
+
headers: { authorization: 'Bearer corrupt-token' },
|
|
310
|
+
});
|
|
311
|
+
const res = makeMockRes();
|
|
312
|
+
const next = vi.fn();
|
|
313
|
+
|
|
314
|
+
await middleware(req, res, next);
|
|
315
|
+
|
|
316
|
+
expect(next).not.toHaveBeenCalled();
|
|
317
|
+
expect(res._status).toBe(401);
|
|
318
|
+
expect(res._json).toEqual({ error: 'Authentication failed' });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should return 401 when verifyUserRecord returns null', async () => {
|
|
322
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
323
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
324
|
+
mockJwtDecode.mockReturnValue({
|
|
325
|
+
iss: 'https://test-issuer.com',
|
|
326
|
+
sub: 'user-1',
|
|
327
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
328
|
+
});
|
|
329
|
+
mockGetByIssuer.mockReturnValue({ name: 'test' });
|
|
330
|
+
mockGetValidationOptions.mockReturnValue({ audience: ['test-audience'] });
|
|
331
|
+
mockGetSigningKeys.mockReturnValue(
|
|
332
|
+
(_h: unknown, cb: (err: Error | null, key: string) => void) => cb(null, 'key')
|
|
333
|
+
);
|
|
334
|
+
mockJwtVerify.mockImplementation(
|
|
335
|
+
(_t: string, _k: unknown, _o: unknown, cb: (err: Error | null, d: unknown) => void) => {
|
|
336
|
+
cb(null, { iss: 'https://test-issuer.com' });
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
mockExtractUserInfo.mockReturnValue({ email: 'unknown@example.com' });
|
|
340
|
+
mockVerifyUserRecord.mockResolvedValue(null); // user not found
|
|
341
|
+
|
|
342
|
+
const req = makeMockReq({
|
|
343
|
+
headers: { authorization: 'Bearer valid-token' },
|
|
344
|
+
});
|
|
345
|
+
const res = makeMockRes();
|
|
346
|
+
const next = vi.fn();
|
|
347
|
+
|
|
348
|
+
await middleware(req, res, next);
|
|
349
|
+
|
|
350
|
+
expect(next).not.toHaveBeenCalled();
|
|
351
|
+
expect(res._status).toBe(401);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('should not call next() on auth failure', async () => {
|
|
355
|
+
mockGetReadOnlyDS.mockReturnValue(MOCK_POOL);
|
|
356
|
+
mockGetReadWriteDS.mockReturnValue(MOCK_POOL);
|
|
357
|
+
mockJwtDecode.mockReturnValue(null); // invalid token
|
|
358
|
+
|
|
359
|
+
const req = makeMockReq({
|
|
360
|
+
headers: { authorization: 'Bearer bad-token' },
|
|
361
|
+
});
|
|
362
|
+
const res = makeMockRes();
|
|
363
|
+
const next = vi.fn();
|
|
364
|
+
|
|
365
|
+
await middleware(req, res, next);
|
|
366
|
+
|
|
367
|
+
expect(next).not.toHaveBeenCalled();
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe('HTTP method handling', () => {
|
|
372
|
+
beforeEach(() => {
|
|
373
|
+
setupSuccessfulAuth();
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('should authenticate GET requests', async () => {
|
|
377
|
+
const req = makeMockReq({ method: 'GET', headers: { authorization: 'Bearer tok' } });
|
|
378
|
+
const res = makeMockRes();
|
|
379
|
+
const next = vi.fn();
|
|
380
|
+
|
|
381
|
+
await middleware(req, res, next);
|
|
382
|
+
|
|
383
|
+
expect(next).toHaveBeenCalled();
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('should authenticate POST requests', async () => {
|
|
387
|
+
const req = makeMockReq({ method: 'POST', headers: { authorization: 'Bearer tok' } });
|
|
388
|
+
const res = makeMockRes();
|
|
389
|
+
const next = vi.fn();
|
|
390
|
+
|
|
391
|
+
await middleware(req, res, next);
|
|
392
|
+
|
|
393
|
+
expect(next).toHaveBeenCalled();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should authenticate PUT requests', async () => {
|
|
397
|
+
const req = makeMockReq({ method: 'PUT', headers: { authorization: 'Bearer tok' } });
|
|
398
|
+
const res = makeMockRes();
|
|
399
|
+
const next = vi.fn();
|
|
400
|
+
|
|
401
|
+
await middleware(req, res, next);
|
|
402
|
+
|
|
403
|
+
expect(next).toHaveBeenCalled();
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('should authenticate DELETE requests', async () => {
|
|
407
|
+
const req = makeMockReq({ method: 'DELETE', headers: { authorization: 'Bearer tok' } });
|
|
408
|
+
const res = makeMockRes();
|
|
409
|
+
const next = vi.fn();
|
|
410
|
+
|
|
411
|
+
await middleware(req, res, next);
|
|
412
|
+
|
|
413
|
+
expect(next).toHaveBeenCalled();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ApolloServer, ApolloServerOptions } from '@apollo/server';
|
|
1
|
+
import { ApolloServer, ApolloServerOptions, ApolloServerPlugin } from '@apollo/server';
|
|
2
2
|
import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer';
|
|
3
3
|
import { Disposable } from 'graphql-ws';
|
|
4
4
|
import { Server } from 'http';
|
|
@@ -7,27 +7,43 @@ import { AppContext } from '../types.js';
|
|
|
7
7
|
import { Metadata } from '@memberjunction/core';
|
|
8
8
|
import { SQLServerDataProvider } from '@memberjunction/sqlserver-dataprovider';
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Creates and configures an Apollo Server instance with built-in plugins
|
|
12
|
+
* for HTTP drain and WebSocket cleanup.
|
|
13
|
+
*
|
|
14
|
+
* @param configOverride - Apollo Server options (typically contains the schema)
|
|
15
|
+
* @param servers - HTTP server and WebSocket cleanup disposable
|
|
16
|
+
* @param additionalPlugins - Optional additional plugins to merge with built-in plugins
|
|
17
|
+
*/
|
|
10
18
|
const buildApolloServer = (
|
|
11
19
|
configOverride: ApolloServerOptions<AppContext>,
|
|
12
|
-
{ httpServer, serverCleanup }: { httpServer: Server; serverCleanup: Disposable }
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
{ httpServer, serverCleanup }: { httpServer: Server; serverCleanup: Disposable },
|
|
21
|
+
additionalPlugins?: ApolloServerPlugin[]
|
|
22
|
+
) => {
|
|
23
|
+
const builtInPlugins: ApolloServerPlugin[] = [
|
|
24
|
+
ApolloServerPluginDrainHttpServer({ httpServer }),
|
|
25
|
+
{
|
|
26
|
+
async serverWillStart() {
|
|
27
|
+
return {
|
|
28
|
+
async drainServer() {
|
|
29
|
+
await serverCleanup.dispose();
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const allPlugins = additionalPlugins
|
|
37
|
+
? [...builtInPlugins, ...additionalPlugins]
|
|
38
|
+
: builtInPlugins;
|
|
39
|
+
|
|
40
|
+
return new ApolloServer({
|
|
15
41
|
csrfPrevention: true,
|
|
16
42
|
cache: 'bounded',
|
|
17
|
-
plugins:
|
|
18
|
-
ApolloServerPluginDrainHttpServer({ httpServer }),
|
|
19
|
-
{
|
|
20
|
-
async serverWillStart() {
|
|
21
|
-
return {
|
|
22
|
-
async drainServer() {
|
|
23
|
-
await serverCleanup.dispose();
|
|
24
|
-
},
|
|
25
|
-
};
|
|
26
|
-
},
|
|
27
|
-
}
|
|
28
|
-
],
|
|
43
|
+
plugins: allPlugins,
|
|
29
44
|
introspection: enableIntrospection,
|
|
30
45
|
...configOverride,
|
|
31
46
|
});
|
|
47
|
+
};
|
|
32
48
|
|
|
33
49
|
export default buildApolloServer;
|
package/src/config.ts
CHANGED
|
@@ -147,6 +147,29 @@ const queryDialectSchema = z.object({
|
|
|
147
147
|
targetPlatforms: z.array(z.string()).optional().default([]),
|
|
148
148
|
});
|
|
149
149
|
|
|
150
|
+
const multiTenancySchema = z.object({
|
|
151
|
+
/** Master switch — when false (default), no tenant isolation is applied */
|
|
152
|
+
enabled: zodBooleanWithTransforms().default(false),
|
|
153
|
+
/** How the tenant ID is determined for each request */
|
|
154
|
+
contextSource: z.enum(['header', 'linkedEntity', 'custom']).default('header'),
|
|
155
|
+
/** HTTP header name used when contextSource is 'header' */
|
|
156
|
+
tenantHeader: z.string().default('X-Tenant-ID'),
|
|
157
|
+
/** Whether scopedEntities is an allowlist or denylist of entities to filter */
|
|
158
|
+
scopingStrategy: z.enum(['allowlist', 'denylist']).default('denylist'),
|
|
159
|
+
/** Entities included/excluded from tenant filtering based on scopingStrategy */
|
|
160
|
+
scopedEntities: z.array(z.string()).default([]),
|
|
161
|
+
/** When true, entities in the __mj core schema are never tenant-filtered */
|
|
162
|
+
autoExcludeCoreEntities: zodBooleanWithTransforms().default(true),
|
|
163
|
+
/** Default column name containing the tenant identifier */
|
|
164
|
+
defaultTenantColumn: z.string().default('OrganizationID'),
|
|
165
|
+
/** Per-entity overrides for the tenant column name: { "EntityName": "ColumnName" } */
|
|
166
|
+
entityColumnMappings: z.record(z.string()).default({}),
|
|
167
|
+
/** Roles that bypass tenant filtering entirely */
|
|
168
|
+
adminRoles: z.array(z.string()).default(['Admin', 'System']),
|
|
169
|
+
/** Write protection mode: 'strict' rejects, 'log' warns, 'off' skips validation */
|
|
170
|
+
writeProtection: z.enum(['strict', 'log', 'off']).default('strict'),
|
|
171
|
+
});
|
|
172
|
+
|
|
150
173
|
const telemetrySchema = z.object({
|
|
151
174
|
enabled: zodBooleanWithTransforms().default(
|
|
152
175
|
process.env.MJ_TELEMETRY_ENABLED !== 'false' // Enabled by default unless explicitly disabled
|
|
@@ -166,6 +189,7 @@ const configInfoSchema = z.object({
|
|
|
166
189
|
scheduledJobs: scheduledJobsSchema.optional().default({}),
|
|
167
190
|
telemetry: telemetrySchema.optional().default({}),
|
|
168
191
|
queryDialects: queryDialectSchema.optional().default({}),
|
|
192
|
+
multiTenancy: multiTenancySchema.optional().default({}),
|
|
169
193
|
|
|
170
194
|
apiKey: z.string().optional(),
|
|
171
195
|
baseUrl: z.string().default('http://localhost'),
|
|
@@ -210,6 +234,7 @@ export type ComponentRegistryConfig = z.infer<typeof componentRegistrySchema>;
|
|
|
210
234
|
export type ScheduledJobsConfig = z.infer<typeof scheduledJobsSchema>;
|
|
211
235
|
export type TelemetryConfig = z.infer<typeof telemetrySchema>;
|
|
212
236
|
export type QueryDialectConfig = z.infer<typeof queryDialectSchema>;
|
|
237
|
+
export type MultiTenancyConfig = z.infer<typeof multiTenancySchema>;
|
|
213
238
|
export type ConfigInfo = z.infer<typeof configInfoSchema>;
|
|
214
239
|
|
|
215
240
|
/**
|