@memberjunction/server 6.1.0-edge.2 → 6.1.0-edge.3
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/LICENSE +180 -4
- package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
- package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
- package/dist/auth/AuthProviderCatalogRouter.js +69 -0
- package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
- package/dist/auth/AuthProviderEngine.d.ts +96 -0
- package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
- package/dist/auth/AuthProviderEngine.js +245 -0
- package/dist/auth/AuthProviderEngine.js.map +1 -0
- package/dist/auth/index.d.ts +11 -0
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +56 -13
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/initializeProviders.d.ts +64 -1
- package/dist/auth/initializeProviders.d.ts.map +1 -1
- package/dist/auth/initializeProviders.js +117 -5
- package/dist/auth/initializeProviders.js.map +1 -1
- package/dist/config.d.ts +89 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +52 -35
- package/dist/config.js.map +1 -1
- package/dist/generated/generated.d.ts +501 -1597
- package/dist/generated/generated.d.ts.map +1 -1
- package/dist/generated/generated.js +2711 -17021
- package/dist/generated/generated.js.map +1 -1
- package/dist/generic/ResolverBase.d.ts +1 -1
- package/dist/generic/ResolverBase.d.ts.map +1 -1
- package/dist/generic/ResolverBase.js +7 -4
- package/dist/generic/ResolverBase.js.map +1 -1
- package/dist/generic/RunViewResolver.d.ts +4 -0
- package/dist/generic/RunViewResolver.d.ts.map +1 -1
- package/dist/generic/RunViewResolver.js +31 -0
- package/dist/generic/RunViewResolver.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +99 -14
- package/dist/index.js.map +1 -1
- package/dist/integration/RSUProgressBridge.d.ts +83 -0
- package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
- package/dist/integration/RSUProgressBridge.js +230 -0
- package/dist/integration/RSUProgressBridge.js.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts +52 -0
- package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
- package/dist/integration/SchemaRefreshLaunch.js +50 -0
- package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
- package/dist/providerConfigUnits.d.ts +20 -0
- package/dist/providerConfigUnits.d.ts.map +1 -0
- package/dist/providerConfigUnits.js +22 -0
- package/dist/providerConfigUnits.js.map +1 -0
- package/dist/resolvers/FileResolver.d.ts +34 -4
- package/dist/resolvers/FileResolver.d.ts.map +1 -1
- package/dist/resolvers/FileResolver.js +218 -28
- package/dist/resolvers/FileResolver.js.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +76 -3
- package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
- package/dist/resolvers/IntegrationDiscoveryResolver.js +299 -81
- package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
- package/dist/resolvers/QueryResolver.d.ts +5 -4
- package/dist/resolvers/QueryResolver.d.ts.map +1 -1
- package/dist/resolvers/QueryResolver.js +29 -14
- package/dist/resolvers/QueryResolver.js.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
- package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
- package/dist/resolvers/QuerySystemUserResolver.js +33 -14
- package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
- package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
- package/dist/resolvers/RunAIAgentResolver.js +20 -7
- package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
- package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
- package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
- package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
- package/dist/resolvers/UserResolver.d.ts +19 -17
- package/dist/resolvers/UserResolver.d.ts.map +1 -1
- package/dist/resolvers/UserResolver.js +66 -67
- package/dist/resolvers/UserResolver.js.map +1 -1
- package/dist/resolvers/currentUserRoles.d.ts +19 -0
- package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
- package/dist/resolvers/currentUserRoles.js +36 -0
- package/dist/resolvers/currentUserRoles.js.map +1 -0
- package/dist/rest/MediaAccessKeys.d.ts +17 -0
- package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
- package/dist/rest/MediaAccessKeys.js +42 -1
- package/dist/rest/MediaAccessKeys.js.map +1 -1
- package/dist/rest/MediaStreamHandler.d.ts +3 -2
- package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
- package/dist/rest/MediaStreamHandler.js +83 -2
- package/dist/rest/MediaStreamHandler.js.map +1 -1
- package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
- package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
- package/dist/rest/OAuthCallbackHandler.js +129 -43
- package/dist/rest/OAuthCallbackHandler.js.map +1 -1
- package/dist/rest/UploadTokenManager.d.ts +90 -0
- package/dist/rest/UploadTokenManager.d.ts.map +1 -0
- package/dist/rest/UploadTokenManager.js +156 -0
- package/dist/rest/UploadTokenManager.js.map +1 -0
- package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
- package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
- package/dist/services/IntegrationSyncWorkerService.js +132 -0
- package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
- package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
- package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
- package/dist/services/StartTaskGraphDispatcher.js +21 -1
- package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
- package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
- package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
- package/dist/services/TaskGraphAgentRunner.js +23 -0
- package/dist/services/TaskGraphAgentRunner.js.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
- package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
- package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
- package/dist/types.d.ts +6 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +91 -91
- package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
- package/src/__tests__/AuthProviderEngine.test.ts +278 -0
- package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
- package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
- package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
- package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
- package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
- package/src/__tests__/RSUProgressBridge.test.ts +250 -0
- package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
- package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
- package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
- package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
- package/src/__tests__/config-units.test.ts +74 -0
- package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
- package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
- package/src/__tests__/newUsers.test.ts +726 -0
- package/src/__tests__/uploadTokenManager.test.ts +159 -0
- package/src/auth/AuthProviderCatalogRouter.ts +76 -0
- package/src/auth/AuthProviderEngine.ts +253 -0
- package/src/auth/index.ts +59 -15
- package/src/auth/initializeProviders.ts +128 -7
- package/src/config.ts +55 -36
- package/src/generated/generated.ts +1911 -10551
- package/src/generic/ResolverBase.ts +11 -4
- package/src/generic/RunViewResolver.ts +31 -0
- package/src/index.ts +108 -15
- package/src/integration/RSUProgressBridge.ts +248 -0
- package/src/integration/SchemaRefreshLaunch.ts +77 -0
- package/src/providerConfigUnits.ts +21 -0
- package/src/resolvers/FileResolver.ts +200 -40
- package/src/resolvers/IntegrationDiscoveryResolver.ts +316 -68
- package/src/resolvers/QueryResolver.ts +25 -11
- package/src/resolvers/QuerySystemUserResolver.ts +28 -13
- package/src/resolvers/RunAIAgentResolver.ts +21 -6
- package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
- package/src/resolvers/UserResolver.ts +99 -60
- package/src/resolvers/currentUserRoles.ts +47 -0
- package/src/rest/MediaAccessKeys.ts +47 -1
- package/src/rest/MediaStreamHandler.ts +98 -3
- package/src/rest/OAuthCallbackHandler.ts +149 -41
- package/src/rest/UploadTokenManager.ts +211 -0
- package/src/services/IntegrationSyncWorkerService.ts +141 -0
- package/src/services/StartTaskGraphDispatcher.ts +21 -1
- package/src/services/TaskGraphAgentRunner.ts +23 -0
- package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
- package/src/types.ts +6 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { UploadTokenManager } from '../rest/UploadTokenManager.js';
|
|
3
|
+
|
|
4
|
+
describe('UploadTokenManager', () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
UploadTokenManager.Instance.Clear();
|
|
7
|
+
UploadTokenManager.Instance.maxFileSizeBytes = 100 * 1024 * 1024;
|
|
8
|
+
UploadTokenManager.Instance.maxPoolMemoryBytes = 500 * 1024 * 1024;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('stages and consumes a buffer atomically', () => {
|
|
12
|
+
const buf = Buffer.from('hello world raw binary', 'utf-8');
|
|
13
|
+
const token = UploadTokenManager.Instance.Stage({
|
|
14
|
+
buffer: buf,
|
|
15
|
+
fileName: 'test.txt',
|
|
16
|
+
mimeType: 'text/plain',
|
|
17
|
+
userId: 'user-123',
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(token).toMatch(/^upt_[a-f0-9]{64}$/);
|
|
21
|
+
expect(UploadTokenManager.Instance.ActiveCount).toBe(1);
|
|
22
|
+
expect(UploadTokenManager.Instance.TotalMemoryBytes).toBe(buf.length);
|
|
23
|
+
|
|
24
|
+
// Consume with matching user
|
|
25
|
+
const consumed = UploadTokenManager.Instance.Consume(token, 'user-123');
|
|
26
|
+
expect(consumed).not.toBeNull();
|
|
27
|
+
expect(consumed?.buffer.toString('utf-8')).toBe('hello world raw binary');
|
|
28
|
+
expect(consumed?.fileName).toBe('test.txt');
|
|
29
|
+
expect(consumed?.mimeType).toBe('text/plain');
|
|
30
|
+
|
|
31
|
+
// Single-use: consuming again must return null
|
|
32
|
+
expect(UploadTokenManager.Instance.Consume(token, 'user-123')).toBeNull();
|
|
33
|
+
expect(UploadTokenManager.Instance.ActiveCount).toBe(0);
|
|
34
|
+
expect(UploadTokenManager.Instance.TotalMemoryBytes).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('rejects claim by a different user and evicts token', () => {
|
|
38
|
+
const buf = Buffer.from('secret payload', 'utf-8');
|
|
39
|
+
const token = UploadTokenManager.Instance.Stage({
|
|
40
|
+
buffer: buf,
|
|
41
|
+
fileName: 'secret.pdf',
|
|
42
|
+
mimeType: 'application/pdf',
|
|
43
|
+
userId: 'user-authorized',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Claim attempt by attacker
|
|
47
|
+
const result = UploadTokenManager.Instance.Consume(token, 'user-attacker');
|
|
48
|
+
expect(result).toBeNull();
|
|
49
|
+
|
|
50
|
+
// Token must be evicted after failed attempt
|
|
51
|
+
expect(UploadTokenManager.Instance.ActiveCount).toBe(0);
|
|
52
|
+
expect(UploadTokenManager.Instance.Consume(token, 'user-authorized')).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('enforces single file size limit', () => {
|
|
56
|
+
UploadTokenManager.Instance.maxFileSizeBytes = 10;
|
|
57
|
+
const buf = Buffer.alloc(20);
|
|
58
|
+
|
|
59
|
+
expect(() => {
|
|
60
|
+
UploadTokenManager.Instance.Stage({
|
|
61
|
+
buffer: buf,
|
|
62
|
+
fileName: 'huge.dat',
|
|
63
|
+
mimeType: 'application/octet-stream',
|
|
64
|
+
userId: 'user-123',
|
|
65
|
+
});
|
|
66
|
+
}).toThrow(/exceeds maximum allowed file size/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('enforces pool memory capacity limit', () => {
|
|
70
|
+
UploadTokenManager.Instance.maxPoolMemoryBytes = 50;
|
|
71
|
+
|
|
72
|
+
UploadTokenManager.Instance.Stage({
|
|
73
|
+
buffer: Buffer.alloc(30),
|
|
74
|
+
fileName: 'file1.dat',
|
|
75
|
+
mimeType: 'application/octet-stream',
|
|
76
|
+
userId: 'user-123',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(() => {
|
|
80
|
+
UploadTokenManager.Instance.Stage({
|
|
81
|
+
buffer: Buffer.alloc(30),
|
|
82
|
+
fileName: 'file2.dat',
|
|
83
|
+
mimeType: 'application/octet-stream',
|
|
84
|
+
userId: 'user-123',
|
|
85
|
+
});
|
|
86
|
+
}).toThrow(/memory capacity reached/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('auto-evicts token after TTL expiry', async () => {
|
|
90
|
+
vi.useFakeTimers();
|
|
91
|
+
try {
|
|
92
|
+
const buf = Buffer.from('ephemeral data');
|
|
93
|
+
const token = UploadTokenManager.Instance.Stage({
|
|
94
|
+
buffer: buf,
|
|
95
|
+
fileName: 'temp.txt',
|
|
96
|
+
mimeType: 'text/plain',
|
|
97
|
+
userId: 'user-123',
|
|
98
|
+
ttlSeconds: 5,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
expect(UploadTokenManager.Instance.ActiveCount).toBe(1);
|
|
102
|
+
|
|
103
|
+
// Fast-forward 6 seconds
|
|
104
|
+
vi.advanceTimersByTime(6000);
|
|
105
|
+
|
|
106
|
+
expect(UploadTokenManager.Instance.ActiveCount).toBe(0);
|
|
107
|
+
expect(UploadTokenManager.Instance.Consume(token, 'user-123')).toBeNull();
|
|
108
|
+
} finally {
|
|
109
|
+
vi.useRealTimers();
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('normalizes UUIDs when verifying claiming user', () => {
|
|
114
|
+
const buf = Buffer.from('uuid test data');
|
|
115
|
+
const token = UploadTokenManager.Instance.Stage({
|
|
116
|
+
buffer: buf,
|
|
117
|
+
fileName: 'uuid.txt',
|
|
118
|
+
mimeType: 'text/plain',
|
|
119
|
+
userId: '4A68B0D7-49EE-4D56-82DE-92F66627EE2B',
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// Claim with lowercase UUID
|
|
123
|
+
const consumed = UploadTokenManager.Instance.Consume(token, '4a68b0d7-49ee-4d56-82de-92f66627ee2b');
|
|
124
|
+
expect(consumed).not.toBeNull();
|
|
125
|
+
expect(consumed?.buffer.toString('utf-8')).toBe('uuid test data');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('enforces per-user memory quota independently of pool memory', () => {
|
|
129
|
+
UploadTokenManager.Instance.maxPoolMemoryBytes = 500;
|
|
130
|
+
UploadTokenManager.Instance.maxUserMemoryBytes = 40;
|
|
131
|
+
|
|
132
|
+
// User A stages 30 bytes (OK)
|
|
133
|
+
UploadTokenManager.Instance.Stage({
|
|
134
|
+
buffer: Buffer.alloc(30),
|
|
135
|
+
fileName: 'userA_1.dat',
|
|
136
|
+
mimeType: 'application/octet-stream',
|
|
137
|
+
userId: 'user-A',
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// User A tries to stage another 20 bytes (exceeds 40 byte user quota)
|
|
141
|
+
expect(() => {
|
|
142
|
+
UploadTokenManager.Instance.Stage({
|
|
143
|
+
buffer: Buffer.alloc(20),
|
|
144
|
+
fileName: 'userA_2.dat',
|
|
145
|
+
mimeType: 'application/octet-stream',
|
|
146
|
+
userId: 'user-A',
|
|
147
|
+
});
|
|
148
|
+
}).toThrow(/Per-user upload memory quota reached/);
|
|
149
|
+
|
|
150
|
+
// User B can still stage 30 bytes because User B has their own quota
|
|
151
|
+
const tokenB = UploadTokenManager.Instance.Stage({
|
|
152
|
+
buffer: Buffer.alloc(30),
|
|
153
|
+
fileName: 'userB_1.dat',
|
|
154
|
+
mimeType: 'application/octet-stream',
|
|
155
|
+
userId: 'user-B',
|
|
156
|
+
});
|
|
157
|
+
expect(tokenB).toBeDefined();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Unauthenticated provider-catalog endpoint.
|
|
3
|
+
*
|
|
4
|
+
* The browser must know which identity providers exist BEFORE it can authenticate, so this
|
|
5
|
+
* router is mounted ahead of the unified auth middleware — the same pattern the magic-link JWKS
|
|
6
|
+
* and widget-session routes already use.
|
|
7
|
+
*
|
|
8
|
+
* @module @memberjunction/server/auth
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Router, type Request, type Response } from 'express';
|
|
12
|
+
import { rateLimit } from 'express-rate-limit';
|
|
13
|
+
import { LogError, type PublicAuthProviderCatalog } from '@memberjunction/core';
|
|
14
|
+
import { AuthProviderEngine } from './AuthProviderEngine.js';
|
|
15
|
+
|
|
16
|
+
/** Mount path for the public catalog router. */
|
|
17
|
+
export const AUTH_CATALOG_MOUNT_PATH = '/auth';
|
|
18
|
+
|
|
19
|
+
/** Requests per window per IP. Generous — a browser makes one call per page load. */
|
|
20
|
+
const DEFAULT_RATE_LIMIT_MAX = 60;
|
|
21
|
+
|
|
22
|
+
/** Rate-limit window. */
|
|
23
|
+
const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How long a browser may cache the catalog.
|
|
27
|
+
*
|
|
28
|
+
* Short by design: this drives the login screen, so an admin who enables a provider expects to
|
|
29
|
+
* see it without telling users to hard-refresh. A minute keeps the endpoint cheap without making
|
|
30
|
+
* configuration changes feel stuck.
|
|
31
|
+
*/
|
|
32
|
+
const CACHE_MAX_AGE_SECONDS = 60;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Builds the unauthenticated `GET /auth/providers` router.
|
|
36
|
+
*
|
|
37
|
+
* **What it exposes.** Only `Active` + `ClientVisible` rows, projected to the public allow-list
|
|
38
|
+
* by {@link AuthProviderEngine.GetPublicCatalog}. Secret material is structurally unable to reach
|
|
39
|
+
* this response: `CredentialID` and `AdditionalConfiguration` are not part of the public shape,
|
|
40
|
+
* and non-primitive `ClientConfiguration` values are dropped rather than serialized.
|
|
41
|
+
*
|
|
42
|
+
* **Why publishing this is safe.** Every value here was already shipped to anonymous users — a
|
|
43
|
+
* single-provider SPA compiled its client ID, domain and issuer straight into its JavaScript
|
|
44
|
+
* bundle. This endpoint moves that same data behind one source of truth; it does not widen what
|
|
45
|
+
* an anonymous caller can learn.
|
|
46
|
+
*
|
|
47
|
+
* **Failure is empty, not an error.** If the catalog cannot be read, the endpoint returns an empty
|
|
48
|
+
* list so the browser falls back to its compiled `AUTH_TYPE` and the user can still sign in.
|
|
49
|
+
* Returning 500 would take the login screen down for a problem the client can route around.
|
|
50
|
+
*/
|
|
51
|
+
export function createAuthProviderCatalogRouter(): Router {
|
|
52
|
+
const router = Router();
|
|
53
|
+
|
|
54
|
+
const limiter = rateLimit({
|
|
55
|
+
windowMs: DEFAULT_RATE_LIMIT_WINDOW_MS,
|
|
56
|
+
limit: DEFAULT_RATE_LIMIT_MAX,
|
|
57
|
+
standardHeaders: 'draft-7',
|
|
58
|
+
legacyHeaders: false,
|
|
59
|
+
message: { providers: [] }
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
router.get('/providers', limiter, (_req: Request, res: Response) => {
|
|
63
|
+
try {
|
|
64
|
+
const body: PublicAuthProviderCatalog = { providers: AuthProviderEngine.Instance.GetPublicCatalog() };
|
|
65
|
+
res.set('Cache-Control', `public, max-age=${CACHE_MAX_AGE_SECONDS}`);
|
|
66
|
+
res.status(200).json(body);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
69
|
+
LogError(`[Auth] Failed to serve the public provider catalog: ${message}`);
|
|
70
|
+
const empty: PublicAuthProviderCatalog = { providers: [] };
|
|
71
|
+
res.status(200).json(empty);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return router;
|
|
76
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Metadata-driven authentication provider catalog.
|
|
3
|
+
*
|
|
4
|
+
* Loads `MJ: Authentication Providers` rows and registers each one with the runtime
|
|
5
|
+
* `AuthProviderFactory`, resolving the concrete driver through `ClassFactory` by `DriverClass`.
|
|
6
|
+
* This is the auth subsystem's equivalent of the File Storage `ServerDriverKey` pattern:
|
|
7
|
+
* adding a provider is a subclass plus a row, never an edit to core code.
|
|
8
|
+
*
|
|
9
|
+
* @module @memberjunction/server/auth
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
BaseEngine,
|
|
14
|
+
BaseEnginePropertyConfig,
|
|
15
|
+
LogError,
|
|
16
|
+
LogStatusEx,
|
|
17
|
+
RegisterForStartup,
|
|
18
|
+
UserInfo,
|
|
19
|
+
type AuthProviderConfig,
|
|
20
|
+
type IMetadataProvider,
|
|
21
|
+
type PublicAuthProviderInfo
|
|
22
|
+
} from '@memberjunction/core';
|
|
23
|
+
import { MJAuthenticationProviderEntity } from '@memberjunction/core-entities';
|
|
24
|
+
import { AuthProviderFactory } from '@memberjunction/auth-providers';
|
|
25
|
+
import { CredentialEngine } from '@memberjunction/credentials';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Subsystem label used when this engine asks CredentialEngine to decrypt secret material,
|
|
29
|
+
* so credential-access auditing attributes the read to authentication.
|
|
30
|
+
*/
|
|
31
|
+
const CREDENTIAL_SUBSYSTEM = 'Authentication';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Loads the authentication-provider catalog from metadata and registers it with the
|
|
35
|
+
* {@link AuthProviderFactory}.
|
|
36
|
+
*
|
|
37
|
+
* **Not `deferred`**: no authenticated request may be served before the catalog is registered,
|
|
38
|
+
* or tokens from a metadata-defined provider would be rejected during the startup window.
|
|
39
|
+
*
|
|
40
|
+
* **Severity is `warn`, not `fatal`**, and that is deliberate. This engine is one of two possible
|
|
41
|
+
* sources of providers — `initializeAuthProviders` also registers whatever `mj.config.cjs`
|
|
42
|
+
* declares. Making a catalog load failure fatal would turn a transient metadata problem into a
|
|
43
|
+
* total authentication outage for deployments that never adopted the table. The failure is
|
|
44
|
+
* logged loudly and the config-declared providers still stand.
|
|
45
|
+
*/
|
|
46
|
+
@RegisterForStartup({
|
|
47
|
+
priority: 20,
|
|
48
|
+
severity: 'warn',
|
|
49
|
+
deferred: false,
|
|
50
|
+
description: 'Loads the metadata authentication-provider catalog and registers it with AuthProviderFactory'
|
|
51
|
+
})
|
|
52
|
+
export class AuthProviderEngine extends BaseEngine<AuthProviderEngine> {
|
|
53
|
+
private _providers: MJAuthenticationProviderEntity[] = [];
|
|
54
|
+
|
|
55
|
+
public static get Instance(): AuthProviderEngine {
|
|
56
|
+
return super.getInstance<AuthProviderEngine>();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Loads Active providers only. Inactive rows are excluded at the query rather than filtered
|
|
61
|
+
* later, so a disabled provider is never even instantiated.
|
|
62
|
+
*/
|
|
63
|
+
public async Config(forceRefresh?: boolean, contextUser?: UserInfo, provider?: IMetadataProvider): Promise<void> {
|
|
64
|
+
const configs: Partial<BaseEnginePropertyConfig>[] = [
|
|
65
|
+
{
|
|
66
|
+
PropertyName: '_providers',
|
|
67
|
+
EntityName: 'MJ: Authentication Providers',
|
|
68
|
+
Filter: `Status='Active'`,
|
|
69
|
+
OrderBy: 'Sequence ASC, Name ASC',
|
|
70
|
+
CacheLocal: true
|
|
71
|
+
}
|
|
72
|
+
];
|
|
73
|
+
await this.Load(configs, provider, forceRefresh, contextUser);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Active providers from metadata, ordered for presentation. */
|
|
77
|
+
public get Providers(): MJAuthenticationProviderEntity[] {
|
|
78
|
+
return this.GetConfigData<MJAuthenticationProviderEntity>('_providers');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Startup hook: load the catalog, then register every row with the factory.
|
|
83
|
+
*/
|
|
84
|
+
public async HandleStartup(contextUser?: UserInfo, provider?: IMetadataProvider): Promise<void> {
|
|
85
|
+
await this.Config(false, contextUser, provider);
|
|
86
|
+
await this.RegisterAll(contextUser);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Instantiates and registers every catalog row with the {@link AuthProviderFactory}.
|
|
91
|
+
*
|
|
92
|
+
* One bad row must not cost the deployment its other providers, so each registration is
|
|
93
|
+
* isolated: a failure is logged with the offending provider's name and the loop continues.
|
|
94
|
+
*
|
|
95
|
+
* @returns the number of providers successfully registered.
|
|
96
|
+
*/
|
|
97
|
+
public async RegisterAll(contextUser?: UserInfo): Promise<number> {
|
|
98
|
+
const factory = AuthProviderFactory.Instance;
|
|
99
|
+
let registered = 0;
|
|
100
|
+
|
|
101
|
+
for (const row of this.Providers) {
|
|
102
|
+
try {
|
|
103
|
+
const config = await this.buildProviderConfig(row, contextUser);
|
|
104
|
+
factory.register(AuthProviderFactory.createProvider(config));
|
|
105
|
+
registered++;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
108
|
+
LogError(`[Auth] Could not register metadata provider '${row.Name}' (DriverClass '${row.DriverClass}'): ${message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (registered > 0) {
|
|
113
|
+
LogStatusEx({ message: `[Auth] Registered ${registered} provider(s) from metadata.`, verboseOnly: true });
|
|
114
|
+
}
|
|
115
|
+
return registered;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Projects a catalog row onto the runtime {@link AuthProviderConfig} the driver expects.
|
|
120
|
+
*
|
|
121
|
+
* `AdditionalConfiguration` is spread FIRST so that the modelled columns win over anything a
|
|
122
|
+
* driver-specific blob happens to repeat — the columns are the reviewable, described surface,
|
|
123
|
+
* and a JSON blob must not be able to silently redefine the issuer a token is validated against.
|
|
124
|
+
*/
|
|
125
|
+
private async buildProviderConfig(row: MJAuthenticationProviderEntity, contextUser?: UserInfo): Promise<AuthProviderConfig> {
|
|
126
|
+
const additional = this.parseJsonColumn(row.AdditionalConfiguration, row.Name, 'AdditionalConfiguration');
|
|
127
|
+
const secrets = await this.resolveCredential(row, contextUser);
|
|
128
|
+
|
|
129
|
+
const config: AuthProviderConfig = {
|
|
130
|
+
...additional,
|
|
131
|
+
...secrets,
|
|
132
|
+
name: row.Name,
|
|
133
|
+
type: row.DriverClass
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (row.ClientID) config.clientId = row.ClientID;
|
|
137
|
+
if (row.Domain) config.domain = row.Domain;
|
|
138
|
+
if (row.Issuer) config.issuer = row.Issuer;
|
|
139
|
+
if (row.Audience) config.audience = row.Audience;
|
|
140
|
+
if (row.JWKSUri) config.jwksUri = row.JWKSUri;
|
|
141
|
+
if (row.Scopes) config.scopes = this.parseScopes(row.Scopes);
|
|
142
|
+
|
|
143
|
+
return config;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Splits the delimited `Scopes` column into the parsed list both projections publish.
|
|
148
|
+
*
|
|
149
|
+
* This is the single home of the delimiter convention: `buildProviderConfig` and
|
|
150
|
+
* `GetPublicCatalog` must agree on it, or the server would validate tokens against one scope
|
|
151
|
+
* set while telling the browser to request another.
|
|
152
|
+
*/
|
|
153
|
+
private parseScopes(raw: string | null): string[] {
|
|
154
|
+
return raw ? raw.split(/[\s,]+/).filter((s) => s.length > 0) : [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Decrypts the row's linked credential, when it has one.
|
|
159
|
+
*
|
|
160
|
+
* Almost every provider validates tokens against a public JWKS and needs no secret at all, so
|
|
161
|
+
* this is the uncommon path — present for confidential-client flows, management APIs, and SCIM.
|
|
162
|
+
*/
|
|
163
|
+
private async resolveCredential(row: MJAuthenticationProviderEntity, contextUser?: UserInfo): Promise<Record<string, string>> {
|
|
164
|
+
if (!row.CredentialID || !row.Credential) {
|
|
165
|
+
return {};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await CredentialEngine.Instance.Config(false, contextUser);
|
|
169
|
+
const resolved = await CredentialEngine.Instance.getCredential(row.Credential, {
|
|
170
|
+
contextUser,
|
|
171
|
+
subsystem: CREDENTIAL_SUBSYSTEM
|
|
172
|
+
});
|
|
173
|
+
return resolved.values;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Parses a JSON configuration column, treating malformed content as absent.
|
|
178
|
+
*
|
|
179
|
+
* Throwing here would take down an otherwise-valid provider over a stray character in an
|
|
180
|
+
* optional extras blob, so the row is registered without the extras and the problem is logged.
|
|
181
|
+
*/
|
|
182
|
+
private parseJsonColumn(raw: string | null, providerName: string, columnName: string): Record<string, unknown> {
|
|
183
|
+
if (!raw?.trim()) {
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const parsed: unknown = JSON.parse(raw);
|
|
188
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
189
|
+
return parsed as Record<string, unknown>;
|
|
190
|
+
}
|
|
191
|
+
LogError(`[Auth] ${columnName} on provider '${providerName}' is not a JSON object; ignoring it.`);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
194
|
+
LogError(`[Auth] ${columnName} on provider '${providerName}' is not valid JSON (${message}); ignoring it.`);
|
|
195
|
+
}
|
|
196
|
+
return {};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Projects the catalog to the PUBLIC shape served by the unauthenticated catalog endpoint.
|
|
201
|
+
*
|
|
202
|
+
* The projection is an explicit allow-list rather than a redaction of the full row: a column
|
|
203
|
+
* added to this entity later is invisible to anonymous callers until someone deliberately adds
|
|
204
|
+
* it here. `AdditionalConfiguration` and `CredentialID` are structurally absent, never merely
|
|
205
|
+
* omitted at serialization time.
|
|
206
|
+
*/
|
|
207
|
+
public GetPublicCatalog(): PublicAuthProviderInfo[] {
|
|
208
|
+
return this.Providers.filter((row) => row.ClientVisible).map((row) => {
|
|
209
|
+
const info: PublicAuthProviderInfo = {
|
|
210
|
+
name: row.Name,
|
|
211
|
+
driverClass: row.DriverClass,
|
|
212
|
+
displayName: row.DisplayName ?? row.Name,
|
|
213
|
+
sequence: row.Sequence,
|
|
214
|
+
isDefault: row.IsDefault
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
if (row.Icon) info.icon = row.Icon;
|
|
218
|
+
if (row.ClientID) info.clientId = row.ClientID;
|
|
219
|
+
if (row.Issuer) info.issuer = row.Issuer;
|
|
220
|
+
if (row.Domain) info.domain = row.Domain;
|
|
221
|
+
|
|
222
|
+
// Published pre-parsed so the browser never re-derives the delimiter convention —
|
|
223
|
+
// the drivers hand this straight to SDK config typed string[].
|
|
224
|
+
const scopes = this.parseScopes(row.Scopes);
|
|
225
|
+
if (scopes.length > 0) info.scopes = scopes;
|
|
226
|
+
|
|
227
|
+
const clientConfig = this.parseJsonColumn(row.ClientConfiguration, row.Name, 'ClientConfiguration');
|
|
228
|
+
if (Object.keys(clientConfig).length > 0) {
|
|
229
|
+
info.clientConfiguration = this.toPublicClientConfig(clientConfig, row.Name);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return info;
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Flattens a parsed `ClientConfiguration` blob to the primitive key/value shape the browser
|
|
238
|
+
* contract declares. Nested objects and arrays are dropped with a warning rather than passed
|
|
239
|
+
* through, so a structured blob (the shape a credential would take) cannot ride along to an
|
|
240
|
+
* anonymous caller.
|
|
241
|
+
*/
|
|
242
|
+
private toPublicClientConfig(parsed: Record<string, unknown>, providerName: string): Record<string, string | number | boolean | null> {
|
|
243
|
+
const result: Record<string, string | number | boolean | null> = {};
|
|
244
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
245
|
+
if (value === null || ['string', 'number', 'boolean'].includes(typeof value)) {
|
|
246
|
+
result[key] = value as string | number | boolean | null;
|
|
247
|
+
} else {
|
|
248
|
+
LogError(`[Auth] ClientConfiguration key '${key}' on provider '${providerName}' is not a primitive value; it was NOT published to the public catalog.`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return result;
|
|
252
|
+
}
|
|
253
|
+
}
|
package/src/auth/index.ts
CHANGED
|
@@ -11,6 +11,9 @@ import { initializeAuthProviders } from './initializeProviders.js';
|
|
|
11
11
|
|
|
12
12
|
export * from './APIKeyScopeAuth.js';
|
|
13
13
|
export * from './actingContextResolver.js';
|
|
14
|
+
export * from './AuthProviderEngine.js';
|
|
15
|
+
export * from './AuthProviderCatalogRouter.js';
|
|
16
|
+
export { initializeAuthProviders, initializeAuthProvidersFromMetadata, refreshAuthProviders, validateAuthProvidersRegistered } from './initializeProviders.js';
|
|
14
17
|
|
|
15
18
|
// This is a hard-coded forever constant due to internal migrations
|
|
16
19
|
|
|
@@ -191,6 +194,42 @@ export const getSystemUser = async (dataSource?: sql.ConnectionPool, attemptCach
|
|
|
191
194
|
return systemUser;
|
|
192
195
|
};
|
|
193
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Extracts the lowercased domain portion of an email address.
|
|
199
|
+
*
|
|
200
|
+
* @returns the domain, or an empty string when the value is not an email address (e.g. an IdP that
|
|
201
|
+
* issues a bare username). Callers MUST treat an empty result as "cannot be authorized"
|
|
202
|
+
* rather than as a wildcard.
|
|
203
|
+
*/
|
|
204
|
+
const extractEmailDomain = (email: string): string => {
|
|
205
|
+
const parts = email.split('@');
|
|
206
|
+
// Reject anything that isn't exactly local@domain — a value with 0 or 2+ '@' is not an address we
|
|
207
|
+
// can make a trust decision about.
|
|
208
|
+
if (parts.length !== 2) return '';
|
|
209
|
+
return parts[1].toLowerCase().trim();
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Tests a domain against `userHandling.newUserAuthorizedDomains`, honoring `*` wildcards.
|
|
214
|
+
*
|
|
215
|
+
* Note that a pattern is matched in full, so `*.example.com` matches `mail.example.com` but NOT
|
|
216
|
+
* `example.com` — list both if you need both.
|
|
217
|
+
*/
|
|
218
|
+
const isDomainAuthorized = (domain: string): boolean =>
|
|
219
|
+
configInfo.userHandling.newUserAuthorizedDomains.some((pattern) => {
|
|
220
|
+
// Convert wildcard domain patterns to regular expressions
|
|
221
|
+
const regex = new RegExp('^' + pattern.toLowerCase().trim().replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
|
222
|
+
return regex.test(domain);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Resolves a verified identity to an MJ `UserInfo`, optionally auto-provisioning a new user.
|
|
227
|
+
*
|
|
228
|
+
* @param requestDomain the hostname parsed from the request's `Origin` header. **Not used for any
|
|
229
|
+
* authorization decision** — it is spoofable on non-browser requests, and new-user domain
|
|
230
|
+
* authorization runs against the verified JWT's email domain instead. Retained for audit
|
|
231
|
+
* logging and for the recursive retry call.
|
|
232
|
+
*/
|
|
194
233
|
export const verifyUserRecord = async (
|
|
195
234
|
email?: string,
|
|
196
235
|
firstName?: string,
|
|
@@ -211,23 +250,21 @@ export const verifyUserRecord = async (
|
|
|
211
250
|
});
|
|
212
251
|
|
|
213
252
|
if (!user) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
//
|
|
253
|
+
// NOTE: `requestDomain` (parsed from the spoofable `Origin` header) is deliberately NOT part of
|
|
254
|
+
// this condition. It was previously required here, which meant a non-browser client sending no
|
|
255
|
+
// Origin could never auto-provision while an attacker simply forged one — it gated entry without
|
|
256
|
+
// authorizing anything. Authorization happens below, against the verified identity's email domain.
|
|
257
|
+
if (configInfo.userHandling.autoCreateNewUsers && firstName && lastName) {
|
|
258
|
+
// SECURITY: authorize against the EMAIL DOMAIN of the cryptographically-verified identity,
|
|
259
|
+
// NOT the request `Origin` header. Origin is trivially spoofable on non-browser / bearer-token
|
|
260
|
+
// requests, so gating on it let a holder of any valid IdP token auto-provision an account under
|
|
261
|
+
// an authorized domain by sending a forged Origin. The email comes from the verified JWT.
|
|
262
|
+
const emailDomain: string = extractEmailDomain(email);
|
|
221
263
|
let passesDomainCheck: boolean =
|
|
222
264
|
configInfo.userHandling.newUserLimitedToAuthorizedDomains ===
|
|
223
265
|
false; /*in this first condition, we are set up to NOT care about domain */
|
|
224
|
-
if (!passesDomainCheck
|
|
225
|
-
|
|
226
|
-
passesDomainCheck = configInfo.userHandling.newUserAuthorizedDomains.some((pattern) => {
|
|
227
|
-
// Convert wildcard domain patterns to regular expressions
|
|
228
|
-
const regex = new RegExp('^' + pattern.toLowerCase().trim().replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
|
|
229
|
-
return regex.test(requestDomain?.toLowerCase().trim());
|
|
230
|
-
});
|
|
266
|
+
if (!passesDomainCheck) {
|
|
267
|
+
passesDomainCheck = emailDomain.length > 0 && isDomainAuthorized(emailDomain);
|
|
231
268
|
}
|
|
232
269
|
|
|
233
270
|
if (passesDomainCheck) {
|
|
@@ -253,9 +290,16 @@ export const verifyUserRecord = async (
|
|
|
253
290
|
UserCache.Instance.Users.push(user);
|
|
254
291
|
console.warn(` >>> New user ${email} created successfully!`);
|
|
255
292
|
}
|
|
293
|
+
} else if (emailDomain.length === 0) {
|
|
294
|
+
// The verified identity carries no email domain at all — typically an IdP that issues a bare
|
|
295
|
+
// `preferred_username` with no `email` claim. There is nothing to match against, so the gate
|
|
296
|
+
// denies rather than falling back to anything spoofable.
|
|
297
|
+
console.warn(
|
|
298
|
+
`User ${email} not found in cache and will NOT be auto-created: the verified identity has no email domain (no '@'), so it cannot be matched against newUserAuthorizedDomains. This usually means the identity provider issues a username rather than an email address — configure it to emit an 'email' claim, or set newUserLimitedToAuthorizedDomains to false to disable domain checking.`
|
|
299
|
+
);
|
|
256
300
|
} else {
|
|
257
301
|
console.warn(
|
|
258
|
-
`User ${email} not found in cache.
|
|
302
|
+
`User ${email} not found in cache. Email domain '${emailDomain}' does not match any of the domains in the newUserAuthorizedDomains setting. NOTE: this check is against the EMAIL DOMAIN of the verified identity, NOT the browser Origin — if newUserAuthorizedDomains lists frontend hostnames (e.g. 'app.example.com'), replace them with email domains (e.g. 'example.com'). To ignore domain, make sure you set the newUserLimitedToAuthorizedDomains setting to false. In this case we are NOT creating a new user.`
|
|
259
303
|
);
|
|
260
304
|
}
|
|
261
305
|
}
|