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