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.
- package/dashboard/dist/components/AuditPane.d.ts +30 -0
- package/dashboard/dist/components/AuditPane.js +127 -0
- package/dashboard/dist/components/AuditPane.test.d.ts +1 -0
- package/dashboard/dist/components/AuditPane.test.js +339 -0
- package/dashboard/dist/components/CompliancePane.d.ts +39 -0
- package/dashboard/dist/components/CompliancePane.js +96 -0
- package/dashboard/dist/components/CompliancePane.test.d.ts +1 -0
- package/dashboard/dist/components/CompliancePane.test.js +183 -0
- package/dashboard/dist/components/SSOPane.d.ts +36 -0
- package/dashboard/dist/components/SSOPane.js +71 -0
- package/dashboard/dist/components/SSOPane.test.d.ts +1 -0
- package/dashboard/dist/components/SSOPane.test.js +155 -0
- package/dashboard/dist/components/WorkspaceDocsPane.js +0 -16
- package/dashboard/dist/components/WorkspacePane.d.ts +1 -1
- package/dashboard/dist/components/ZeroRetentionPane.d.ts +44 -0
- package/dashboard/dist/components/ZeroRetentionPane.js +83 -0
- package/dashboard/dist/components/ZeroRetentionPane.test.d.ts +1 -0
- package/dashboard/dist/components/ZeroRetentionPane.test.js +160 -0
- package/package.json +1 -1
- package/server/lib/access-control-doc.js +541 -0
- package/server/lib/access-control-doc.test.js +672 -0
- package/server/lib/adr-generator.js +423 -0
- package/server/lib/adr-generator.test.js +586 -0
- package/server/lib/agent-progress-monitor.js +223 -0
- package/server/lib/agent-progress-monitor.test.js +202 -0
- package/server/lib/audit-attribution.js +191 -0
- package/server/lib/audit-attribution.test.js +359 -0
- package/server/lib/audit-classifier.js +202 -0
- package/server/lib/audit-classifier.test.js +209 -0
- package/server/lib/audit-command.js +275 -0
- package/server/lib/audit-command.test.js +325 -0
- package/server/lib/audit-exporter.js +380 -0
- package/server/lib/audit-exporter.test.js +464 -0
- package/server/lib/audit-logger.js +236 -0
- package/server/lib/audit-logger.test.js +364 -0
- package/server/lib/audit-query.js +257 -0
- package/server/lib/audit-query.test.js +352 -0
- package/server/lib/audit-storage.js +269 -0
- package/server/lib/audit-storage.test.js +272 -0
- package/server/lib/bulk-repo-init.js +342 -0
- package/server/lib/bulk-repo-init.test.js +388 -0
- package/server/lib/compliance-checklist.js +866 -0
- package/server/lib/compliance-checklist.test.js +476 -0
- package/server/lib/compliance-command.js +616 -0
- package/server/lib/compliance-command.test.js +551 -0
- package/server/lib/compliance-reporter.js +692 -0
- package/server/lib/compliance-reporter.test.js +707 -0
- package/server/lib/data-flow-doc.js +665 -0
- package/server/lib/data-flow-doc.test.js +659 -0
- package/server/lib/ephemeral-storage.js +249 -0
- package/server/lib/ephemeral-storage.test.js +254 -0
- package/server/lib/evidence-collector.js +627 -0
- package/server/lib/evidence-collector.test.js +901 -0
- package/server/lib/flow-diagram-generator.js +474 -0
- package/server/lib/flow-diagram-generator.test.js +446 -0
- package/server/lib/idp-manager.js +626 -0
- package/server/lib/idp-manager.test.js +587 -0
- package/server/lib/memory-exclusion.js +326 -0
- package/server/lib/memory-exclusion.test.js +241 -0
- package/server/lib/mfa-handler.js +452 -0
- package/server/lib/mfa-handler.test.js +490 -0
- package/server/lib/oauth-flow.js +375 -0
- package/server/lib/oauth-flow.test.js +487 -0
- package/server/lib/oauth-registry.js +190 -0
- package/server/lib/oauth-registry.test.js +306 -0
- package/server/lib/readme-generator.js +490 -0
- package/server/lib/readme-generator.test.js +493 -0
- package/server/lib/repo-dependency-tracker.js +261 -0
- package/server/lib/repo-dependency-tracker.test.js +350 -0
- package/server/lib/retention-policy.js +281 -0
- package/server/lib/retention-policy.test.js +486 -0
- package/server/lib/role-mapper.js +236 -0
- package/server/lib/role-mapper.test.js +395 -0
- package/server/lib/saml-provider.js +765 -0
- package/server/lib/saml-provider.test.js +643 -0
- package/server/lib/security-policy-generator.js +682 -0
- package/server/lib/security-policy-generator.test.js +544 -0
- package/server/lib/sensitive-detector.js +112 -0
- package/server/lib/sensitive-detector.test.js +209 -0
- package/server/lib/service-interaction-diagram.js +700 -0
- package/server/lib/service-interaction-diagram.test.js +638 -0
- package/server/lib/service-summary.js +553 -0
- package/server/lib/service-summary.test.js +619 -0
- package/server/lib/session-purge.js +460 -0
- package/server/lib/session-purge.test.js +312 -0
- package/server/lib/sso-command.js +544 -0
- package/server/lib/sso-command.test.js +552 -0
- package/server/lib/sso-session.js +492 -0
- package/server/lib/sso-session.test.js +670 -0
- package/server/lib/workspace-command.js +249 -0
- package/server/lib/workspace-command.test.js +264 -0
- package/server/lib/workspace-config.js +270 -0
- package/server/lib/workspace-config.test.js +312 -0
- package/server/lib/workspace-docs-command.js +547 -0
- package/server/lib/workspace-docs-command.test.js +692 -0
- package/server/lib/workspace-memory.js +451 -0
- package/server/lib/workspace-memory.test.js +403 -0
- package/server/lib/workspace-scanner.js +452 -0
- package/server/lib/workspace-scanner.test.js +677 -0
- package/server/lib/workspace-test-runner.js +315 -0
- package/server/lib/workspace-test-runner.test.js +294 -0
- package/server/lib/zero-retention-command.js +439 -0
- package/server/lib/zero-retention-command.test.js +448 -0
- package/server/lib/zero-retention.js +322 -0
- 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
|
+
};
|