@devilsdev/rag-pipeline-utils 2.4.4 → 2.5.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.
Files changed (225) hide show
  1. package/.ragrc.schema.json +83 -192
  2. package/CHANGELOG.md +345 -0
  3. package/README.md +418 -117
  4. package/SECURITY.md +5 -5
  5. package/dist/experimental.cjs +2 -0
  6. package/dist/experimental.d.ts +1 -0
  7. package/dist/experimental.mjs +5 -0
  8. package/dist/index.cjs +4992 -1242
  9. package/dist/index.d.ts +80 -2
  10. package/dist/index.mjs +1 -1
  11. package/dist/types/agentic/agentic-pipeline.d.ts +103 -0
  12. package/dist/types/agentic/iterative-retriever.d.ts +85 -0
  13. package/dist/types/agentic/query-planner.d.ts +145 -0
  14. package/dist/types/agentic/self-critique.d.ts +45 -0
  15. package/dist/types/ai/adaptive-retrieval.d.ts +44 -0
  16. package/dist/types/ai/federation/federated-learning-coordinator.d.ts +62 -0
  17. package/dist/types/ai/multimodal-processing.d.ts +32 -0
  18. package/dist/types/ai/multimodal.d.ts +39 -0
  19. package/dist/types/ai/retrieval-engine.d.ts +37 -0
  20. package/dist/types/ai/training/model-training-orchestrator.d.ts +31 -0
  21. package/dist/types/chunking/chunking-engine.d.ts +70 -0
  22. package/dist/types/chunking/strategies/fixed-size.d.ts +17 -0
  23. package/dist/types/chunking/strategies/recursive.d.ts +20 -0
  24. package/dist/types/chunking/strategies/semantic.d.ts +19 -0
  25. package/dist/types/chunking/strategies/sentence.d.ts +13 -0
  26. package/dist/types/chunking/strategies/structure-aware.d.ts +19 -0
  27. package/dist/types/citation/citation-tracker.d.ts +64 -0
  28. package/dist/types/citation/hallucination-detector.d.ts +40 -0
  29. package/dist/types/citation/source-mapper.d.ts +37 -0
  30. package/dist/types/connectors/anthropic-connector.d.ts +92 -0
  31. package/dist/types/connectors/base-connector.d.ts +87 -0
  32. package/dist/types/connectors/cohere-connector.d.ts +89 -0
  33. package/dist/types/connectors/connector-utils.d.ts +110 -0
  34. package/dist/types/connectors/local-embedder.d.ts +86 -0
  35. package/dist/types/connectors/memory-retriever.d.ts +91 -0
  36. package/dist/types/connectors/ollama-connector.d.ts +110 -0
  37. package/dist/types/connectors/openai-connector.d.ts +114 -0
  38. package/dist/types/connectors/rag-prompt.d.ts +40 -0
  39. package/dist/types/cost/cost-calculator.d.ts +152 -0
  40. package/dist/types/cost/cost-tracker.d.ts +126 -0
  41. package/dist/types/cost/token-budget.d.ts +110 -0
  42. package/dist/types/dag/core/error-context.d.ts +239 -0
  43. package/dist/types/dag/core/execution-scheduler.d.ts +191 -0
  44. package/dist/types/dag/core/topology-validation.d.ts +226 -0
  45. package/dist/types/dag/dag-engine.d.ts +156 -0
  46. package/dist/types/dag/dag-node.d.ts +37 -0
  47. package/dist/types/dashboard/dashboard-generator.d.ts +127 -0
  48. package/dist/types/dashboard/metrics-aggregator.d.ts +218 -0
  49. package/dist/types/debug/execution-tracer.d.ts +57 -0
  50. package/dist/types/debug/trace-inspector.d.ts +31 -0
  51. package/dist/types/ecosystem/plugin-certification.d.ts +169 -0
  52. package/dist/types/enterprise/data-governance.d.ts +203 -0
  53. package/dist/types/enterprise/multi-tenancy.d.ts +137 -0
  54. package/dist/types/enterprise/sso-integration.d.ts +420 -0
  55. package/dist/types/evaluate/context-metrics.d.ts +55 -0
  56. package/dist/types/evaluate/faithfulness.d.ts +41 -0
  57. package/dist/types/evaluate/groundedness.d.ts +60 -0
  58. package/dist/types/evaluate/pipeline-evaluator.d.ts +88 -0
  59. package/dist/types/evaluate/relevance.d.ts +21 -0
  60. package/dist/types/evaluate/scoring.d.ts +123 -0
  61. package/dist/types/experimental.d.ts +15 -0
  62. package/dist/types/graph/entity-extractor.d.ts +84 -0
  63. package/dist/types/graph/graph-index.d.ts +49 -0
  64. package/dist/types/graph/graph-retriever.d.ts +81 -0
  65. package/dist/types/graph/knowledge-graph.d.ts +129 -0
  66. package/dist/types/guardrails/guardrails-pipeline.d.ts +63 -0
  67. package/dist/types/guardrails/injection-detector.d.ts +47 -0
  68. package/dist/types/guardrails/post-generation-guard.d.ts +61 -0
  69. package/dist/types/guardrails/pre-retrieval-guard.d.ts +64 -0
  70. package/dist/types/guardrails/retrieval-guard.d.ts +57 -0
  71. package/dist/types/mcp/mcp-server.d.ts +85 -0
  72. package/dist/types/mcp/mcp-tool-builder.d.ts +55 -0
  73. package/dist/types/reranker/cascade-reranker.d.ts +40 -0
  74. package/dist/types/reranker/embedding-reranker.d.ts +77 -0
  75. package/dist/types/reranker/scoring-reranker.d.ts +41 -0
  76. package/dist/types/retrieval/bm25-search.d.ts +43 -0
  77. package/dist/types/retrieval/file-vector-store.d.ts +87 -0
  78. package/dist/types/retrieval/hybrid-retriever.d.ts +35 -0
  79. package/dist/types/retrieval/rank-fusion.d.ts +10 -0
  80. package/dist/types/security/jwt-config.d.ts +80 -0
  81. package/dist/types/security/jwt-validator.d.ts +195 -0
  82. package/dist/types/security/plugin-sandbox.d.ts +158 -0
  83. package/dist/types/security/plugin-signature-verifier.d.ts +139 -0
  84. package/dist/types/security/validator.d.ts +5 -0
  85. package/dist/types/streaming/embedding-stream.d.ts +65 -0
  86. package/dist/types/streaming/sse-adapter.d.ts +59 -0
  87. package/dist/types/streaming/stream-router.d.ts +88 -0
  88. package/dist/types/streaming/streaming-embedder.d.ts +99 -0
  89. package/dist/types/streaming/websocket-adapter.d.ts +71 -0
  90. package/dist/types/templates/scaffold.d.ts +55 -0
  91. package/dist/types/templates/template-registry.d.ts +63 -0
  92. package/dist/types/utils/embed-query.d.ts +20 -0
  93. package/dist/types/utils/logger.d.ts +50 -0
  94. package/dist/types/utils/rate-limiter.d.ts +66 -0
  95. package/dist/types/utils/retry.d.ts +27 -0
  96. package/dist/types/utils/run-abort.d.ts +34 -0
  97. package/dist/types/utils/secure-logging.d.ts +51 -0
  98. package/dist/types/utils/structured-logger.d.ts +126 -0
  99. package/package.json +53 -29
  100. package/src/agentic/agentic-pipeline.js +75 -7
  101. package/src/agentic/iterative-retriever.js +47 -10
  102. package/src/agentic/query-planner.js +329 -78
  103. package/src/citation/citation-tracker.js +6 -4
  104. package/src/citation/hallucination-detector.js +2 -2
  105. package/src/citation/source-mapper.js +15 -28
  106. package/src/cli/commands/ai.js +101 -0
  107. package/src/cli/commands/plugin-hub/event-handlers.js +6 -0
  108. package/src/cli/commands/plugin-hub/hub-commands.js +0 -74
  109. package/src/cli/commands/plugin-hub/local-commands.js +143 -0
  110. package/src/cli/commands/plugin-hub.js +96 -36
  111. package/src/cli/diagnostics/config-checker.js +76 -63
  112. package/src/cli/diagnostics/environment-checker.js +62 -31
  113. package/src/cli/diagnostics/plugin-checker.js +36 -89
  114. package/src/cli/doctor-command.js +39 -5
  115. package/src/cli/enhanced-cli-commands.js +72 -217
  116. package/src/cli/handlers/ai-handler.js +364 -0
  117. package/src/cli/handlers/diagnostics-handler.js +78 -31
  118. package/src/cli/handlers/ingest-handler.js +28 -22
  119. package/src/cli/handlers/init-handler.js +148 -25
  120. package/src/cli/handlers/query-handler.js +41 -26
  121. package/src/cli/handlers/run-handler.js +16 -167
  122. package/src/cli/handlers/serve-handler.js +60 -0
  123. package/src/cli/services/bench-stats.js +54 -0
  124. package/src/cli/services/benchmark.js +161 -0
  125. package/src/cli/services/feedback.js +155 -0
  126. package/src/cli/services/rag-service.js +322 -0
  127. package/src/cli/shared/cli-utils.js +24 -7
  128. package/src/cli/shared/config-context.js +21 -0
  129. package/src/cli/wizard/config-builder.js +81 -0
  130. package/src/cli/wizard/init-wizard.js +617 -0
  131. package/src/config/component-factory.js +125 -0
  132. package/src/config/load-config.js +29 -10
  133. package/src/config/load-plugin-config.js +46 -2
  134. package/src/connectors/anthropic-connector.js +93 -55
  135. package/src/connectors/base-connector.js +74 -19
  136. package/src/connectors/cohere-connector.js +88 -32
  137. package/src/connectors/connector-utils.js +302 -0
  138. package/src/connectors/local-embedder.js +43 -5
  139. package/src/connectors/memory-retriever.js +108 -15
  140. package/src/connectors/ollama-connector.js +83 -49
  141. package/src/connectors/openai-connector.js +108 -44
  142. package/src/connectors/rag-prompt.js +88 -0
  143. package/src/core/create-pipeline.js +209 -60
  144. package/src/core/plugin-registry.js +87 -11
  145. package/src/cost/cost-calculator.js +205 -32
  146. package/src/dev/dev-server.js +166 -11
  147. package/src/dev/hot-reload.js +40 -1
  148. package/src/ecosystem/plugin-checks.js +181 -0
  149. package/src/ecosystem/plugin-hub.js +50 -125
  150. package/src/ecosystem/plugin-installer.js +298 -0
  151. package/src/ecosystem/plugin-probe.js +127 -0
  152. package/src/ecosystem/plugin-scanner.js +185 -0
  153. package/src/ecosystem/tar-extract.js +114 -0
  154. package/src/enterprise/audit-logging.js +158 -29
  155. package/src/enterprise/sso-integration.js +715 -228
  156. package/src/evaluate/context-metrics.js +28 -16
  157. package/src/evaluate/evaluator.js +3 -3
  158. package/src/evaluate/faithfulness.js +18 -14
  159. package/src/evaluate/groundedness.js +43 -12
  160. package/src/evaluate/pipeline-evaluator.js +35 -8
  161. package/src/evaluate/scoring.js +251 -3
  162. package/src/experimental.js +71 -0
  163. package/src/guardrails/guardrails-pipeline.js +69 -7
  164. package/src/guardrails/injection-detector.js +283 -0
  165. package/src/guardrails/post-generation-guard.js +19 -0
  166. package/src/guardrails/pre-retrieval-guard.js +36 -36
  167. package/src/guardrails/retrieval-guard.js +58 -8
  168. package/src/index.js +36 -1
  169. package/src/loader/document-reader.js +91 -0
  170. package/src/reranker/cascade-reranker.js +3 -0
  171. package/src/reranker/embedding-reranker.js +136 -21
  172. package/src/reranker/llm-reranker.js +79 -12
  173. package/src/reranker/scoring-reranker.js +3 -0
  174. package/src/retrieval/bm25-search.js +12 -6
  175. package/src/retrieval/file-vector-store.js +212 -0
  176. package/src/retrieval/hybrid-retriever.js +22 -19
  177. package/src/security/jwt-config.js +13 -2
  178. package/src/security/jwt-validator.js +134 -30
  179. package/src/security/plugin-sandbox.js +18 -1
  180. package/src/security/plugin-signature-verifier.js +245 -70
  181. package/src/utils/deprecate.js +69 -0
  182. package/src/utils/embed-query.js +33 -0
  183. package/src/utils/input-sanitizer.js +19 -4
  184. package/src/utils/logger.js +34 -4
  185. package/src/utils/run-abort.js +102 -0
  186. package/src/ai/federated-learning.js +0 -211
  187. package/src/ai/federation.js +0 -71
  188. package/src/ai/index.js +0 -22
  189. package/src/ai/model-trainer.js +0 -217
  190. package/src/ai/model-training.js +0 -202
  191. package/src/ai/multimodal/multi-modal-processor.js +0 -345
  192. package/src/ai/orchestrator.js +0 -65
  193. package/src/ai/retrieval/adaptive-retrieval-engine.js +0 -398
  194. package/src/cli/commands/ai-ml/adaptive-commands.js +0 -178
  195. package/src/cli/commands/ai-ml/federated-commands.js +0 -207
  196. package/src/cli/commands/ai-ml/index.js +0 -17
  197. package/src/cli/commands/ai-ml/multimodal-commands.js +0 -187
  198. package/src/cli/commands/ai-ml/shared.js +0 -51
  199. package/src/cli/commands/ai-ml/training-commands.js +0 -228
  200. package/src/cli/commands/ai-ml.js +0 -386
  201. package/src/cli/commands/docs.js +0 -228
  202. package/src/cli/commands/dx.js +0 -538
  203. package/src/cli/commands/wizard.js +0 -627
  204. package/src/cli/input-sanitization-middleware.js +0 -356
  205. package/src/cli/interactive-wizard.js +0 -373
  206. package/src/cli/templates/development.template.json +0 -64
  207. package/src/cli/templates/minimal.template.json +0 -33
  208. package/src/cli/templates/production.template.json +0 -104
  209. package/src/cli/templates/testing.template.json +0 -45
  210. package/src/cli/wizard/configuration.js +0 -148
  211. package/src/cli/wizard/observability.js +0 -79
  212. package/src/cli/wizard/performance.js +0 -76
  213. package/src/cli/wizard/plugin-selection.js +0 -297
  214. package/src/cli/wizard/preview-save.js +0 -172
  215. package/src/cli/wizard/project-setup.js +0 -66
  216. package/src/core/pipeline-factory.js +0 -59
  217. package/src/dag/enhanced-dag-engine.js +0 -894
  218. package/src/dx/index.js +0 -40
  219. package/src/dx/integration-templates.js +0 -662
  220. package/src/dx/performance-profiler.js +0 -968
  221. package/src/dx/realtime-debugger.js +0 -755
  222. package/src/dx/visual-pipeline-builder.js +0 -578
  223. package/src/ingest.js +0 -59
  224. package/src/pipeline/pipeline.js +0 -18
  225. package/src/query.js +0 -117
@@ -1,13 +1,25 @@
1
1
  /**
2
- * Enterprise SSO Integration
2
+ * Enterprise SSO Integration (EXPERIMENTAL - see src/experimental.js)
3
3
  * SAML 2.0, OAuth2, Active Directory, and identity provider support
4
+ *
5
+ * Security behaviour (2.5.0):
6
+ * - OAuth2 and OIDC always run the real authorization-code flow with PKCE
7
+ * (S256). redirect_uri comes from provider configuration (`redirectUri`)
8
+ * or the server-side login request, never from callback data.
9
+ * - OIDC id_tokens are validated (JWKS signature, iss, aud, exp, nonce).
10
+ * - SAML refuses to authenticate (no vetted XML-signature verification).
11
+ * - Active Directory is only implemented as a development stub.
12
+ * - The development bypass (any code accepted, fixed dev identity) is OFF
13
+ * by default and only active with `devBypass: true` AND
14
+ * NODE_ENV !== 'production'; it throws in production and logs a loud
15
+ * warning otherwise.
4
16
  */
5
17
 
6
- const fs = require('fs').promises;
7
- const path = require('path');
8
18
  const crypto = require('crypto');
9
19
  const { EventEmitter } = require('events');
20
+ const jwt = require('jsonwebtoken');
10
21
  const RateLimiter = require('../utils/rate-limiter');
22
+ const { logger } = require('../utils/logger');
11
23
  const { JWTValidator } = require('../security/jwt-validator');
12
24
  const { createJWTConfig } = require('../security/jwt-config');
13
25
 
@@ -17,6 +29,20 @@ class SSOManager extends EventEmitter {
17
29
 
18
30
  this._config = this._buildConfig(_options);
19
31
 
32
+ if (this._config.devBypass === true) {
33
+ if (process.env.NODE_ENV === 'production') {
34
+ throw new Error(
35
+ 'SSOManager: devBypass must not be enabled when NODE_ENV=production',
36
+ );
37
+ }
38
+ logger.warn(
39
+ '[SSO] WARNING: SSOManager created with devBypass: true. SSO ' +
40
+ 'callbacks will accept any authorization code and issue sessions ' +
41
+ 'for a fixed development identity. Never use this outside local ' +
42
+ 'development.',
43
+ );
44
+ }
45
+
20
46
  this.sessions = new Map();
21
47
  this.providers = new Map();
22
48
  this.userCache = new Map();
@@ -51,6 +77,7 @@ class SSOManager extends EventEmitter {
51
77
  authorizationUrl: _options.authorizationUrl,
52
78
  tokenUrl: _options.tokenUrl,
53
79
  userInfoUrl: _options.userInfoUrl,
80
+ redirectUri: _options.redirectUri,
54
81
  scopes: ['openid', 'profile', 'email'],
55
82
  },
56
83
  activeDirectory: {
@@ -60,6 +87,7 @@ class SSOManager extends EventEmitter {
60
87
  baseDN: _options.baseDN,
61
88
  bindDN: _options.bindDN,
62
89
  bindCredentials: _options.bindCredentials,
90
+ redirectUri: _options.redirectUri,
63
91
  },
64
92
  oidc: {
65
93
  enabled: false,
@@ -87,6 +115,8 @@ class SSOManager extends EventEmitter {
87
115
  blockDurationMs: _options.blockDurationMs || 60 * 60 * 1000, // 1 hour
88
116
  },
89
117
  ..._options,
118
+ // Development bypass: off unless explicitly requested
119
+ devBypass: _options.devBypass === true,
90
120
  };
91
121
  }
92
122
 
@@ -150,27 +180,36 @@ class SSOManager extends EventEmitter {
150
180
  /**
151
181
  * Initialize SSO providers
152
182
  */
153
- async _initializeProviders() {
154
- if (this._config.providers.saml.enabled) {
155
- this.providers.set('saml', new SAMLProvider(this._config.providers.saml));
183
+ _initializeProviders() {
184
+ // Synchronous so configuration errors (e.g. devBypass in production)
185
+ // surface from the constructor instead of as unhandled rejections.
186
+ const providers = this._config.providers || {};
187
+ const withBypass = (providerConfig) => ({
188
+ ...providerConfig,
189
+ devBypass:
190
+ this._config.devBypass === true || providerConfig.devBypass === true,
191
+ });
192
+
193
+ if (providers.saml && providers.saml.enabled) {
194
+ this.providers.set('saml', new SAMLProvider(withBypass(providers.saml)));
156
195
  }
157
196
 
158
- if (this._config.providers.oauth2.enabled) {
197
+ if (providers.oauth2 && providers.oauth2.enabled) {
159
198
  this.providers.set(
160
199
  'oauth2',
161
- new OAuth2Provider(this._config.providers.oauth2),
200
+ new OAuth2Provider(withBypass(providers.oauth2)),
162
201
  );
163
202
  }
164
203
 
165
- if (this._config.providers.activeDirectory.enabled) {
204
+ if (providers.activeDirectory && providers.activeDirectory.enabled) {
166
205
  this.providers.set(
167
206
  'ad',
168
- new ActiveDirectoryProvider(this._config.providers.activeDirectory),
207
+ new ActiveDirectoryProvider(withBypass(providers.activeDirectory)),
169
208
  );
170
209
  }
171
210
 
172
- if (this._config.providers.oidc.enabled) {
173
- this.providers.set('oidc', new OIDCProvider(this._config.providers.oidc));
211
+ if (providers.oidc && providers.oidc.enabled) {
212
+ this.providers.set('oidc', new OIDCProvider(withBypass(providers.oidc)));
174
213
  }
175
214
  }
176
215
 
@@ -207,11 +246,19 @@ class SSOManager extends EventEmitter {
207
246
  }
208
247
 
209
248
  const state = crypto.randomUUID();
249
+ const { codeVerifier, codeChallenge } = createPkcePair();
210
250
  const loginRequest = {
211
251
  tenantId,
212
252
  provider,
213
253
  state,
214
254
  _redirectUrl,
255
+ // Server-side only: PKCE verifier, OIDC nonce and the redirect_uri
256
+ // sent in the authorization request (configured value preferred).
257
+ codeVerifier,
258
+ nonce: crypto.randomBytes(16).toString('base64url'),
259
+ redirectUri:
260
+ (ssoProvider._config && ssoProvider._config.redirectUri) ||
261
+ _redirectUrl,
215
262
  timestamp: Date.now(),
216
263
  expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
217
264
  };
@@ -219,7 +266,11 @@ class SSOManager extends EventEmitter {
219
266
  // Store login request for callback validation
220
267
  this.sessions.set(state, loginRequest);
221
268
 
222
- const authUrl = await ssoProvider.getAuthorizationUrl(state, _redirectUrl);
269
+ const authUrl = await ssoProvider.getAuthorizationUrl(state, _redirectUrl, {
270
+ codeChallenge,
271
+ nonce: loginRequest.nonce,
272
+ redirectUri: loginRequest.redirectUri,
273
+ });
223
274
 
224
275
  this.emit('login_initiated', { tenantId, provider, state });
225
276
 
@@ -239,21 +290,54 @@ class SSOManager extends EventEmitter {
239
290
  throw new Error(`SSO provider ${provider} not configured`);
240
291
  }
241
292
 
293
+ if (
294
+ !_callbackData ||
295
+ typeof _callbackData !== 'object' ||
296
+ typeof _callbackData.state !== 'string'
297
+ ) {
298
+ throw new Error('Invalid or expired login request');
299
+ }
300
+
242
301
  const loginRequest = this.sessions.get(_callbackData.state);
243
- if (!loginRequest) {
302
+ // Only login requests (not sessions) are keyed by state; they carry it.
303
+ if (!loginRequest || loginRequest.state !== _callbackData.state) {
244
304
  throw new Error('Invalid or expired login request');
245
305
  }
246
306
 
307
+ // A state is single-use: consume it before doing anything else
308
+ this.sessions.delete(_callbackData.state);
309
+
310
+ if (loginRequest.provider !== provider) {
311
+ this.emit('login_provider_mismatch', {
312
+ tenantId: loginRequest.tenantId,
313
+ expected: loginRequest.provider,
314
+ received: provider,
315
+ });
316
+ throw new Error('Login request provider mismatch');
317
+ }
318
+
247
319
  if (loginRequest.expiresAt < Date.now()) {
248
- this.sessions.delete(_callbackData.state);
249
320
  throw new Error('Login request expired');
250
321
  }
251
322
 
323
+ const loginContext = {
324
+ state: loginRequest.state,
325
+ codeVerifier: loginRequest.codeVerifier,
326
+ nonce: loginRequest.nonce,
327
+ redirectUri: loginRequest.redirectUri,
328
+ };
329
+
252
330
  // Exchange authorization code for tokens
253
- const tokenData = await ssoProvider.exchangeCodeForTokens(_callbackData);
331
+ const tokenData = await ssoProvider.exchangeCodeForTokens(
332
+ _callbackData,
333
+ loginContext,
334
+ );
254
335
 
255
336
  // Get user information
256
- const userInfo = await ssoProvider.getUserInfo(tokenData._accessToken);
337
+ const userInfo = await ssoProvider.getUserInfo(
338
+ tokenData._accessToken,
339
+ tokenData,
340
+ );
257
341
 
258
342
  // Create internal user session
259
343
  const session = await this._createUserSession(
@@ -263,9 +347,6 @@ class SSOManager extends EventEmitter {
263
347
  tokenData,
264
348
  );
265
349
 
266
- // Clean up login request
267
- this.sessions.delete(_callbackData.state);
268
-
269
350
  this.emit('login_completed', {
270
351
  tenantId: loginRequest.tenantId,
271
352
  provider,
@@ -652,10 +733,149 @@ class SSOManager extends EventEmitter {
652
733
  }
653
734
  }
654
735
 
736
+ // ---------------------------------------------------------------------------
737
+ // Shared helpers
738
+ // ---------------------------------------------------------------------------
739
+
740
+ const SAML_UNSUPPORTED_MESSAGE =
741
+ 'SAML authentication is not supported: SAML assertions cannot be verified ' +
742
+ 'without a vetted XML-signature library, so SAMLProvider refuses to ' +
743
+ 'authenticate. Use the OIDC provider instead (or devBypass: true outside ' +
744
+ 'production for local testing only).';
745
+
746
+ const DEFAULT_ID_TOKEN_ALGORITHMS = [
747
+ 'RS256',
748
+ 'RS384',
749
+ 'RS512',
750
+ 'PS256',
751
+ 'PS384',
752
+ 'PS512',
753
+ 'ES256',
754
+ 'ES384',
755
+ 'ES512',
756
+ ];
757
+
758
+ /**
759
+ * True when the development bypass is active for a provider: it must be
760
+ * requested explicitly (devBypass: true) AND NODE_ENV must not be
761
+ * "production". Evaluated at call time.
762
+ * @param {Object} config - Provider configuration
763
+ * @returns {boolean}
764
+ */
765
+ function isDevBypassActive(config) {
766
+ return (
767
+ Boolean(config) &&
768
+ config.devBypass === true &&
769
+ process.env.NODE_ENV !== 'production'
770
+ );
771
+ }
772
+
773
+ /**
774
+ * Refuse devBypass in production and warn loudly when it is enabled.
775
+ * @param {string} providerName - Provider name for the message
776
+ * @param {Object} config - Provider configuration
777
+ */
778
+ function checkDevBypass(providerName, config) {
779
+ if (!config || config.devBypass !== true) return;
780
+ if (process.env.NODE_ENV === 'production') {
781
+ throw new Error(
782
+ `${providerName}: devBypass must not be enabled when NODE_ENV=production`,
783
+ );
784
+ }
785
+ logger.warn(
786
+ `[SSO] WARNING: devBypass is ENABLED for ${providerName}. Any ` +
787
+ 'authorization code with a valid state is accepted and a session is ' +
788
+ 'issued for a fixed development identity. Never enable this outside ' +
789
+ 'local development.',
790
+ );
791
+ }
792
+
793
+ /**
794
+ * Create a PKCE (RFC 7636, S256) verifier/challenge pair.
795
+ * @returns {{codeVerifier: string, codeChallenge: string}}
796
+ */
797
+ function createPkcePair() {
798
+ const codeVerifier = crypto.randomBytes(32).toString('base64url');
799
+ const codeChallenge = crypto
800
+ .createHash('sha256')
801
+ .update(codeVerifier)
802
+ .digest('base64url');
803
+ return { codeVerifier, codeChallenge };
804
+ }
805
+
806
+ /**
807
+ * Build a token-endpoint request (authorization_code grant with PKCE).
808
+ * Confidential clients authenticate with HTTP Basic; public clients send
809
+ * client_id in the body.
810
+ * @private
811
+ */
812
+ function buildCodeExchangeRequest(config, code, redirectUri, codeVerifier) {
813
+ const body = new URLSearchParams({
814
+ grant_type: 'authorization_code',
815
+ code,
816
+ redirect_uri: redirectUri,
817
+ code_verifier: codeVerifier,
818
+ });
819
+ const headers = {
820
+ 'Content-Type': 'application/x-www-form-urlencoded',
821
+ Accept: 'application/json',
822
+ };
823
+ if (config.clientSecret) {
824
+ headers.Authorization = `Basic ${Buffer.from(
825
+ `${config.clientId}:${config.clientSecret}`,
826
+ ).toString('base64')}`;
827
+ } else {
828
+ body.set('client_id', config.clientId);
829
+ }
830
+ return { method: 'POST', headers, body };
831
+ }
832
+
833
+ /**
834
+ * Validate the common inputs of a real (non-bypass) code exchange.
835
+ * redirect_uri comes from provider configuration (or the server-side login
836
+ * request), never from callback data.
837
+ * @private
838
+ */
839
+ function requireExchangeInputs(providerName, config, callbackData, ctx) {
840
+ if (!callbackData.code || typeof callbackData.code !== 'string') {
841
+ throw new Error('Authorization code is required');
842
+ }
843
+ const redirectUri = config.redirectUri || (ctx && ctx.redirectUri);
844
+ if (!redirectUri) {
845
+ throw new Error(
846
+ `${providerName}: redirectUri must be configured for the provider`,
847
+ );
848
+ }
849
+ if (!ctx || !ctx.codeVerifier) {
850
+ throw new Error(
851
+ `${providerName}: PKCE code_verifier missing; use SSOManager.initiateLogin()/handleCallback()`,
852
+ );
853
+ }
854
+ return redirectUri;
855
+ }
856
+
857
+ function assertCallbackData(callbackData) {
858
+ if (!callbackData || typeof callbackData !== 'object') {
859
+ throw new Error('Callback data is required');
860
+ }
861
+ }
862
+
655
863
  // SSO Provider implementations
656
864
  class SAMLProvider {
657
- constructor(_config) {
865
+ /**
866
+ * SAML 2.0 provider.
867
+ *
868
+ * Real SAML authentication is NOT supported: verifying SAML assertions
869
+ * requires XML canonicalization and XML-signature verification, which
870
+ * must come from a vetted library rather than hand-written code. Every
871
+ * authentication method throws unless the explicit development bypass is
872
+ * active (devBypass: true and NODE_ENV !== 'production').
873
+ *
874
+ * @param {Object} _config - Provider configuration
875
+ */
876
+ constructor(_config = {}) {
658
877
  this._config = _config;
878
+ checkDevBypass('SAMLProvider', this._config);
659
879
  }
660
880
 
661
881
  async getAuthorizationUrl(state, _redirectUrl) {
@@ -669,35 +889,39 @@ class SAMLProvider {
669
889
  }
670
890
 
671
891
  async exchangeCodeForTokens(_callbackData) {
892
+ assertCallbackData(_callbackData);
672
893
  // SAML assertion processing
673
894
  const assertion = this._validateSAMLResponse(_callbackData.SAMLResponse);
674
895
 
675
896
  return {
676
897
  _accessToken: assertion.sessionIndex,
898
+ assertion,
677
899
  refreshToken: null,
678
900
  expiresAt: assertion.notOnOrAfter,
679
901
  };
680
902
  }
681
903
 
682
- async getUserInfo(_accessToken) {
683
- // Extract user info from SAML assertion
904
+ async getUserInfo(_accessToken, tokenData = {}) {
905
+ if (!isDevBypassActive(this._config)) {
906
+ throw new Error(SAML_UNSUPPORTED_MESSAGE);
907
+ }
908
+ // Extract user info from the (development-only) SAML assertion
909
+ const assertion = tokenData.assertion || _accessToken;
684
910
  return {
685
- id: _accessToken.nameID,
686
- email: _accessToken.attributes.email,
687
- name: _accessToken.attributes.displayName,
688
- roles: _accessToken.attributes.roles || [],
689
- groups: _accessToken.attributes.groups || [],
911
+ id: assertion.nameID,
912
+ email: assertion.attributes.email,
913
+ name: assertion.attributes.displayName,
914
+ roles: assertion.attributes.roles || [],
915
+ groups: assertion.attributes.groups || [],
690
916
  };
691
917
  }
692
918
 
693
919
  _buildSAMLRequest(state) {
694
- if (process.env.NODE_ENV === 'production') {
695
- throw new Error(
696
- 'Production SAML implementation required - please configure SAML library',
697
- );
920
+ if (!isDevBypassActive(this._config)) {
921
+ throw new Error(SAML_UNSUPPORTED_MESSAGE);
698
922
  }
699
923
 
700
- // Development only - remove in production
924
+ // Development bypass only
701
925
  return Buffer.from(
702
926
  `<samlp:AuthnRequest ID="${state}" Version="2.0" IssueInstant="${new Date().toISOString()}" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
703
927
  <saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">${this._config.entityId}</saml:Issuer>
@@ -707,17 +931,15 @@ class SAMLProvider {
707
931
  }
708
932
 
709
933
  _validateSAMLResponse(response) {
710
- if (process.env.NODE_ENV === 'production') {
711
- throw new Error(
712
- 'Production SAML validation required - please configure SAML library',
713
- );
934
+ if (!isDevBypassActive(this._config)) {
935
+ throw new Error(SAML_UNSUPPORTED_MESSAGE);
714
936
  }
715
937
 
716
938
  if (!response) {
717
939
  throw new Error('SAML response is required');
718
940
  }
719
941
 
720
- // Development only - implement proper SAML validation in production
942
+ // Development bypass only: the response is NOT verified
721
943
  try {
722
944
  const decodedResponse = Buffer.from(response, 'base64').toString('utf8');
723
945
 
@@ -742,179 +964,217 @@ class SAMLProvider {
742
964
  }
743
965
 
744
966
  class OAuth2Provider {
745
- constructor(_config) {
746
- this._config = _config;
967
+ /**
968
+ * OAuth 2.0 authorization-code provider with PKCE (S256).
969
+ *
970
+ * @param {Object} _config - clientId, clientSecret (optional for public
971
+ * clients), authorizationUrl, tokenUrl, userInfoUrl, redirectUri,
972
+ * scopes, devBypass
973
+ */
974
+ constructor(_config = {}) {
975
+ this._config = { scopes: ['openid', 'profile', 'email'], ..._config };
976
+ checkDevBypass('OAuth2Provider', this._config);
747
977
  }
748
978
 
749
- async getAuthorizationUrl(state, _redirectUrl) {
979
+ /**
980
+ * @param {string} state - Opaque state bound to the login request
981
+ * @param {string} [_redirectUrl] - Fallback redirect_uri when none is configured
982
+ * @param {Object} [loginContext] - { codeChallenge, redirectUri } from SSOManager
983
+ */
984
+ async getAuthorizationUrl(state, _redirectUrl, loginContext = {}) {
750
985
  const params = new URLSearchParams({
751
986
  response_type: 'code',
752
987
  client_id: this._config.clientId,
753
- redirect_uri: _redirectUrl,
988
+ redirect_uri:
989
+ this._config.redirectUri || loginContext.redirectUri || _redirectUrl,
754
990
  scope: this._config.scopes.join(' '),
755
991
  state,
756
992
  });
993
+ if (loginContext.codeChallenge) {
994
+ params.set('code_challenge', loginContext.codeChallenge);
995
+ params.set('code_challenge_method', 'S256');
996
+ }
757
997
 
758
998
  return `${this._config.authorizationUrl}?${params.toString()}`;
759
999
  }
760
1000
 
761
- async exchangeCodeForTokens(callbackData) {
762
- if (process.env.NODE_ENV === 'production') {
763
- // Production implementation using real OAuth2 flow
764
- const tokenRequest = {
765
- method: 'POST',
766
- headers: {
767
- 'Content-Type': 'application/x-www-form-urlencoded',
768
- Authorization: `Basic ${Buffer.from(`${this._config.clientId}:${this._config.clientSecret}`).toString('base64')}`,
769
- },
770
- body: new URLSearchParams({
771
- grant_type: 'authorization_code',
772
- code: callbackData.code,
773
- redirect_uri: callbackData.redirect_uri,
774
- }),
775
- };
1001
+ /**
1002
+ * Exchange the authorization code. redirect_uri is taken from provider
1003
+ * configuration (or the server-side login request), never from
1004
+ * callbackData, and the PKCE code_verifier from the login request.
1005
+ *
1006
+ * @param {Object} callbackData - { code, state }
1007
+ * @param {Object} [loginContext] - { codeVerifier, redirectUri }
1008
+ */
1009
+ async exchangeCodeForTokens(callbackData, loginContext = {}) {
1010
+ assertCallbackData(callbackData);
776
1011
 
777
- try {
778
- const response = await fetch(this._config.tokenUrl, tokenRequest);
1012
+ if (isDevBypassActive(this._config)) {
1013
+ if (!callbackData.code) {
1014
+ throw new Error('Authorization code is required');
1015
+ }
1016
+ return {
1017
+ _accessToken: crypto.randomBytes(32).toString('hex'),
1018
+ refreshToken: crypto.randomBytes(32).toString('hex'),
1019
+ expiresAt: Date.now() + 3600 * 1000,
1020
+ };
1021
+ }
779
1022
 
780
- if (!response.ok) {
781
- throw new Error(
782
- `Token exchange failed: ${response.status} ${response.statusText}`,
783
- );
784
- }
1023
+ const redirectUri = requireExchangeInputs(
1024
+ 'OAuth2Provider',
1025
+ this._config,
1026
+ callbackData,
1027
+ loginContext,
1028
+ );
1029
+ const tokenRequest = buildCodeExchangeRequest(
1030
+ this._config,
1031
+ callbackData.code,
1032
+ redirectUri,
1033
+ loginContext.codeVerifier,
1034
+ );
785
1035
 
786
- const tokenData = await response.json();
1036
+ try {
1037
+ const response = await fetch(this._config.tokenUrl, tokenRequest);
787
1038
 
788
- return {
789
- _accessToken: tokenData.access_token,
790
- refreshToken: tokenData.refresh_token,
791
- expiresAt: Date.now() + tokenData.expires_in * 1000,
792
- tokenType: tokenData.token_type || 'Bearer',
793
- };
794
- } catch (error) {
795
- throw new Error(`OAuth2 token exchange failed: ${error.message}`);
1039
+ if (!response.ok) {
1040
+ throw new Error(
1041
+ `Token exchange failed: ${response.status} ${response.statusText}`,
1042
+ );
796
1043
  }
797
- }
798
1044
 
799
- // Development only
800
- if (!callbackData.code) {
801
- throw new Error('Authorization code is required');
802
- }
1045
+ const tokenData = await response.json();
803
1046
 
804
- return {
805
- _accessToken: crypto.randomBytes(32).toString('hex'),
806
- refreshToken: crypto.randomBytes(32).toString('hex'),
807
- expiresAt: Date.now() + 3600 * 1000,
808
- };
1047
+ return {
1048
+ _accessToken: tokenData.access_token,
1049
+ refreshToken: tokenData.refresh_token,
1050
+ expiresAt: Date.now() + tokenData.expires_in * 1000,
1051
+ tokenType: tokenData.token_type || 'Bearer',
1052
+ };
1053
+ } catch (error) {
1054
+ throw new Error(`OAuth2 token exchange failed: ${error.message}`);
1055
+ }
809
1056
  }
810
1057
 
811
1058
  async getUserInfo(accessToken) {
812
- if (process.env.NODE_ENV === 'production') {
813
- // Production implementation
814
- try {
815
- const userResponse = await fetch(this._config.userInfoUrl, {
816
- headers: {
817
- Authorization: `Bearer ${accessToken}`,
818
- Accept: 'application/json',
819
- },
820
- });
1059
+ if (isDevBypassActive(this._config)) {
1060
+ return {
1061
+ id: 'dev-' + crypto.randomUUID(),
1062
+ email: 'dev-oauth-user@example.com',
1063
+ name: 'Development OAuth User',
1064
+ roles: ['user', 'dev'],
1065
+ };
1066
+ }
821
1067
 
822
- if (!userResponse.ok) {
823
- throw new Error(`User info request failed: ${userResponse.status}`);
824
- }
1068
+ try {
1069
+ const userResponse = await fetch(this._config.userInfoUrl, {
1070
+ headers: {
1071
+ Authorization: `Bearer ${accessToken}`,
1072
+ Accept: 'application/json',
1073
+ },
1074
+ });
825
1075
 
826
- const userData = await userResponse.json();
827
-
828
- return {
829
- id: userData.id || userData.sub,
830
- email: userData.email,
831
- name: userData.name || userData.display_name,
832
- roles: userData.roles || [],
833
- attributes: userData,
834
- };
835
- } catch (error) {
836
- throw new Error(`Failed to get user info: ${error.message}`);
1076
+ if (!userResponse.ok) {
1077
+ throw new Error(`User info request failed: ${userResponse.status}`);
837
1078
  }
838
- }
839
1079
 
840
- // Development only
841
- return {
842
- id: 'dev-' + crypto.randomUUID(),
843
- email: 'dev-oauth-user@example.com',
844
- name: 'Development OAuth User',
845
- roles: ['user', 'dev'],
846
- };
1080
+ const userData = await userResponse.json();
1081
+
1082
+ return {
1083
+ id: userData.id || userData.sub,
1084
+ email: userData.email,
1085
+ name: userData.name || userData.display_name,
1086
+ roles: userData.roles || [],
1087
+ attributes: userData,
1088
+ };
1089
+ } catch (error) {
1090
+ throw new Error(`Failed to get user info: ${error.message}`);
1091
+ }
847
1092
  }
848
1093
 
849
1094
  async refreshTokens(refreshToken) {
850
- if (process.env.NODE_ENV === 'production') {
851
- // Production token refresh
852
- const refreshRequest = {
853
- method: 'POST',
854
- headers: {
855
- 'Content-Type': 'application/x-www-form-urlencoded',
856
- Authorization: `Basic ${Buffer.from(`${this._config.clientId}:${this._config.clientSecret}`).toString('base64')}`,
857
- },
858
- body: new URLSearchParams({
859
- grant_type: 'refresh_token',
860
- refresh_token: refreshToken,
861
- }),
1095
+ if (isDevBypassActive(this._config)) {
1096
+ return {
1097
+ _accessToken: crypto.randomBytes(32).toString('hex'),
1098
+ refreshToken: refreshToken,
1099
+ expiresAt: Date.now() + 3600 * 1000,
862
1100
  };
1101
+ }
863
1102
 
864
- try {
865
- const response = await fetch(this._config.tokenUrl, refreshRequest);
866
-
867
- if (!response.ok) {
868
- throw new Error(`Token refresh failed: ${response.status}`);
869
- }
1103
+ const body = new URLSearchParams({
1104
+ grant_type: 'refresh_token',
1105
+ refresh_token: refreshToken,
1106
+ });
1107
+ const headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
1108
+ if (this._config.clientSecret) {
1109
+ headers.Authorization = `Basic ${Buffer.from(
1110
+ `${this._config.clientId}:${this._config.clientSecret}`,
1111
+ ).toString('base64')}`;
1112
+ } else {
1113
+ body.set('client_id', this._config.clientId);
1114
+ }
870
1115
 
871
- const tokenData = await response.json();
1116
+ try {
1117
+ const response = await fetch(this._config.tokenUrl, {
1118
+ method: 'POST',
1119
+ headers,
1120
+ body,
1121
+ });
872
1122
 
873
- return {
874
- _accessToken: tokenData.access_token,
875
- refreshToken: tokenData.refresh_token || refreshToken,
876
- expiresAt: Date.now() + tokenData.expires_in * 1000,
877
- };
878
- } catch (error) {
879
- throw new Error(`Token refresh failed: ${error.message}`);
1123
+ if (!response.ok) {
1124
+ throw new Error(`Token refresh failed: ${response.status}`);
880
1125
  }
881
- }
882
1126
 
883
- // Development only
884
- return {
885
- _accessToken: crypto.randomBytes(32).toString('hex'),
886
- refreshToken: refreshToken,
887
- expiresAt: Date.now() + 3600 * 1000,
888
- };
1127
+ const tokenData = await response.json();
1128
+
1129
+ return {
1130
+ _accessToken: tokenData.access_token,
1131
+ refreshToken: tokenData.refresh_token || refreshToken,
1132
+ expiresAt: Date.now() + tokenData.expires_in * 1000,
1133
+ };
1134
+ } catch (error) {
1135
+ throw new Error(`Token refresh failed: ${error.message}`);
1136
+ }
889
1137
  }
890
1138
  }
891
1139
 
892
1140
  class ActiveDirectoryProvider {
893
- constructor(_config) {
1141
+ /**
1142
+ * Active Directory provider. Only the development bypass is implemented;
1143
+ * for AD FS / Entra ID use the OIDC provider, which both support.
1144
+ * @param {Object} _config - Provider configuration
1145
+ */
1146
+ constructor(_config = {}) {
894
1147
  this._config = _config;
1148
+ checkDevBypass('ActiveDirectoryProvider', this._config);
895
1149
  }
896
1150
 
897
- async getAuthorizationUrl(state, _redirectUrl) {
1151
+ async getAuthorizationUrl(state, _redirectUrl, loginContext = {}) {
898
1152
  // AD FS OAuth2 flow
899
1153
  const params = new URLSearchParams({
900
1154
  response_type: 'code',
901
- client_id: 'rag-pipeline-utils',
1155
+ client_id: this._config.clientId || 'rag-pipeline-utils',
902
1156
  resource: this._config.url,
903
- redirect_uri: _redirectUrl,
1157
+ redirect_uri:
1158
+ this._config.redirectUri || loginContext.redirectUri || _redirectUrl,
904
1159
  state,
905
1160
  });
1161
+ if (loginContext.codeChallenge) {
1162
+ params.set('code_challenge', loginContext.codeChallenge);
1163
+ params.set('code_challenge_method', 'S256');
1164
+ }
906
1165
 
907
1166
  return `${this._config.url}/oauth2/authorize?${params.toString()}`;
908
1167
  }
909
1168
 
910
1169
  async exchangeCodeForTokens(callbackData) {
911
- if (process.env.NODE_ENV === 'production') {
1170
+ assertCallbackData(callbackData);
1171
+ if (!isDevBypassActive(this._config)) {
912
1172
  throw new Error(
913
- 'Production Active Directory integration required - please configure AD FS or Azure AD',
1173
+ 'Production Active Directory integration required - please configure AD FS or Azure AD (use the OIDC provider)',
914
1174
  );
915
1175
  }
916
1176
 
917
- // Development only
1177
+ // Development bypass only
918
1178
  if (!callbackData.code) {
919
1179
  throw new Error('Authorization code is required');
920
1180
  }
@@ -926,14 +1186,14 @@ class ActiveDirectoryProvider {
926
1186
  };
927
1187
  }
928
1188
 
929
- async getUserInfo(accessToken) {
930
- if (process.env.NODE_ENV === 'production') {
1189
+ async getUserInfo(_accessToken) {
1190
+ if (!isDevBypassActive(this._config)) {
931
1191
  throw new Error(
932
- 'Production Active Directory user lookup required - please configure AD integration',
1192
+ 'Production Active Directory user lookup required - please configure AD integration (use the OIDC provider)',
933
1193
  );
934
1194
  }
935
1195
 
936
- // Development only
1196
+ // Development bypass only
937
1197
  return {
938
1198
  id: 'dev-' + crypto.randomUUID(),
939
1199
  email: 'dev-user@corp.com',
@@ -945,79 +1205,306 @@ class ActiveDirectoryProvider {
945
1205
  }
946
1206
 
947
1207
  class OIDCProvider {
948
- constructor(_config) {
1208
+ /**
1209
+ * OpenID Connect provider (authorization code + PKCE, validated id_token).
1210
+ *
1211
+ * Endpoints and keys come from discovery
1212
+ * (`<issuer>/.well-known/openid-configuration`) unless configured:
1213
+ * authorizationUrl, tokenUrl, userInfoUrl, jwksUri, or `jwks` (a JWK Set
1214
+ * `{ keys: [...] }` or an array of JWKs) to avoid fetching keys.
1215
+ *
1216
+ * The id_token must be signed by a key from the provider JWKS (selected
1217
+ * by `kid`) with an asymmetric algorithm (`idTokenAlgorithms`, default
1218
+ * RS/PS/ES 256-512), and its iss, aud (clientId), exp and nonce are
1219
+ * checked.
1220
+ *
1221
+ * @param {Object} _config - issuer, clientId, clientSecret, redirectUri,
1222
+ * scopes, jwks, jwksUri, authorizationUrl, tokenUrl, userInfoUrl,
1223
+ * idTokenAlgorithms, clockTolerance (seconds, default 60), devBypass
1224
+ */
1225
+ constructor(_config = {}) {
949
1226
  this._config = _config;
1227
+ this._metadata = null;
1228
+ this._jwks = null;
1229
+ checkDevBypass('OIDCProvider', this._config);
950
1230
  }
951
1231
 
952
- async getAuthorizationUrl(state, _redirectUrl) {
1232
+ /**
1233
+ * Resolve provider metadata (configured values override discovery).
1234
+ * @returns {Promise<Object>}
1235
+ */
1236
+ async getMetadata() {
1237
+ if (this._metadata) return this._metadata;
1238
+
1239
+ const configured = {
1240
+ issuer: this._config.issuer,
1241
+ authorization_endpoint: this._config.authorizationUrl,
1242
+ token_endpoint: this._config.tokenUrl,
1243
+ userinfo_endpoint: this._config.userInfoUrl,
1244
+ jwks_uri: this._config.jwksUri,
1245
+ };
1246
+
1247
+ const needsDiscovery =
1248
+ !configured.authorization_endpoint ||
1249
+ !configured.token_endpoint ||
1250
+ (!configured.jwks_uri && !this._config.jwks);
1251
+
1252
+ let discovered = {};
1253
+ if (needsDiscovery) {
1254
+ if (!this._config.issuer) {
1255
+ throw new Error('OIDC issuer must be configured');
1256
+ }
1257
+ const url = `${this._config.issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`;
1258
+ const response = await fetch(url, {
1259
+ headers: { Accept: 'application/json' },
1260
+ });
1261
+ if (!response.ok) {
1262
+ throw new Error(`OIDC discovery failed: ${response.status}`);
1263
+ }
1264
+ discovered = await response.json();
1265
+ if (discovered.issuer !== this._config.issuer) {
1266
+ throw new Error(
1267
+ `OIDC discovery issuer mismatch: expected ${this._config.issuer}, got ${discovered.issuer}`,
1268
+ );
1269
+ }
1270
+ }
1271
+
1272
+ const metadata = { ...discovered };
1273
+ for (const [key, value] of Object.entries(configured)) {
1274
+ if (value) metadata[key] = value;
1275
+ }
1276
+ this._metadata = metadata;
1277
+ return metadata;
1278
+ }
1279
+
1280
+ async getAuthorizationUrl(state, _redirectUrl, loginContext = {}) {
1281
+ const metadata = await this.getMetadata();
953
1282
  const params = new URLSearchParams({
954
1283
  response_type: 'code',
955
1284
  client_id: this._config.clientId,
956
- redirect_uri: _redirectUrl,
957
- scope: 'openid profile email',
1285
+ redirect_uri:
1286
+ this._config.redirectUri || loginContext.redirectUri || _redirectUrl,
1287
+ scope: (this._config.scopes || ['openid', 'profile', 'email']).join(' '),
958
1288
  state,
959
1289
  });
1290
+ if (loginContext.nonce) {
1291
+ params.set('nonce', loginContext.nonce);
1292
+ }
1293
+ if (loginContext.codeChallenge) {
1294
+ params.set('code_challenge', loginContext.codeChallenge);
1295
+ params.set('code_challenge_method', 'S256');
1296
+ }
960
1297
 
961
- return `${this._config.issuer}/auth?${params.toString()}`;
1298
+ return `${metadata.authorization_endpoint}?${params.toString()}`;
962
1299
  }
963
1300
 
964
- async exchangeCodeForTokens(callbackData) {
965
- if (process.env.NODE_ENV === 'production') {
966
- // Production OIDC implementation
967
- const tokenRequest = {
968
- method: 'POST',
969
- headers: {
970
- 'Content-Type': 'application/x-www-form-urlencoded',
971
- Authorization: `Basic ${Buffer.from(`${this._config.clientId}:${this._config.clientSecret}`).toString('base64')}`,
972
- },
973
- body: new URLSearchParams({
974
- grant_type: 'authorization_code',
975
- code: callbackData.code,
976
- redirect_uri: callbackData.redirect_uri,
977
- }),
978
- };
1301
+ /**
1302
+ * Exchange the code and validate the returned id_token.
1303
+ * @param {Object} callbackData - { code, state }
1304
+ * @param {Object} [loginContext] - { codeVerifier, nonce, redirectUri }
1305
+ */
1306
+ async exchangeCodeForTokens(callbackData, loginContext = {}) {
1307
+ assertCallbackData(callbackData);
979
1308
 
980
- try {
981
- const response = await fetch(
982
- `${this._config.issuer}/token`,
983
- tokenRequest,
984
- );
1309
+ if (isDevBypassActive(this._config)) {
1310
+ if (!callbackData.code) {
1311
+ throw new Error('Authorization code is required');
1312
+ }
1313
+ return {
1314
+ _accessToken: crypto.randomBytes(32).toString('hex'),
1315
+ refreshToken: crypto.randomBytes(32).toString('hex'),
1316
+ idToken: this._generateMockIdToken(),
1317
+ expiresAt: Date.now() + 3600 * 1000,
1318
+ };
1319
+ }
985
1320
 
986
- if (!response.ok) {
987
- throw new Error(`OIDC token exchange failed: ${response.status}`);
988
- }
1321
+ const redirectUri = requireExchangeInputs(
1322
+ 'OIDCProvider',
1323
+ this._config,
1324
+ callbackData,
1325
+ loginContext,
1326
+ );
1327
+ if (!loginContext.nonce) {
1328
+ throw new Error(
1329
+ 'OIDCProvider: nonce missing; use SSOManager.initiateLogin()/handleCallback()',
1330
+ );
1331
+ }
989
1332
 
990
- const tokenData = await response.json();
1333
+ let tokenData;
1334
+ try {
1335
+ const metadata = await this.getMetadata();
1336
+ const response = await fetch(
1337
+ metadata.token_endpoint,
1338
+ buildCodeExchangeRequest(
1339
+ this._config,
1340
+ callbackData.code,
1341
+ redirectUri,
1342
+ loginContext.codeVerifier,
1343
+ ),
1344
+ );
991
1345
 
992
- return {
993
- _accessToken: tokenData.access_token,
994
- refreshToken: tokenData.refresh_token,
995
- idToken: tokenData.id_token,
996
- expiresAt: Date.now() + tokenData.expires_in * 1000,
997
- };
998
- } catch (error) {
999
- throw new Error(`OIDC token exchange failed: ${error.message}`);
1346
+ if (!response.ok) {
1347
+ throw new Error(`OIDC token exchange failed: ${response.status}`);
1000
1348
  }
1349
+
1350
+ tokenData = await response.json();
1351
+ } catch (error) {
1352
+ throw new Error(`OIDC token exchange failed: ${error.message}`);
1001
1353
  }
1002
1354
 
1003
- // Development only
1004
- if (!callbackData.code) {
1005
- throw new Error('Authorization code is required');
1355
+ if (!tokenData.id_token) {
1356
+ throw new Error('OIDC token response did not include an id_token');
1006
1357
  }
1358
+ const claims = await this.validateIdToken(tokenData.id_token, {
1359
+ nonce: loginContext.nonce,
1360
+ });
1007
1361
 
1008
1362
  return {
1009
- _accessToken: crypto.randomBytes(32).toString('hex'),
1010
- refreshToken: crypto.randomBytes(32).toString('hex'),
1011
- idToken: this._generateMockIdToken(),
1012
- expiresAt: Date.now() + 3600 * 1000,
1363
+ _accessToken: tokenData.access_token,
1364
+ refreshToken: tokenData.refresh_token,
1365
+ idToken: tokenData.id_token,
1366
+ claims,
1367
+ expiresAt: Date.now() + tokenData.expires_in * 1000,
1013
1368
  };
1014
1369
  }
1015
1370
 
1016
- async getUserInfo(accessToken) {
1017
- if (process.env.NODE_ENV === 'production') {
1018
- // Production OIDC userinfo
1019
- try {
1020
- const userResponse = await fetch(`${this._config.issuer}/userinfo`, {
1371
+ /**
1372
+ * Validate an id_token: signature (provider JWKS), iss, aud, exp, nonce.
1373
+ * @param {string} idToken - Compact JWS
1374
+ * @param {Object} options - { nonce } expected nonce (required)
1375
+ * @returns {Promise<Object>} Verified claims
1376
+ */
1377
+ async validateIdToken(idToken, { nonce } = {}) {
1378
+ try {
1379
+ if (!nonce) {
1380
+ throw new Error('expected nonce is required');
1381
+ }
1382
+ // jsonwebtoken silently skips iss/aud checks for falsy values
1383
+ if (!this._config.issuer) {
1384
+ throw new Error('issuer must be configured');
1385
+ }
1386
+ if (!this._config.clientId) {
1387
+ throw new Error('clientId must be configured');
1388
+ }
1389
+ const decoded = jwt.decode(idToken, { complete: true });
1390
+ if (!decoded || !decoded.header || !decoded.payload) {
1391
+ throw new Error('malformed id_token');
1392
+ }
1393
+ const { header, payload } = decoded;
1394
+ const allowed =
1395
+ this._config.idTokenAlgorithms || DEFAULT_ID_TOKEN_ALGORITHMS;
1396
+ if (!allowed.includes(header.alg)) {
1397
+ throw new Error(`algorithm ${header.alg} is not allowed`);
1398
+ }
1399
+
1400
+ const jwk = await this._selectJwk(header);
1401
+ if (jwk.alg && jwk.alg !== header.alg) {
1402
+ throw new Error('JWK algorithm does not match the token');
1403
+ }
1404
+ const key = crypto.createPublicKey({ key: jwk, format: 'jwk' });
1405
+
1406
+ if (typeof payload.exp !== 'number') {
1407
+ throw new Error('id_token has no exp claim');
1408
+ }
1409
+ if (!payload.sub) {
1410
+ throw new Error('id_token has no sub claim');
1411
+ }
1412
+
1413
+ const claims = jwt.verify(idToken, key, {
1414
+ algorithms: [header.alg],
1415
+ issuer: this._config.issuer,
1416
+ audience: this._config.clientId,
1417
+ nonce,
1418
+ clockTolerance: this._config.clockTolerance ?? 60,
1419
+ });
1420
+
1421
+ if (
1422
+ Array.isArray(claims.aud) &&
1423
+ claims.aud.length > 1 &&
1424
+ claims.azp !== this._config.clientId
1425
+ ) {
1426
+ throw new Error('azp does not match client_id');
1427
+ }
1428
+ return claims;
1429
+ } catch (error) {
1430
+ throw new Error(`OIDC id_token validation failed: ${error.message}`);
1431
+ }
1432
+ }
1433
+
1434
+ /**
1435
+ * Pick the verification key for a token header from the JWKS.
1436
+ * Refetches the JWKS once when the kid is unknown (key rotation).
1437
+ * @private
1438
+ */
1439
+ async _selectJwk(header) {
1440
+ const find = (keys) => {
1441
+ const candidates = keys.filter(
1442
+ (k) => (!k.use || k.use === 'sig') && k.kty !== 'oct',
1443
+ );
1444
+ if (header.kid) {
1445
+ return candidates.find((k) => k.kid === header.kid);
1446
+ }
1447
+ return candidates.length === 1 ? candidates[0] : undefined;
1448
+ };
1449
+
1450
+ let jwk = find(await this._getJwks(false));
1451
+ if (!jwk && !this._config.jwks) {
1452
+ jwk = find(await this._getJwks(true));
1453
+ }
1454
+ if (!jwk) {
1455
+ throw new Error(
1456
+ header.kid
1457
+ ? `no signing key found for kid ${header.kid}`
1458
+ : 'token has no kid and the JWKS does not have exactly one signing key',
1459
+ );
1460
+ }
1461
+ return jwk;
1462
+ }
1463
+
1464
+ /** @private */
1465
+ async _getJwks(forceRefresh) {
1466
+ if (this._config.jwks) {
1467
+ const configured = this._config.jwks;
1468
+ return Array.isArray(configured) ? configured : configured.keys || [];
1469
+ }
1470
+ if (this._jwks && !forceRefresh) return this._jwks;
1471
+
1472
+ const metadata = await this.getMetadata();
1473
+ if (!metadata.jwks_uri) {
1474
+ throw new Error('provider metadata has no jwks_uri');
1475
+ }
1476
+ const response = await fetch(metadata.jwks_uri, {
1477
+ headers: { Accept: 'application/json' },
1478
+ });
1479
+ if (!response.ok) {
1480
+ throw new Error(`JWKS request failed: ${response.status}`);
1481
+ }
1482
+ const body = await response.json();
1483
+ this._jwks = Array.isArray(body.keys) ? body.keys : [];
1484
+ return this._jwks;
1485
+ }
1486
+
1487
+ /**
1488
+ * @param {string} accessToken - Access token for the userinfo endpoint
1489
+ * @param {Object} [tokenData] - Result of exchangeCodeForTokens; when it
1490
+ * carries validated id_token claims, the userinfo `sub` must match.
1491
+ */
1492
+ async getUserInfo(accessToken, tokenData = {}) {
1493
+ if (isDevBypassActive(this._config)) {
1494
+ return {
1495
+ id: 'dev-' + crypto.randomUUID(),
1496
+ email: 'dev-user@oidc.com',
1497
+ name: 'Development OIDC User',
1498
+ roles: ['user', 'dev'],
1499
+ };
1500
+ }
1501
+
1502
+ const claims = tokenData.claims;
1503
+ try {
1504
+ const metadata = await this.getMetadata();
1505
+ let userData;
1506
+ if (metadata.userinfo_endpoint) {
1507
+ const userResponse = await fetch(metadata.userinfo_endpoint, {
1021
1508
  headers: {
1022
1509
  Authorization: `Bearer ${accessToken}`,
1023
1510
  Accept: 'application/json',
@@ -1030,28 +1517,27 @@ class OIDCProvider {
1030
1517
  );
1031
1518
  }
1032
1519
 
1033
- const userData = await userResponse.json();
1034
-
1035
- return {
1036
- id: userData.sub,
1037
- email: userData.email,
1038
- name: userData.name || userData.preferred_username,
1039
- roles: userData.roles || [],
1040
- groups: userData.groups || [],
1041
- attributes: userData,
1042
- };
1043
- } catch (error) {
1044
- throw new Error(`OIDC getUserInfo failed: ${error.message}`);
1520
+ userData = await userResponse.json();
1521
+ if (claims && userData.sub !== claims.sub) {
1522
+ throw new Error('userinfo sub does not match the id_token sub');
1523
+ }
1524
+ } else if (claims) {
1525
+ userData = claims;
1526
+ } else {
1527
+ throw new Error('no userinfo endpoint and no validated id_token');
1045
1528
  }
1046
- }
1047
1529
 
1048
- // Development only
1049
- return {
1050
- id: 'dev-' + crypto.randomUUID(),
1051
- email: 'dev-user@oidc.com',
1052
- name: 'Development OIDC User',
1053
- roles: ['user', 'dev'],
1054
- };
1530
+ return {
1531
+ id: userData.sub,
1532
+ email: userData.email,
1533
+ name: userData.name || userData.preferred_username,
1534
+ roles: userData.roles || [],
1535
+ groups: userData.groups || [],
1536
+ attributes: userData,
1537
+ };
1538
+ } catch (error) {
1539
+ throw new Error(`OIDC getUserInfo failed: ${error.message}`);
1540
+ }
1055
1541
  }
1056
1542
 
1057
1543
  _generateMockIdToken() {
@@ -1059,7 +1545,7 @@ class OIDCProvider {
1059
1545
  throw new Error('Mock ID token generation not allowed in production');
1060
1546
  }
1061
1547
 
1062
- // Development only - basic JWT structure
1548
+ // Development bypass only - basic JWT structure, NOT a verifiable token
1063
1549
  const header = Buffer.from(
1064
1550
  JSON.stringify({ alg: 'HS256', typ: 'JWT' }),
1065
1551
  ).toString('base64url');
@@ -1089,4 +1575,5 @@ module.exports = {
1089
1575
  OAuth2Provider,
1090
1576
  ActiveDirectoryProvider,
1091
1577
  OIDCProvider,
1578
+ createPkcePair,
1092
1579
  };