@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.
Files changed (162) hide show
  1. package/LICENSE +180 -4
  2. package/dist/auth/AuthProviderCatalogRouter.d.ts +31 -0
  3. package/dist/auth/AuthProviderCatalogRouter.d.ts.map +1 -0
  4. package/dist/auth/AuthProviderCatalogRouter.js +69 -0
  5. package/dist/auth/AuthProviderCatalogRouter.js.map +1 -0
  6. package/dist/auth/AuthProviderEngine.d.ts +96 -0
  7. package/dist/auth/AuthProviderEngine.d.ts.map +1 -0
  8. package/dist/auth/AuthProviderEngine.js +245 -0
  9. package/dist/auth/AuthProviderEngine.js.map +1 -0
  10. package/dist/auth/index.d.ts +11 -0
  11. package/dist/auth/index.d.ts.map +1 -1
  12. package/dist/auth/index.js +56 -13
  13. package/dist/auth/index.js.map +1 -1
  14. package/dist/auth/initializeProviders.d.ts +64 -1
  15. package/dist/auth/initializeProviders.d.ts.map +1 -1
  16. package/dist/auth/initializeProviders.js +117 -5
  17. package/dist/auth/initializeProviders.js.map +1 -1
  18. package/dist/config.d.ts +89 -0
  19. package/dist/config.d.ts.map +1 -1
  20. package/dist/config.js +52 -35
  21. package/dist/config.js.map +1 -1
  22. package/dist/generated/generated.d.ts +501 -1597
  23. package/dist/generated/generated.d.ts.map +1 -1
  24. package/dist/generated/generated.js +2711 -17021
  25. package/dist/generated/generated.js.map +1 -1
  26. package/dist/generic/ResolverBase.d.ts +1 -1
  27. package/dist/generic/ResolverBase.d.ts.map +1 -1
  28. package/dist/generic/ResolverBase.js +7 -4
  29. package/dist/generic/ResolverBase.js.map +1 -1
  30. package/dist/generic/RunViewResolver.d.ts +4 -0
  31. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  32. package/dist/generic/RunViewResolver.js +31 -0
  33. package/dist/generic/RunViewResolver.js.map +1 -1
  34. package/dist/index.d.ts +2 -0
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +99 -14
  37. package/dist/index.js.map +1 -1
  38. package/dist/integration/RSUProgressBridge.d.ts +83 -0
  39. package/dist/integration/RSUProgressBridge.d.ts.map +1 -0
  40. package/dist/integration/RSUProgressBridge.js +230 -0
  41. package/dist/integration/RSUProgressBridge.js.map +1 -0
  42. package/dist/integration/SchemaRefreshLaunch.d.ts +52 -0
  43. package/dist/integration/SchemaRefreshLaunch.d.ts.map +1 -0
  44. package/dist/integration/SchemaRefreshLaunch.js +50 -0
  45. package/dist/integration/SchemaRefreshLaunch.js.map +1 -0
  46. package/dist/providerConfigUnits.d.ts +20 -0
  47. package/dist/providerConfigUnits.d.ts.map +1 -0
  48. package/dist/providerConfigUnits.js +22 -0
  49. package/dist/providerConfigUnits.js.map +1 -0
  50. package/dist/resolvers/FileResolver.d.ts +34 -4
  51. package/dist/resolvers/FileResolver.d.ts.map +1 -1
  52. package/dist/resolvers/FileResolver.js +218 -28
  53. package/dist/resolvers/FileResolver.js.map +1 -1
  54. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +76 -3
  55. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  56. package/dist/resolvers/IntegrationDiscoveryResolver.js +299 -81
  57. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  58. package/dist/resolvers/QueryResolver.d.ts +5 -4
  59. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  60. package/dist/resolvers/QueryResolver.js +29 -14
  61. package/dist/resolvers/QueryResolver.js.map +1 -1
  62. package/dist/resolvers/QuerySystemUserResolver.d.ts +12 -3
  63. package/dist/resolvers/QuerySystemUserResolver.d.ts.map +1 -1
  64. package/dist/resolvers/QuerySystemUserResolver.js +33 -14
  65. package/dist/resolvers/QuerySystemUserResolver.js.map +1 -1
  66. package/dist/resolvers/RunAIAgentResolver.d.ts +3 -1
  67. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  68. package/dist/resolvers/RunAIAgentResolver.js +20 -7
  69. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  70. package/dist/resolvers/TaskGraphFrameResolver.d.ts +18 -0
  71. package/dist/resolvers/TaskGraphFrameResolver.d.ts.map +1 -1
  72. package/dist/resolvers/TaskGraphFrameResolver.js +75 -0
  73. package/dist/resolvers/TaskGraphFrameResolver.js.map +1 -1
  74. package/dist/resolvers/UserResolver.d.ts +19 -17
  75. package/dist/resolvers/UserResolver.d.ts.map +1 -1
  76. package/dist/resolvers/UserResolver.js +66 -67
  77. package/dist/resolvers/UserResolver.js.map +1 -1
  78. package/dist/resolvers/currentUserRoles.d.ts +19 -0
  79. package/dist/resolvers/currentUserRoles.d.ts.map +1 -0
  80. package/dist/resolvers/currentUserRoles.js +36 -0
  81. package/dist/resolvers/currentUserRoles.js.map +1 -0
  82. package/dist/rest/MediaAccessKeys.d.ts +17 -0
  83. package/dist/rest/MediaAccessKeys.d.ts.map +1 -1
  84. package/dist/rest/MediaAccessKeys.js +42 -1
  85. package/dist/rest/MediaAccessKeys.js.map +1 -1
  86. package/dist/rest/MediaStreamHandler.d.ts +3 -2
  87. package/dist/rest/MediaStreamHandler.d.ts.map +1 -1
  88. package/dist/rest/MediaStreamHandler.js +83 -2
  89. package/dist/rest/MediaStreamHandler.js.map +1 -1
  90. package/dist/rest/OAuthCallbackHandler.d.ts +49 -1
  91. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  92. package/dist/rest/OAuthCallbackHandler.js +129 -43
  93. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  94. package/dist/rest/UploadTokenManager.d.ts +90 -0
  95. package/dist/rest/UploadTokenManager.d.ts.map +1 -0
  96. package/dist/rest/UploadTokenManager.js +156 -0
  97. package/dist/rest/UploadTokenManager.js.map +1 -0
  98. package/dist/services/IntegrationSyncWorkerService.d.ts +48 -0
  99. package/dist/services/IntegrationSyncWorkerService.d.ts.map +1 -0
  100. package/dist/services/IntegrationSyncWorkerService.js +132 -0
  101. package/dist/services/IntegrationSyncWorkerService.js.map +1 -0
  102. package/dist/services/StartTaskGraphDispatcher.d.ts +0 -10
  103. package/dist/services/StartTaskGraphDispatcher.d.ts.map +1 -1
  104. package/dist/services/StartTaskGraphDispatcher.js +21 -1
  105. package/dist/services/StartTaskGraphDispatcher.js.map +1 -1
  106. package/dist/services/TaskGraphAgentRunner.d.ts +7 -0
  107. package/dist/services/TaskGraphAgentRunner.d.ts.map +1 -1
  108. package/dist/services/TaskGraphAgentRunner.js +23 -0
  109. package/dist/services/TaskGraphAgentRunner.js.map +1 -1
  110. package/dist/services/TaskGraphContinuationDeliverer.d.ts.map +1 -1
  111. package/dist/services/TaskGraphContinuationDeliverer.js +12 -1
  112. package/dist/services/TaskGraphContinuationDeliverer.js.map +1 -1
  113. package/dist/types.d.ts +6 -0
  114. package/dist/types.d.ts.map +1 -1
  115. package/dist/types.js.map +1 -1
  116. package/package.json +91 -91
  117. package/src/__tests__/APIKeyScopeAuth.test.ts +475 -0
  118. package/src/__tests__/AuthProviderEngine.test.ts +278 -0
  119. package/src/__tests__/EntityCRUDHandler.test.ts +378 -0
  120. package/src/__tests__/IntegrationSyncWorkerService.test.ts +275 -0
  121. package/src/__tests__/OAuthCallbackHandler.openRedirect.test.ts +117 -0
  122. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +4 -1
  123. package/src/__tests__/RESTEndpointHandler.test.ts +672 -0
  124. package/src/__tests__/RSUProgressBridge.test.ts +250 -0
  125. package/src/__tests__/RealtimeBridgeResolver.test.ts +10 -2
  126. package/src/__tests__/SchemaRefreshLaunch.test.ts +114 -0
  127. package/src/__tests__/UserResolver.currentUser.test.ts +87 -0
  128. package/src/__tests__/ViewOperationsHandler.test.ts +277 -0
  129. package/src/__tests__/config-units.test.ts +74 -0
  130. package/src/__tests__/initializeProviders.refresh.test.ts +146 -0
  131. package/src/__tests__/mjapi-bootstrap.test.ts +0 -6
  132. package/src/__tests__/newUsers.test.ts +726 -0
  133. package/src/__tests__/uploadTokenManager.test.ts +159 -0
  134. package/src/auth/AuthProviderCatalogRouter.ts +76 -0
  135. package/src/auth/AuthProviderEngine.ts +253 -0
  136. package/src/auth/index.ts +59 -15
  137. package/src/auth/initializeProviders.ts +128 -7
  138. package/src/config.ts +55 -36
  139. package/src/generated/generated.ts +1911 -10551
  140. package/src/generic/ResolverBase.ts +11 -4
  141. package/src/generic/RunViewResolver.ts +31 -0
  142. package/src/index.ts +108 -15
  143. package/src/integration/RSUProgressBridge.ts +248 -0
  144. package/src/integration/SchemaRefreshLaunch.ts +77 -0
  145. package/src/providerConfigUnits.ts +21 -0
  146. package/src/resolvers/FileResolver.ts +200 -40
  147. package/src/resolvers/IntegrationDiscoveryResolver.ts +316 -68
  148. package/src/resolvers/QueryResolver.ts +25 -11
  149. package/src/resolvers/QuerySystemUserResolver.ts +28 -13
  150. package/src/resolvers/RunAIAgentResolver.ts +21 -6
  151. package/src/resolvers/TaskGraphFrameResolver.ts +67 -0
  152. package/src/resolvers/UserResolver.ts +99 -60
  153. package/src/resolvers/currentUserRoles.ts +47 -0
  154. package/src/rest/MediaAccessKeys.ts +47 -1
  155. package/src/rest/MediaStreamHandler.ts +98 -3
  156. package/src/rest/OAuthCallbackHandler.ts +149 -41
  157. package/src/rest/UploadTokenManager.ts +211 -0
  158. package/src/services/IntegrationSyncWorkerService.ts +141 -0
  159. package/src/services/StartTaskGraphDispatcher.ts +21 -1
  160. package/src/services/TaskGraphAgentRunner.ts +23 -0
  161. package/src/services/TaskGraphContinuationDeliverer.ts +11 -1
  162. 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
- if (
215
- configInfo.userHandling.autoCreateNewUsers &&
216
- firstName &&
217
- lastName &&
218
- (requestDomain || configInfo.userHandling.newUserLimitedToAuthorizedDomains === false)
219
- ) {
220
- // check to see if the domain that we have a request coming in from matches one of the domains in the autoCreateNewUsersDomains setting
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 && requestDomain) {
225
- /*in this second condition, we check the domain against authorized domains*/
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. Request domain '${requestDomain}' does not match any of the domains in the newUserAuthorizedDomains setting. To ignore domain, make sure you set the newUserLimitedToAuthorizedDomains setting to false. In this case we are NOT creating a new user.`
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
  }