tlc-claude-code 1.3.0 → 1.4.1

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 (105) hide show
  1. package/dashboard/dist/components/AuditPane.d.ts +30 -0
  2. package/dashboard/dist/components/AuditPane.js +127 -0
  3. package/dashboard/dist/components/AuditPane.test.d.ts +1 -0
  4. package/dashboard/dist/components/AuditPane.test.js +339 -0
  5. package/dashboard/dist/components/CompliancePane.d.ts +39 -0
  6. package/dashboard/dist/components/CompliancePane.js +96 -0
  7. package/dashboard/dist/components/CompliancePane.test.d.ts +1 -0
  8. package/dashboard/dist/components/CompliancePane.test.js +183 -0
  9. package/dashboard/dist/components/SSOPane.d.ts +36 -0
  10. package/dashboard/dist/components/SSOPane.js +71 -0
  11. package/dashboard/dist/components/SSOPane.test.d.ts +1 -0
  12. package/dashboard/dist/components/SSOPane.test.js +155 -0
  13. package/dashboard/dist/components/WorkspaceDocsPane.js +0 -16
  14. package/dashboard/dist/components/WorkspacePane.d.ts +1 -1
  15. package/dashboard/dist/components/ZeroRetentionPane.d.ts +44 -0
  16. package/dashboard/dist/components/ZeroRetentionPane.js +83 -0
  17. package/dashboard/dist/components/ZeroRetentionPane.test.d.ts +1 -0
  18. package/dashboard/dist/components/ZeroRetentionPane.test.js +160 -0
  19. package/package.json +1 -1
  20. package/server/lib/access-control-doc.js +541 -0
  21. package/server/lib/access-control-doc.test.js +672 -0
  22. package/server/lib/adr-generator.js +423 -0
  23. package/server/lib/adr-generator.test.js +586 -0
  24. package/server/lib/agent-progress-monitor.js +223 -0
  25. package/server/lib/agent-progress-monitor.test.js +202 -0
  26. package/server/lib/audit-attribution.js +191 -0
  27. package/server/lib/audit-attribution.test.js +359 -0
  28. package/server/lib/audit-classifier.js +202 -0
  29. package/server/lib/audit-classifier.test.js +209 -0
  30. package/server/lib/audit-command.js +275 -0
  31. package/server/lib/audit-command.test.js +325 -0
  32. package/server/lib/audit-exporter.js +380 -0
  33. package/server/lib/audit-exporter.test.js +464 -0
  34. package/server/lib/audit-logger.js +236 -0
  35. package/server/lib/audit-logger.test.js +364 -0
  36. package/server/lib/audit-query.js +257 -0
  37. package/server/lib/audit-query.test.js +352 -0
  38. package/server/lib/audit-storage.js +269 -0
  39. package/server/lib/audit-storage.test.js +272 -0
  40. package/server/lib/bulk-repo-init.js +342 -0
  41. package/server/lib/bulk-repo-init.test.js +388 -0
  42. package/server/lib/compliance-checklist.js +866 -0
  43. package/server/lib/compliance-checklist.test.js +476 -0
  44. package/server/lib/compliance-command.js +616 -0
  45. package/server/lib/compliance-command.test.js +551 -0
  46. package/server/lib/compliance-reporter.js +692 -0
  47. package/server/lib/compliance-reporter.test.js +707 -0
  48. package/server/lib/data-flow-doc.js +665 -0
  49. package/server/lib/data-flow-doc.test.js +659 -0
  50. package/server/lib/ephemeral-storage.js +249 -0
  51. package/server/lib/ephemeral-storage.test.js +254 -0
  52. package/server/lib/evidence-collector.js +627 -0
  53. package/server/lib/evidence-collector.test.js +901 -0
  54. package/server/lib/flow-diagram-generator.js +474 -0
  55. package/server/lib/flow-diagram-generator.test.js +446 -0
  56. package/server/lib/idp-manager.js +626 -0
  57. package/server/lib/idp-manager.test.js +587 -0
  58. package/server/lib/memory-exclusion.js +326 -0
  59. package/server/lib/memory-exclusion.test.js +241 -0
  60. package/server/lib/mfa-handler.js +452 -0
  61. package/server/lib/mfa-handler.test.js +490 -0
  62. package/server/lib/oauth-flow.js +375 -0
  63. package/server/lib/oauth-flow.test.js +487 -0
  64. package/server/lib/oauth-registry.js +190 -0
  65. package/server/lib/oauth-registry.test.js +306 -0
  66. package/server/lib/readme-generator.js +490 -0
  67. package/server/lib/readme-generator.test.js +493 -0
  68. package/server/lib/repo-dependency-tracker.js +261 -0
  69. package/server/lib/repo-dependency-tracker.test.js +350 -0
  70. package/server/lib/retention-policy.js +281 -0
  71. package/server/lib/retention-policy.test.js +486 -0
  72. package/server/lib/role-mapper.js +236 -0
  73. package/server/lib/role-mapper.test.js +395 -0
  74. package/server/lib/saml-provider.js +765 -0
  75. package/server/lib/saml-provider.test.js +643 -0
  76. package/server/lib/security-policy-generator.js +682 -0
  77. package/server/lib/security-policy-generator.test.js +544 -0
  78. package/server/lib/sensitive-detector.js +112 -0
  79. package/server/lib/sensitive-detector.test.js +209 -0
  80. package/server/lib/service-interaction-diagram.js +700 -0
  81. package/server/lib/service-interaction-diagram.test.js +638 -0
  82. package/server/lib/service-summary.js +553 -0
  83. package/server/lib/service-summary.test.js +619 -0
  84. package/server/lib/session-purge.js +460 -0
  85. package/server/lib/session-purge.test.js +312 -0
  86. package/server/lib/sso-command.js +544 -0
  87. package/server/lib/sso-command.test.js +552 -0
  88. package/server/lib/sso-session.js +492 -0
  89. package/server/lib/sso-session.test.js +670 -0
  90. package/server/lib/workspace-command.js +249 -0
  91. package/server/lib/workspace-command.test.js +264 -0
  92. package/server/lib/workspace-config.js +270 -0
  93. package/server/lib/workspace-config.test.js +312 -0
  94. package/server/lib/workspace-docs-command.js +547 -0
  95. package/server/lib/workspace-docs-command.test.js +692 -0
  96. package/server/lib/workspace-memory.js +451 -0
  97. package/server/lib/workspace-memory.test.js +403 -0
  98. package/server/lib/workspace-scanner.js +452 -0
  99. package/server/lib/workspace-scanner.test.js +677 -0
  100. package/server/lib/workspace-test-runner.js +315 -0
  101. package/server/lib/workspace-test-runner.test.js +294 -0
  102. package/server/lib/zero-retention-command.js +439 -0
  103. package/server/lib/zero-retention-command.test.js +448 -0
  104. package/server/lib/zero-retention.js +322 -0
  105. package/server/lib/zero-retention.test.js +258 -0
@@ -0,0 +1,765 @@
1
+ /**
2
+ * SAML Provider
3
+ * SAML 2.0 Service Provider implementation
4
+ */
5
+
6
+ const crypto = require('crypto');
7
+ const zlib = require('zlib');
8
+
9
+ /**
10
+ * SAML namespaces
11
+ */
12
+ const SAML_NAMESPACES = {
13
+ SAMLP: 'urn:oasis:names:tc:SAML:2.0:protocol',
14
+ SAML: 'urn:oasis:names:tc:SAML:2.0:assertion',
15
+ DS: 'http://www.w3.org/2000/09/xmldsig#',
16
+ MD: 'urn:oasis:names:tc:SAML:2.0:metadata',
17
+ };
18
+
19
+ /**
20
+ * SAML bindings
21
+ */
22
+ const SAML_BINDINGS = {
23
+ REDIRECT: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
24
+ POST: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
25
+ };
26
+
27
+ /**
28
+ * SAML status codes
29
+ */
30
+ const SAML_STATUS = {
31
+ SUCCESS: 'urn:oasis:names:tc:SAML:2.0:status:Success',
32
+ PARTIAL_LOGOUT: 'urn:oasis:names:tc:SAML:2.0:status:PartialLogout',
33
+ };
34
+
35
+ /**
36
+ * Generate unique SAML ID
37
+ * @returns {string} Unique ID prefixed with underscore
38
+ */
39
+ function generateId() {
40
+ return '_' + crypto.randomBytes(16).toString('hex');
41
+ }
42
+
43
+ /**
44
+ * Get current ISO timestamp
45
+ * @returns {string} ISO timestamp
46
+ */
47
+ function getISOTimestamp() {
48
+ return new Date().toISOString();
49
+ }
50
+
51
+ /**
52
+ * Simple XML parser - extracts elements and attributes
53
+ * @param {string} xml - XML string to parse
54
+ * @returns {Object} Parsed structure
55
+ */
56
+ function parseXML(xml) {
57
+ if (!xml || typeof xml !== 'string') {
58
+ throw new Error('Invalid XML input');
59
+ }
60
+
61
+ const trimmed = xml.trim();
62
+ if (!trimmed.startsWith('<?xml') && !trimmed.startsWith('<')) {
63
+ throw new Error('Invalid XML: does not start with XML declaration or element');
64
+ }
65
+
66
+ return {
67
+ raw: xml,
68
+
69
+ // Get attribute value from element
70
+ getAttribute(elementPattern, attrName) {
71
+ const regex = new RegExp(`<[^>]*${elementPattern}[^>]*${attrName}="([^"]*)"`, 'i');
72
+ const match = xml.match(regex);
73
+ return match ? match[1] : null;
74
+ },
75
+
76
+ // Get element content
77
+ getElementContent(elementName) {
78
+ // Handle namespaced elements
79
+ const patterns = [
80
+ new RegExp(`<(?:[a-z]+:)?${elementName}[^>]*>([^<]*)<\\/(?:[a-z]+:)?${elementName}>`, 'i'),
81
+ new RegExp(`<${elementName}[^>]*>([^<]*)<\\/${elementName}>`, 'i'),
82
+ ];
83
+
84
+ for (const regex of patterns) {
85
+ const match = xml.match(regex);
86
+ if (match) return match[1].trim();
87
+ }
88
+ return null;
89
+ },
90
+
91
+ // Get all elements with a name
92
+ getAllElements(elementName) {
93
+ const results = [];
94
+ const regex = new RegExp(`<(?:[a-z]+:)?${elementName}([^>]*)(?:\\/>|>([\\s\\S]*?)<\\/(?:[a-z]+:)?${elementName}>)`, 'gi');
95
+ let match;
96
+ while ((match = regex.exec(xml)) !== null) {
97
+ results.push({
98
+ attributes: match[1],
99
+ content: match[2] ? match[2].trim() : '',
100
+ });
101
+ }
102
+ return results;
103
+ },
104
+
105
+ // Check if element exists
106
+ hasElement(elementName) {
107
+ const regex = new RegExp(`<(?:[a-z]+:)?${elementName}[\\s>]`, 'i');
108
+ return regex.test(xml);
109
+ },
110
+
111
+ // Get attribute from specific element
112
+ getAttributeFromElement(elementName, attrName) {
113
+ const regex = new RegExp(`<(?:[a-z]+:)?${elementName}[^>]*${attrName}="([^"]*)"`, 'i');
114
+ const match = xml.match(regex);
115
+ return match ? match[1] : null;
116
+ },
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Parse SAML IdP metadata
122
+ * @param {string} metadata - XML metadata string
123
+ * @returns {Object} Parsed metadata
124
+ */
125
+ function parseMetadata(metadata) {
126
+ const doc = parseXML(metadata);
127
+
128
+ // Get entity ID
129
+ const entityId = doc.getAttribute('EntityDescriptor', 'entityID');
130
+ if (!entityId) {
131
+ throw new Error('Invalid metadata: missing entity ID');
132
+ }
133
+
134
+ // Get SSO URLs
135
+ const ssoUrls = {};
136
+ const ssoServices = doc.getAllElements('SingleSignOnService');
137
+ for (const sso of ssoServices) {
138
+ const binding = sso.attributes.match(/Binding="([^"]*)"/)?.[1];
139
+ const location = sso.attributes.match(/Location="([^"]*)"/)?.[1];
140
+
141
+ if (binding && location) {
142
+ if (binding.includes('Redirect')) {
143
+ ssoUrls.redirect = location;
144
+ } else if (binding.includes('POST')) {
145
+ ssoUrls.post = location;
146
+ }
147
+ }
148
+ }
149
+
150
+ // Get SLO URLs
151
+ const sloUrls = {};
152
+ const sloServices = doc.getAllElements('SingleLogoutService');
153
+ for (const slo of sloServices) {
154
+ const binding = slo.attributes.match(/Binding="([^"]*)"/)?.[1];
155
+ const location = slo.attributes.match(/Location="([^"]*)"/)?.[1];
156
+
157
+ if (binding && location) {
158
+ if (binding.includes('Redirect')) {
159
+ sloUrls.redirect = location;
160
+ } else if (binding.includes('POST')) {
161
+ sloUrls.post = location;
162
+ }
163
+ }
164
+ }
165
+
166
+ // Get signing certificate
167
+ let signingCert = null;
168
+ const certElements = doc.getAllElements('X509Certificate');
169
+ if (certElements.length > 0) {
170
+ signingCert = certElements[0].content.replace(/\s/g, '');
171
+ }
172
+
173
+ return {
174
+ entityId,
175
+ ssoUrls,
176
+ sloUrls,
177
+ signingCert,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Generate SAML AuthnRequest
183
+ * @param {Object} config - Request configuration
184
+ * @returns {Object} Request XML and metadata
185
+ */
186
+ function generateAuthnRequest(config) {
187
+ const {
188
+ issuer,
189
+ callbackUrl,
190
+ destination,
191
+ binding = 'redirect',
192
+ nameIdFormat = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
193
+ } = config;
194
+
195
+ if (!issuer) {
196
+ throw new Error('Missing required configuration: issuer');
197
+ }
198
+
199
+ if (!callbackUrl) {
200
+ throw new Error('Missing required configuration: callbackUrl');
201
+ }
202
+
203
+ const id = generateId();
204
+ const issueInstant = getISOTimestamp();
205
+
206
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
207
+ <samlp:AuthnRequest xmlns:samlp="${SAML_NAMESPACES.SAMLP}"
208
+ xmlns:saml="${SAML_NAMESPACES.SAML}"
209
+ ID="${id}" Version="2.0" IssueInstant="${issueInstant}"
210
+ ${destination ? `Destination="${destination}"` : ''}
211
+ AssertionConsumerServiceURL="${callbackUrl}"
212
+ ProtocolBinding="${SAML_BINDINGS.POST}">
213
+ <saml:Issuer>${issuer}</saml:Issuer>
214
+ <samlp:NameIDPolicy Format="${nameIdFormat}" AllowCreate="true"/>
215
+ </samlp:AuthnRequest>`;
216
+
217
+ let encoded;
218
+ if (binding === 'redirect') {
219
+ // Deflate and URL-safe base64 encode
220
+ const deflated = zlib.deflateRawSync(xml);
221
+ encoded = deflated.toString('base64')
222
+ .replace(/\+/g, '-')
223
+ .replace(/\//g, '_')
224
+ .replace(/=+$/, '');
225
+ } else {
226
+ // POST binding - just base64 encode
227
+ encoded = Buffer.from(xml).toString('base64');
228
+ }
229
+
230
+ return {
231
+ id,
232
+ xml,
233
+ encoded,
234
+ issueInstant,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * Validate SAML Response
240
+ * @param {string} response - SAML Response XML
241
+ * @param {Object} config - Validation configuration
242
+ * @returns {Object} Validation result
243
+ */
244
+ function validateResponse(response, config = {}) {
245
+ const {
246
+ issuer,
247
+ idpCert,
248
+ expectedInResponseTo,
249
+ expectedIssuer,
250
+ skipSignatureValidation = false,
251
+ } = config;
252
+
253
+ const errors = [];
254
+ let doc;
255
+
256
+ try {
257
+ doc = parseXML(response);
258
+ } catch (err) {
259
+ throw new Error(`Invalid SAML Response XML: ${err.message}`);
260
+ }
261
+
262
+ // Check for Assertion
263
+ if (!doc.hasElement('Assertion')) {
264
+ errors.push('Missing Assertion element');
265
+ }
266
+
267
+ // Check InResponseTo
268
+ if (expectedInResponseTo) {
269
+ const inResponseTo = doc.getAttribute('Response', 'InResponseTo') ||
270
+ doc.getAttributeFromElement('SubjectConfirmationData', 'InResponseTo');
271
+ if (inResponseTo && inResponseTo !== expectedInResponseTo) {
272
+ errors.push(`InResponseTo mismatch: expected ${expectedInResponseTo}, got ${inResponseTo}`);
273
+ }
274
+ }
275
+
276
+ // Check Issuer
277
+ if (expectedIssuer) {
278
+ const responseIssuer = doc.getElementContent('Issuer');
279
+ if (responseIssuer && responseIssuer !== expectedIssuer) {
280
+ errors.push(`Issuer mismatch: expected ${expectedIssuer}, got ${responseIssuer}`);
281
+ }
282
+ }
283
+
284
+ // Check timing conditions
285
+ const notOnOrAfter = doc.getAttributeFromElement('Conditions', 'NotOnOrAfter') ||
286
+ doc.getAttributeFromElement('SubjectConfirmationData', 'NotOnOrAfter');
287
+ if (notOnOrAfter) {
288
+ const expiry = new Date(notOnOrAfter);
289
+ if (expiry < new Date()) {
290
+ errors.push(`Assertion expired: NotOnOrAfter ${notOnOrAfter}`);
291
+ }
292
+ }
293
+
294
+ const notBefore = doc.getAttributeFromElement('Conditions', 'NotBefore');
295
+ if (notBefore) {
296
+ const start = new Date(notBefore);
297
+ if (start > new Date()) {
298
+ errors.push(`Assertion not yet valid: NotBefore ${notBefore}`);
299
+ }
300
+ }
301
+
302
+ // Signature validation would go here
303
+ // For now, we skip it if requested (for testing)
304
+ if (!skipSignatureValidation && idpCert) {
305
+ // In production, this would verify the XML signature
306
+ // Using the IdP certificate
307
+ // This is a simplified placeholder
308
+ }
309
+
310
+ return {
311
+ valid: errors.length === 0,
312
+ errors,
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Extract user attributes from SAML Response
318
+ * @param {string} response - SAML Response XML
319
+ * @param {Object} options - Extraction options
320
+ * @returns {Object} User attributes
321
+ */
322
+ function extractAttributes(response, options = {}) {
323
+ if (!response) {
324
+ return {};
325
+ }
326
+
327
+ const { attributeMapping = {} } = options;
328
+ const attrs = {};
329
+
330
+ let doc;
331
+ try {
332
+ doc = parseXML(response);
333
+ } catch {
334
+ return {};
335
+ }
336
+
337
+ // Get NameID
338
+ const nameId = doc.getElementContent('NameID');
339
+ if (nameId) {
340
+ attrs.nameId = nameId;
341
+ }
342
+
343
+ // Get Attributes using a more robust regex approach
344
+ // Match Attribute elements with their Name and nested AttributeValue
345
+ const attrRegex = /<(?:saml:)?Attribute\s+Name="([^"]*)"[^>]*>([\s\S]*?)<\/(?:saml:)?Attribute>/gi;
346
+ let attrMatch;
347
+
348
+ while ((attrMatch = attrRegex.exec(response)) !== null) {
349
+ const name = attrMatch[1];
350
+ const content = attrMatch[2];
351
+
352
+ // Extract the value from AttributeValue element
353
+ const valueMatch = content.match(/<(?:saml:)?AttributeValue[^>]*>([^<]*)<\/(?:saml:)?AttributeValue>/i);
354
+ const value = valueMatch ? valueMatch[1].trim() : null;
355
+
356
+ if (value !== null) {
357
+ // Check for mapping
358
+ const mappedName = attributeMapping[name] || name;
359
+ attrs[mappedName] = value;
360
+ }
361
+ }
362
+
363
+ return attrs;
364
+ }
365
+
366
+ /**
367
+ * Handle SAML Logout Request
368
+ * @param {string} logoutRequest - SAML LogoutRequest XML
369
+ * @returns {Object} Parsed logout request
370
+ */
371
+ function handleLogout(logoutRequest) {
372
+ let doc;
373
+ try {
374
+ doc = parseXML(logoutRequest);
375
+ } catch (err) {
376
+ throw new Error(`Invalid SAML LogoutRequest: ${err.message}`);
377
+ }
378
+
379
+ const sessionIndex = doc.getElementContent('SessionIndex');
380
+
381
+ return {
382
+ requestId: doc.getAttribute('LogoutRequest', 'ID'),
383
+ nameId: doc.getElementContent('NameID'),
384
+ sessionIndex: sessionIndex || undefined, // Return undefined instead of null when missing
385
+ issuer: doc.getElementContent('Issuer'),
386
+ };
387
+ }
388
+
389
+ /**
390
+ * Generate SAML Logout Response
391
+ * @param {Object} config - Response configuration
392
+ * @returns {Object} Response XML and metadata
393
+ */
394
+ function generateLogoutResponse(config) {
395
+ const {
396
+ inResponseTo,
397
+ issuer,
398
+ destination,
399
+ status = 'Success',
400
+ binding = 'post',
401
+ } = config;
402
+
403
+ const id = generateId();
404
+ const issueInstant = getISOTimestamp();
405
+
406
+ const statusCode = status === 'PartialLogout'
407
+ ? SAML_STATUS.PARTIAL_LOGOUT
408
+ : SAML_STATUS.SUCCESS;
409
+
410
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
411
+ <samlp:LogoutResponse xmlns:samlp="${SAML_NAMESPACES.SAMLP}"
412
+ xmlns:saml="${SAML_NAMESPACES.SAML}"
413
+ ID="${id}" Version="2.0" IssueInstant="${issueInstant}"
414
+ Destination="${destination}"
415
+ InResponseTo="${inResponseTo}">
416
+ <saml:Issuer>${issuer}</saml:Issuer>
417
+ <samlp:Status>
418
+ <samlp:StatusCode Value="${statusCode}"/>
419
+ </samlp:Status>
420
+ </samlp:LogoutResponse>`;
421
+
422
+ let encoded;
423
+ if (binding === 'redirect') {
424
+ const deflated = zlib.deflateRawSync(xml);
425
+ encoded = deflated.toString('base64')
426
+ .replace(/\+/g, '-')
427
+ .replace(/\//g, '_')
428
+ .replace(/=+$/, '');
429
+ } else {
430
+ encoded = Buffer.from(xml).toString('base64');
431
+ }
432
+
433
+ return {
434
+ id,
435
+ xml,
436
+ encoded,
437
+ issueInstant,
438
+ };
439
+ }
440
+
441
+ /**
442
+ * Create SAML Service Provider
443
+ * @param {Object} spConfig - SP configuration
444
+ * @returns {Object} SAML Provider instance
445
+ */
446
+ function createSAMLProvider(spConfig) {
447
+ const {
448
+ entityId,
449
+ callbackUrl,
450
+ logoutUrl,
451
+ privateKey,
452
+ certificate,
453
+ } = spConfig;
454
+
455
+ const idps = new Map();
456
+ const pendingRequests = new Map();
457
+
458
+ return {
459
+ /**
460
+ * Register an IdP
461
+ * @param {string} id - IdP identifier
462
+ * @param {Object} config - IdP configuration
463
+ */
464
+ registerIdP(id, config) {
465
+ idps.set(id, {
466
+ ...config,
467
+ id,
468
+ });
469
+ },
470
+
471
+ /**
472
+ * Register IdP from metadata
473
+ * @param {string} id - IdP identifier
474
+ * @param {string} metadata - XML metadata
475
+ */
476
+ registerIdPFromMetadata(id, metadata) {
477
+ const parsed = parseMetadata(metadata);
478
+ idps.set(id, {
479
+ id,
480
+ entityId: parsed.entityId,
481
+ ssoUrl: parsed.ssoUrls.redirect || parsed.ssoUrls.post,
482
+ sloUrl: parsed.sloUrls?.redirect || parsed.sloUrls?.post,
483
+ cert: parsed.signingCert,
484
+ ssoUrls: parsed.ssoUrls,
485
+ sloUrls: parsed.sloUrls,
486
+ });
487
+ },
488
+
489
+ /**
490
+ * Get IdP by ID
491
+ * @param {string} id - IdP identifier
492
+ * @returns {Object|undefined} IdP configuration
493
+ */
494
+ getIdP(id) {
495
+ return idps.get(id);
496
+ },
497
+
498
+ /**
499
+ * List registered IdPs
500
+ * @returns {string[]} IdP identifiers
501
+ */
502
+ listIdPs() {
503
+ return Array.from(idps.keys());
504
+ },
505
+
506
+ /**
507
+ * Remove IdP
508
+ * @param {string} id - IdP identifier
509
+ */
510
+ removeIdP(id) {
511
+ idps.delete(id);
512
+ },
513
+
514
+ /**
515
+ * Create login request for IdP
516
+ * @param {string} idpId - IdP identifier
517
+ * @param {Object} options - Request options
518
+ * @returns {Object} Request data
519
+ */
520
+ createLoginRequest(idpId, options = {}) {
521
+ const idp = idps.get(idpId);
522
+ if (!idp) {
523
+ throw new Error(`Unknown IdP: ${idpId}`);
524
+ }
525
+
526
+ const { binding = 'redirect', relayState } = options;
527
+
528
+ const request = generateAuthnRequest({
529
+ issuer: entityId,
530
+ callbackUrl,
531
+ destination: idp.ssoUrl,
532
+ binding,
533
+ });
534
+
535
+ // Store pending request for validation
536
+ pendingRequests.set(request.id, {
537
+ idpId,
538
+ createdAt: new Date(),
539
+ });
540
+
541
+ // Build URL
542
+ let url = idp.ssoUrl;
543
+ if (binding === 'redirect') {
544
+ const params = new URLSearchParams();
545
+ params.set('SAMLRequest', request.encoded);
546
+ if (relayState) {
547
+ params.set('RelayState', relayState);
548
+ }
549
+ url = `${idp.ssoUrl}?${params.toString()}`;
550
+ }
551
+
552
+ return {
553
+ id: request.id,
554
+ url,
555
+ xml: request.xml,
556
+ encoded: request.encoded,
557
+ binding,
558
+ };
559
+ },
560
+
561
+ /**
562
+ * Handle login response
563
+ * @param {string} samlResponse - Base64 encoded response
564
+ * @param {Object} options - Processing options
565
+ * @returns {Object} Result with user info
566
+ */
567
+ async handleLoginResponse(samlResponse, options = {}) {
568
+ const { idpId, expectedInResponseTo, relayState } = options;
569
+
570
+ // Decode response
571
+ let responseXml;
572
+ try {
573
+ responseXml = Buffer.from(samlResponse, 'base64').toString('utf8');
574
+ } catch {
575
+ return { success: false, error: 'Failed to decode SAML response' };
576
+ }
577
+
578
+ // Get IdP config
579
+ const idp = idpId ? idps.get(idpId) : null;
580
+
581
+ // Validate response
582
+ const validation = validateResponse(responseXml, {
583
+ issuer: entityId,
584
+ idpCert: idp?.cert,
585
+ expectedInResponseTo,
586
+ expectedIssuer: idp?.entityId,
587
+ skipSignatureValidation: idp?.skipSignatureValidation,
588
+ });
589
+
590
+ if (!validation.valid) {
591
+ return { success: false, errors: validation.errors };
592
+ }
593
+
594
+ // Extract user attributes
595
+ const user = extractAttributes(responseXml);
596
+
597
+ // Clean up pending request
598
+ if (expectedInResponseTo) {
599
+ pendingRequests.delete(expectedInResponseTo);
600
+ }
601
+
602
+ return {
603
+ success: true,
604
+ user,
605
+ relayState,
606
+ };
607
+ },
608
+
609
+ /**
610
+ * Handle logout request from IdP
611
+ * @param {string} samlRequest - SAML logout request
612
+ * @returns {Object} Logout info
613
+ */
614
+ handleLogoutRequest(samlRequest) {
615
+ let requestXml = samlRequest;
616
+
617
+ // Try to decode if base64
618
+ try {
619
+ const decoded = Buffer.from(samlRequest, 'base64').toString('utf8');
620
+ if (decoded.includes('LogoutRequest')) {
621
+ requestXml = decoded;
622
+ }
623
+ } catch {
624
+ // Use as-is
625
+ }
626
+
627
+ // Try to inflate if deflated
628
+ try {
629
+ const inflated = zlib.inflateRawSync(Buffer.from(samlRequest, 'base64')).toString('utf8');
630
+ if (inflated.includes('LogoutRequest')) {
631
+ requestXml = inflated;
632
+ }
633
+ } catch {
634
+ // Use as-is
635
+ }
636
+
637
+ return handleLogout(requestXml);
638
+ },
639
+
640
+ /**
641
+ * Create logout request
642
+ * @param {string} idpId - IdP identifier
643
+ * @param {Object} session - Session info
644
+ * @returns {Object} Logout request
645
+ */
646
+ createLogoutRequest(idpId, session) {
647
+ const idp = idps.get(idpId);
648
+ if (!idp) {
649
+ throw new Error(`Unknown IdP: ${idpId}`);
650
+ }
651
+
652
+ const id = generateId();
653
+ const issueInstant = getISOTimestamp();
654
+
655
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
656
+ <samlp:LogoutRequest xmlns:samlp="${SAML_NAMESPACES.SAMLP}"
657
+ xmlns:saml="${SAML_NAMESPACES.SAML}"
658
+ ID="${id}" Version="2.0" IssueInstant="${issueInstant}"
659
+ Destination="${idp.sloUrl || idp.ssoUrl}">
660
+ <saml:Issuer>${entityId}</saml:Issuer>
661
+ <saml:NameID>${session.nameId}</saml:NameID>
662
+ ${session.sessionIndex ? `<samlp:SessionIndex>${session.sessionIndex}</samlp:SessionIndex>` : ''}
663
+ </samlp:LogoutRequest>`;
664
+
665
+ const deflated = zlib.deflateRawSync(xml);
666
+ const encoded = deflated.toString('base64')
667
+ .replace(/\+/g, '-')
668
+ .replace(/\//g, '_')
669
+ .replace(/=+$/, '');
670
+
671
+ const params = new URLSearchParams();
672
+ params.set('SAMLRequest', encoded);
673
+
674
+ return {
675
+ id,
676
+ url: `${idp.sloUrl || idp.ssoUrl}?${params.toString()}`,
677
+ xml,
678
+ encoded,
679
+ };
680
+ },
681
+
682
+ /**
683
+ * Create logout response
684
+ * @param {string} idpId - IdP identifier
685
+ * @param {Object} options - Response options
686
+ * @returns {Object} Logout response
687
+ */
688
+ createLogoutResponse(idpId, options) {
689
+ const idp = idps.get(idpId);
690
+
691
+ return generateLogoutResponse({
692
+ inResponseTo: options.inResponseTo,
693
+ issuer: entityId,
694
+ destination: idp?.sloUrl || options.destination,
695
+ status: options.status || 'Success',
696
+ binding: options.binding || 'redirect',
697
+ });
698
+ },
699
+
700
+ /**
701
+ * Generate SP metadata
702
+ * @returns {string} XML metadata
703
+ */
704
+ generateMetadata() {
705
+ return `<?xml version="1.0" encoding="UTF-8"?>
706
+ <EntityDescriptor xmlns="${SAML_NAMESPACES.MD}"
707
+ entityID="${entityId}">
708
+ <SPSSODescriptor protocolSupportEnumeration="${SAML_NAMESPACES.SAMLP}"
709
+ AuthnRequestsSigned="false" WantAssertionsSigned="true">
710
+ <NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</NameIDFormat>
711
+ <AssertionConsumerService
712
+ Binding="${SAML_BINDINGS.POST}"
713
+ Location="${callbackUrl}"
714
+ index="0"
715
+ isDefault="true"/>
716
+ ${logoutUrl ? `<SingleLogoutService
717
+ Binding="${SAML_BINDINGS.REDIRECT}"
718
+ Location="${logoutUrl}"/>` : ''}
719
+ </SPSSODescriptor>
720
+ </EntityDescriptor>`;
721
+ },
722
+
723
+ /**
724
+ * Get pending request info
725
+ * @param {string} id - Request ID
726
+ * @returns {Object|undefined} Request info
727
+ */
728
+ getPendingRequest(id) {
729
+ return pendingRequests.get(id);
730
+ },
731
+
732
+ /**
733
+ * Clean expired pending requests
734
+ * @param {number} maxAgeMs - Max age in milliseconds
735
+ * @returns {number} Number of cleaned requests
736
+ */
737
+ cleanPendingRequests(maxAgeMs = 300000) {
738
+ const now = new Date();
739
+ let cleaned = 0;
740
+
741
+ for (const [id, request] of pendingRequests) {
742
+ if (now - request.createdAt > maxAgeMs) {
743
+ pendingRequests.delete(id);
744
+ cleaned++;
745
+ }
746
+ }
747
+
748
+ return cleaned;
749
+ },
750
+ };
751
+ }
752
+
753
+ module.exports = {
754
+ SAML_NAMESPACES,
755
+ SAML_BINDINGS,
756
+ SAML_STATUS,
757
+ generateId,
758
+ parseMetadata,
759
+ generateAuthnRequest,
760
+ validateResponse,
761
+ extractAttributes,
762
+ handleLogout,
763
+ generateLogoutResponse,
764
+ createSAMLProvider,
765
+ };