agentshield-sdk 7.0.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 (84) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/LICENSE +21 -0
  3. package/README.md +975 -0
  4. package/bin/agent-shield.js +680 -0
  5. package/package.json +118 -0
  6. package/src/adaptive.js +330 -0
  7. package/src/agent-protocol.js +998 -0
  8. package/src/alert-tuning.js +480 -0
  9. package/src/allowlist.js +603 -0
  10. package/src/audit-immutable.js +914 -0
  11. package/src/audit-streaming.js +469 -0
  12. package/src/badges.js +196 -0
  13. package/src/behavior-profiling.js +289 -0
  14. package/src/benchmark-harness.js +804 -0
  15. package/src/canary.js +271 -0
  16. package/src/certification.js +563 -0
  17. package/src/circuit-breaker.js +321 -0
  18. package/src/compliance.js +617 -0
  19. package/src/confidence-tuning.js +324 -0
  20. package/src/confused-deputy.js +624 -0
  21. package/src/context-scoring.js +360 -0
  22. package/src/conversation.js +494 -0
  23. package/src/cost-optimizer.js +1024 -0
  24. package/src/ctf.js +462 -0
  25. package/src/detector-core.js +1999 -0
  26. package/src/distributed.js +359 -0
  27. package/src/document-scanner.js +795 -0
  28. package/src/embedding.js +307 -0
  29. package/src/encoding.js +429 -0
  30. package/src/enterprise.js +405 -0
  31. package/src/errors.js +100 -0
  32. package/src/eu-ai-act.js +523 -0
  33. package/src/fuzzer.js +764 -0
  34. package/src/honeypot.js +328 -0
  35. package/src/i18n-patterns.js +523 -0
  36. package/src/index.js +430 -0
  37. package/src/integrations.js +528 -0
  38. package/src/llm-redteam.js +670 -0
  39. package/src/main.js +741 -0
  40. package/src/main.mjs +38 -0
  41. package/src/mcp-bridge.js +542 -0
  42. package/src/mcp-certification.js +846 -0
  43. package/src/mcp-sdk-integration.js +355 -0
  44. package/src/mcp-security-runtime.js +741 -0
  45. package/src/mcp-server.js +740 -0
  46. package/src/middleware.js +208 -0
  47. package/src/model-finetuning.js +884 -0
  48. package/src/model-fingerprint.js +1042 -0
  49. package/src/multi-agent-trust.js +453 -0
  50. package/src/multi-agent.js +404 -0
  51. package/src/multimodal.js +296 -0
  52. package/src/nist-mapping.js +505 -0
  53. package/src/observability.js +330 -0
  54. package/src/openclaw.js +450 -0
  55. package/src/otel.js +544 -0
  56. package/src/owasp-2025.js +483 -0
  57. package/src/pii.js +390 -0
  58. package/src/plugin-marketplace.js +628 -0
  59. package/src/plugin-system.js +349 -0
  60. package/src/policy-dsl.js +775 -0
  61. package/src/policy-extended.js +635 -0
  62. package/src/policy.js +443 -0
  63. package/src/presets.js +409 -0
  64. package/src/production.js +557 -0
  65. package/src/prompt-leakage.js +321 -0
  66. package/src/rag-vulnerability.js +579 -0
  67. package/src/redteam.js +475 -0
  68. package/src/response-handler.js +429 -0
  69. package/src/scanners.js +357 -0
  70. package/src/self-healing.js +363 -0
  71. package/src/semantic.js +339 -0
  72. package/src/shield-score.js +250 -0
  73. package/src/sso-saml.js +897 -0
  74. package/src/stream-scanner.js +806 -0
  75. package/src/testing.js +505 -0
  76. package/src/threat-encyclopedia.js +629 -0
  77. package/src/threat-intel-network.js +1017 -0
  78. package/src/token-analysis.js +467 -0
  79. package/src/tool-guard.js +412 -0
  80. package/src/tool-output-validator.js +354 -0
  81. package/src/utils.js +83 -0
  82. package/src/watermark.js +235 -0
  83. package/src/worker-scanner.js +601 -0
  84. package/types/index.d.ts +2088 -0
@@ -0,0 +1,897 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Agent Shield — SSO/SAML Integration
5
+ *
6
+ * Ties RBAC to enterprise identity providers. Supports SAML, OIDC, and LDAP
7
+ * provider types. All processing is local — no external calls are made.
8
+ *
9
+ * - SSOManager: Main SSO orchestration
10
+ * - SAMLParser: SAML assertion parsing and validation (simulated)
11
+ * - OIDCHandler: OpenID Connect flow handling
12
+ * - IdentityMapper: Maps IdP identities to Agent Shield RBAC roles
13
+ * - SSOSession: Session management with TTL and permissions
14
+ */
15
+
16
+ const crypto = require('crypto');
17
+
18
+ // =========================================================================
19
+ // SSOSession
20
+ // =========================================================================
21
+
22
+ /**
23
+ * Represents an authenticated SSO session.
24
+ */
25
+ class SSOSession {
26
+ /**
27
+ * @param {object} identity - The user identity from the IdP.
28
+ * @param {string} role - The mapped Agent Shield RBAC role.
29
+ * @param {string[]} permissions - List of granted permissions.
30
+ * @param {number} ttl - Session time-to-live in milliseconds.
31
+ */
32
+ constructor(identity, role, permissions, ttl) {
33
+ this.id = crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex');
34
+ this.identity = identity;
35
+ this.role = role;
36
+ this.permissions = permissions || [];
37
+ this.ttl = ttl || 3600000;
38
+ this.createdAt = Date.now();
39
+ this.expiresAt = this.createdAt + this.ttl;
40
+ this.revoked = false;
41
+ }
42
+
43
+ /**
44
+ * Check whether the session is still valid (not expired, not revoked).
45
+ * @returns {boolean}
46
+ */
47
+ isValid() {
48
+ if (this.revoked) return false;
49
+ return Date.now() < this.expiresAt;
50
+ }
51
+
52
+ /**
53
+ * Check whether the session has a specific permission.
54
+ * @param {string} permission - Permission string to check.
55
+ * @returns {boolean}
56
+ */
57
+ hasPermission(permission) {
58
+ if (!this.isValid()) return false;
59
+ return this.permissions.includes('*') || this.permissions.includes(permission);
60
+ }
61
+
62
+ /**
63
+ * Serializable representation of the session.
64
+ * @returns {object}
65
+ */
66
+ toJSON() {
67
+ return {
68
+ id: this.id,
69
+ identity: this.identity,
70
+ role: this.role,
71
+ permissions: this.permissions,
72
+ createdAt: new Date(this.createdAt).toISOString(),
73
+ expiresAt: new Date(this.expiresAt).toISOString(),
74
+ revoked: this.revoked,
75
+ valid: this.isValid()
76
+ };
77
+ }
78
+ }
79
+
80
+ // =========================================================================
81
+ // IdentityMapper
82
+ // =========================================================================
83
+
84
+ /**
85
+ * Default group-to-role mappings for common IdP groups.
86
+ */
87
+ const DEFAULT_MAPPINGS = [
88
+ { idpGroup: 'admins', shieldRole: 'admin', permissions: ['*'] },
89
+ { idpGroup: 'security', shieldRole: 'analyst', permissions: ['scan', 'read', 'audit', 'configure', 'view_audit', 'manage_policies', 'view_reports'] },
90
+ { idpGroup: 'developers', shieldRole: 'operator', permissions: ['scan', 'read', 'configure', 'view_reports'] },
91
+ { idpGroup: '*', shieldRole: 'viewer', permissions: ['read', 'view_reports'] }
92
+ ];
93
+
94
+ /**
95
+ * Maps IdP identities (groups/attributes) to Agent Shield RBAC roles.
96
+ */
97
+ class IdentityMapper {
98
+ /**
99
+ * @param {Array<{idpGroup: string, shieldRole: string, permissions: string[]}>} [mappingRules]
100
+ */
101
+ constructor(mappingRules) {
102
+ this.rules = mappingRules ? [...mappingRules] : [...DEFAULT_MAPPINGS];
103
+ }
104
+
105
+ /**
106
+ * Add a mapping rule.
107
+ * @param {{idpGroup: string, shieldRole: string, permissions: string[]}} rule
108
+ */
109
+ addRule(rule) {
110
+ if (!rule || !rule.idpGroup || !rule.shieldRole) {
111
+ throw new Error('[Agent Shield] Mapping rule must have idpGroup and shieldRole');
112
+ }
113
+ // Insert before the wildcard rule if one exists
114
+ const wildcardIdx = this.rules.findIndex(r => r.idpGroup === '*');
115
+ if (wildcardIdx >= 0) {
116
+ this.rules.splice(wildcardIdx, 0, rule);
117
+ } else {
118
+ this.rules.push(rule);
119
+ }
120
+ return this;
121
+ }
122
+
123
+ /**
124
+ * Map an identity to a role and permissions based on the identity's groups.
125
+ * @param {object} identity - Identity object with a groups array.
126
+ * @returns {{role: string, permissions: string[]}}
127
+ */
128
+ mapIdentity(identity) {
129
+ const groups = identity.groups || [];
130
+ let matchedRole = null;
131
+ let matchedPermissions = [];
132
+
133
+ for (const rule of this.rules) {
134
+ if (rule.idpGroup === '*' || groups.includes(rule.idpGroup)) {
135
+ matchedRole = rule.shieldRole;
136
+ matchedPermissions = [...(rule.permissions || [])];
137
+ break;
138
+ }
139
+ }
140
+
141
+ // Fallback to viewer if no rule matched
142
+ if (!matchedRole) {
143
+ matchedRole = 'viewer';
144
+ matchedPermissions = ['view_reports'];
145
+ }
146
+
147
+ return { role: matchedRole, permissions: matchedPermissions };
148
+ }
149
+ }
150
+
151
+ // =========================================================================
152
+ // SAMLParser
153
+ // =========================================================================
154
+
155
+ /**
156
+ * Simulated SAML assertion parser. Extracts identity attributes from
157
+ * SAML-like XML strings without requiring real XML crypto libraries.
158
+ */
159
+ class SAMLParser {
160
+ /**
161
+ * Parse a SAML assertion XML string and extract identity attributes.
162
+ * @param {string} xml - SAML assertion XML string.
163
+ * @returns {object} Parsed assertion with issuer, subject, attributes, conditions.
164
+ */
165
+ parseAssertion(xml) {
166
+ if (!xml || typeof xml !== 'string') {
167
+ throw new Error('[Agent Shield] SAML assertion must be a non-empty string');
168
+ }
169
+
170
+ const assertion = {
171
+ issuer: this._extractTag(xml, 'Issuer'),
172
+ subject: {
173
+ nameId: this._extractTag(xml, 'NameID'),
174
+ format: this._extractAttribute(xml, 'NameID', 'Format')
175
+ },
176
+ conditions: {
177
+ notBefore: this._extractAttribute(xml, 'Conditions', 'NotBefore'),
178
+ notOnOrAfter: this._extractAttribute(xml, 'Conditions', 'NotOnOrAfter'),
179
+ audience: this._extractTag(xml, 'Audience')
180
+ },
181
+ attributes: this.extractAttributes({ xml }),
182
+ raw: xml
183
+ };
184
+
185
+ return assertion;
186
+ }
187
+
188
+ /**
189
+ * Validate a parsed SAML assertion against a provider configuration.
190
+ * Checks issuer, audience, time validity, subject, and XML signature.
191
+ * @param {object} assertion - Parsed assertion from parseAssertion.
192
+ * @param {object} provider - Provider config with {issuer, audience, certificate}.
193
+ * @returns {{valid: boolean, errors: string[], signatureVerified: boolean}}
194
+ */
195
+ validateAssertion(assertion, provider) {
196
+ const errors = [];
197
+ let signatureVerified = false;
198
+
199
+ // Check issuer
200
+ if (assertion.issuer && provider.metadata && provider.metadata.issuer) {
201
+ if (assertion.issuer !== provider.metadata.issuer) {
202
+ errors.push(`Issuer mismatch: expected "${provider.metadata.issuer}", got "${assertion.issuer}"`);
203
+ }
204
+ }
205
+
206
+ // Check audience
207
+ if (assertion.conditions && assertion.conditions.audience && provider.metadata && provider.metadata.audience) {
208
+ if (assertion.conditions.audience !== provider.metadata.audience) {
209
+ errors.push(`Audience mismatch: expected "${provider.metadata.audience}", got "${assertion.conditions.audience}"`);
210
+ }
211
+ }
212
+
213
+ // Check time validity
214
+ const now = new Date();
215
+ if (assertion.conditions && assertion.conditions.notBefore) {
216
+ const notBefore = new Date(assertion.conditions.notBefore);
217
+ if (now < notBefore) {
218
+ errors.push(`Assertion not yet valid (notBefore: ${assertion.conditions.notBefore})`);
219
+ }
220
+ }
221
+ if (assertion.conditions && assertion.conditions.notOnOrAfter) {
222
+ const notOnOrAfter = new Date(assertion.conditions.notOnOrAfter);
223
+ if (now >= notOnOrAfter) {
224
+ errors.push(`Assertion expired (notOnOrAfter: ${assertion.conditions.notOnOrAfter})`);
225
+ }
226
+ }
227
+
228
+ // Check subject
229
+ if (!assertion.subject || !assertion.subject.nameId) {
230
+ errors.push('Missing subject NameID');
231
+ }
232
+
233
+ // Verify XML signature if certificate is provided
234
+ if (provider.metadata && provider.metadata.certificate) {
235
+ const sigResult = this.verifySignature(assertion.raw, provider.metadata.certificate);
236
+ signatureVerified = sigResult.valid;
237
+ if (!sigResult.valid) {
238
+ errors.push(`Signature verification failed: ${sigResult.error}`);
239
+ }
240
+ } else {
241
+ errors.push('No IdP certificate provided — cannot verify assertion signature. This is a security risk.');
242
+ }
243
+
244
+ return { valid: errors.length === 0, errors, signatureVerified };
245
+ }
246
+
247
+ /**
248
+ * Verify the XML digital signature on a SAML assertion.
249
+ * Uses Node.js crypto to validate RSA-SHA256 or RSA-SHA1 signatures
250
+ * against the IdP's public certificate.
251
+ *
252
+ * @param {string} xml - Raw SAML assertion XML.
253
+ * @param {string} certificate - PEM-encoded X.509 certificate from IdP metadata.
254
+ * @returns {{valid: boolean, error: string|null, algorithm: string|null}}
255
+ */
256
+ verifySignature(xml, certificate) {
257
+ if (!xml || !certificate) {
258
+ return { valid: false, error: 'Missing XML or certificate', algorithm: null };
259
+ }
260
+
261
+ // Extract the SignatureValue from the XML
262
+ const sigValueMatch = xml.match(/<(?:ds:)?SignatureValue[^>]*>([\s\S]*?)<\/(?:ds:)?SignatureValue>/i);
263
+ if (!sigValueMatch) {
264
+ return { valid: false, error: 'No SignatureValue element found in assertion', algorithm: null };
265
+ }
266
+ const signatureB64 = sigValueMatch[1].replace(/\s+/g, '');
267
+
268
+ // Extract the SignedInfo block (the data that was signed)
269
+ const signedInfoMatch = xml.match(/<(?:ds:)?SignedInfo[^>]*>([\s\S]*?)<\/(?:ds:)?SignedInfo>/i);
270
+ if (!signedInfoMatch) {
271
+ return { valid: false, error: 'No SignedInfo element found in assertion', algorithm: null };
272
+ }
273
+ // Reconstruct the canonicalized SignedInfo element
274
+ const signedInfoXml = xml.match(/<(?:ds:)?SignedInfo[^>]*>[\s\S]*?<\/(?:ds:)?SignedInfo>/i)[0];
275
+
276
+ // Detect signing algorithm
277
+ const algMatch = xml.match(/Algorithm="([^"]*(?:rsa-sha(?:1|256|384|512))[^"]*)"/i);
278
+ let algorithm = 'RSA-SHA256'; // default
279
+ if (algMatch) {
280
+ const algUri = algMatch[1].toLowerCase();
281
+ if (algUri.includes('sha512')) algorithm = 'RSA-SHA512';
282
+ else if (algUri.includes('sha384')) algorithm = 'RSA-SHA384';
283
+ else if (algUri.includes('sha256')) algorithm = 'RSA-SHA256';
284
+ else if (algUri.includes('sha1')) algorithm = 'RSA-SHA1';
285
+ }
286
+
287
+ // Map algorithm name to Node.js crypto algorithm
288
+ const cryptoAlg = algorithm.replace('RSA-', '').toLowerCase().replace('-', '');
289
+
290
+ // Normalize certificate to PEM format
291
+ let pem = certificate.trim();
292
+ if (!pem.startsWith('-----BEGIN')) {
293
+ pem = '-----BEGIN CERTIFICATE-----\n' + pem.replace(/(.{64})/g, '$1\n').trim() + '\n-----END CERTIFICATE-----';
294
+ }
295
+
296
+ try {
297
+ const verifier = crypto.createVerify(cryptoAlg);
298
+ verifier.update(signedInfoXml);
299
+ const signatureBuffer = Buffer.from(signatureB64, 'base64');
300
+ const isValid = verifier.verify(pem, signatureBuffer);
301
+ return { valid: isValid, error: isValid ? null : 'Signature does not match', algorithm };
302
+ } catch (e) {
303
+ return { valid: false, error: `Crypto error: ${e.message}`, algorithm };
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Extract user attributes (email, name, groups, roles) from a SAML assertion.
309
+ * @param {object} assertion - Object with either raw xml or pre-parsed attributes.
310
+ * @returns {object} Extracted attributes {email, name, groups, roles, custom}.
311
+ */
312
+ extractAttributes(assertion) {
313
+ const xml = assertion.xml || assertion.raw || '';
314
+ const attributes = {
315
+ email: null,
316
+ name: null,
317
+ groups: [],
318
+ roles: [],
319
+ custom: {}
320
+ };
321
+
322
+ // Extract email
323
+ const emailPatterns = [
324
+ /AttributeName="email"[^>]*>\s*<[^>]*AttributeValue[^>]*>([^<]+)/i,
325
+ /AttributeName="http:\/\/schemas\.xmlsoap\.org\/ws\/2005\/05\/identity\/claims\/emailaddress"[^>]*>\s*<[^>]*AttributeValue[^>]*>([^<]+)/i,
326
+ /AttributeName="mail"[^>]*>\s*<[^>]*AttributeValue[^>]*>([^<]+)/i
327
+ ];
328
+ for (const pattern of emailPatterns) {
329
+ const match = xml.match(pattern);
330
+ if (match) { attributes.email = match[1].trim(); break; }
331
+ }
332
+
333
+ // Extract name
334
+ const namePatterns = [
335
+ /AttributeName="displayName"[^>]*>\s*<[^>]*AttributeValue[^>]*>([^<]+)/i,
336
+ /AttributeName="name"[^>]*>\s*<[^>]*AttributeValue[^>]*>([^<]+)/i,
337
+ /AttributeName="http:\/\/schemas\.xmlsoap\.org\/ws\/2005\/05\/identity\/claims\/name"[^>]*>\s*<[^>]*AttributeValue[^>]*>([^<]+)/i
338
+ ];
339
+ for (const pattern of namePatterns) {
340
+ const match = xml.match(pattern);
341
+ if (match) { attributes.name = match[1].trim(); break; }
342
+ }
343
+
344
+ // Extract groups
345
+ const groupPattern = /AttributeName="(?:groups?|memberOf|http:\/\/schemas\.xmlsoap\.org\/claims\/Group)"[^>]*>([\s\S]*?)<\/(?:saml2?:)?Attribute>/gi;
346
+ const groupMatch = xml.match(groupPattern);
347
+ if (groupMatch) {
348
+ for (const block of groupMatch) {
349
+ const valuePattern = /<[^>]*AttributeValue[^>]*>([^<]+)/gi;
350
+ let valMatch;
351
+ while ((valMatch = valuePattern.exec(block)) !== null) {
352
+ attributes.groups.push(valMatch[1].trim());
353
+ }
354
+ }
355
+ }
356
+
357
+ // Extract roles
358
+ const rolePattern = /AttributeName="(?:roles?|http:\/\/schemas\.microsoft\.com\/ws\/2008\/06\/identity\/claims\/role)"[^>]*>([\s\S]*?)<\/(?:saml2?:)?Attribute>/gi;
359
+ const roleMatch = xml.match(rolePattern);
360
+ if (roleMatch) {
361
+ for (const block of roleMatch) {
362
+ const valuePattern = /<[^>]*AttributeValue[^>]*>([^<]+)/gi;
363
+ let valMatch;
364
+ while ((valMatch = valuePattern.exec(block)) !== null) {
365
+ attributes.roles.push(valMatch[1].trim());
366
+ }
367
+ }
368
+ }
369
+
370
+ return attributes;
371
+ }
372
+
373
+ /**
374
+ * Build a SAML AuthnRequest for the given provider.
375
+ * @param {object} provider - Provider config with metadata {ssoUrl, entityId, acsUrl}.
376
+ * @returns {string} SAML AuthnRequest XML string.
377
+ */
378
+ buildAuthnRequest(provider) {
379
+ const id = '_' + crypto.randomBytes(16).toString('hex');
380
+ const issueInstant = new Date().toISOString();
381
+ const metadata = provider.metadata || {};
382
+
383
+ return [
384
+ '<samlp:AuthnRequest',
385
+ ' xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"',
386
+ ' xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"',
387
+ ` ID="${id}"`,
388
+ ' Version="2.0"',
389
+ ` IssueInstant="${issueInstant}"`,
390
+ ` Destination="${metadata.ssoUrl || ''}"`,
391
+ ` AssertionConsumerServiceURL="${metadata.acsUrl || ''}"`,
392
+ ' ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">',
393
+ ` <saml:Issuer>${metadata.entityId || ''}</saml:Issuer>`,
394
+ ' <samlp:NameIDPolicy',
395
+ ' Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"',
396
+ ' AllowCreate="true"/>',
397
+ '</samlp:AuthnRequest>'
398
+ ].join('\n');
399
+ }
400
+
401
+ // --- Internal helpers ---
402
+
403
+ _extractTag(xml, tagName) {
404
+ const pattern = new RegExp(`<(?:[\\w-]+:)?${tagName}[^>]*>([^<]+)<\\/`, 'i');
405
+ const match = xml.match(pattern);
406
+ return match ? match[1].trim() : null;
407
+ }
408
+
409
+ _extractAttribute(xml, tagName, attrName) {
410
+ const pattern = new RegExp(`<(?:[\\w-]+:)?${tagName}[^>]*${attrName}="([^"]*)"`, 'i');
411
+ const match = xml.match(pattern);
412
+ return match ? match[1].trim() : null;
413
+ }
414
+ }
415
+
416
+ // =========================================================================
417
+ // OIDCHandler
418
+ // =========================================================================
419
+
420
+ /**
421
+ * Simulated OpenID Connect handler. Builds auth URLs, exchanges codes,
422
+ * and validates JWT-like id_tokens — all locally, no external calls.
423
+ */
424
+ class OIDCHandler {
425
+ /**
426
+ * @param {object} config
427
+ * @param {string} config.clientId - OIDC client ID.
428
+ * @param {string} config.issuer - OIDC issuer URL.
429
+ * @param {string} config.redirectUri - Redirect URI after authentication.
430
+ */
431
+ constructor(config = {}) {
432
+ this.clientId = config.clientId || '';
433
+ this.issuer = config.issuer || '';
434
+ this.redirectUri = config.redirectUri || '';
435
+ }
436
+
437
+ /**
438
+ * Build an OIDC authorization URL.
439
+ * @param {string} state - Opaque state parameter for CSRF protection.
440
+ * @returns {string} Authorization URL.
441
+ */
442
+ buildAuthorizationUrl(state) {
443
+ const nonce = crypto.randomBytes(16).toString('hex');
444
+ const params = new URLSearchParams({
445
+ response_type: 'code',
446
+ client_id: this.clientId,
447
+ redirect_uri: this.redirectUri,
448
+ scope: 'openid profile email groups',
449
+ state: state || crypto.randomBytes(16).toString('hex'),
450
+ nonce
451
+ });
452
+ return `${this.issuer}/authorize?${params.toString()}`;
453
+ }
454
+
455
+ /**
456
+ * Simulate an authorization code exchange. Returns token-like objects.
457
+ * @param {string} code - The authorization code.
458
+ * @returns {object} Token response {access_token, id_token, token_type, expires_in}.
459
+ */
460
+ exchangeCode(code) {
461
+ if (!code || typeof code !== 'string') {
462
+ throw new Error('[Agent Shield] Authorization code is required');
463
+ }
464
+
465
+ const now = Math.floor(Date.now() / 1000);
466
+ const payload = {
467
+ iss: this.issuer,
468
+ sub: 'user_' + code.substring(0, 8),
469
+ aud: this.clientId,
470
+ iat: now,
471
+ exp: now + 3600,
472
+ nonce: crypto.randomBytes(8).toString('hex')
473
+ };
474
+
475
+ const header = Buffer.from(JSON.stringify({ alg: 'RS256', typ: 'JWT' })).toString('base64url');
476
+ const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
477
+ const signature = crypto.randomBytes(32).toString('base64url');
478
+
479
+ const idToken = `${header}.${body}.${signature}`;
480
+ const accessToken = crypto.randomBytes(32).toString('base64url');
481
+
482
+ return {
483
+ access_token: accessToken,
484
+ id_token: idToken,
485
+ token_type: 'Bearer',
486
+ expires_in: 3600
487
+ };
488
+ }
489
+
490
+ /**
491
+ * Validate a JWT id_token. Decodes, checks claims, and verifies signature
492
+ * if a signing key or JWKS is configured.
493
+ *
494
+ * @param {string} token - JWT id_token string.
495
+ * @param {object} [options] - Validation options.
496
+ * @param {string} [options.signingKey] - PEM public key or certificate for RS256 verification.
497
+ * @param {string} [options.secret] - Shared secret for HS256 verification.
498
+ * @param {string} [options.nonce] - Expected nonce value for replay protection.
499
+ * @returns {{valid: boolean, claims: object, errors: string[], signatureVerified: boolean}}
500
+ */
501
+ validateIdToken(token, options = {}) {
502
+ const errors = [];
503
+ let signatureVerified = false;
504
+
505
+ if (!token || typeof token !== 'string') {
506
+ return { valid: false, claims: null, errors: ['Token must be a non-empty string'], signatureVerified: false };
507
+ }
508
+
509
+ const parts = token.split('.');
510
+ if (parts.length !== 3) {
511
+ return { valid: false, claims: null, errors: ['Invalid JWT structure: expected 3 parts'], signatureVerified: false };
512
+ }
513
+
514
+ // Decode header
515
+ let header;
516
+ try {
517
+ header = JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf-8'));
518
+ } catch (e) {
519
+ return { valid: false, claims: null, errors: ['Failed to decode JWT header'], signatureVerified: false };
520
+ }
521
+
522
+ // Decode claims
523
+ let claims;
524
+ try {
525
+ claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
526
+ } catch (e) {
527
+ return { valid: false, claims: null, errors: ['Failed to decode JWT payload'], signatureVerified: false };
528
+ }
529
+
530
+ // Verify signature
531
+ const signingInput = parts[0] + '.' + parts[1];
532
+ const signature = parts[2];
533
+ const alg = (header.alg || '').toUpperCase();
534
+
535
+ if (options.signingKey && (alg === 'RS256' || alg === 'RS384' || alg === 'RS512')) {
536
+ // RSA signature verification
537
+ const hashAlg = alg.replace('RS', 'sha');
538
+ try {
539
+ const verifier = crypto.createVerify(hashAlg);
540
+ verifier.update(signingInput);
541
+ const sigBuf = Buffer.from(signature, 'base64url');
542
+ signatureVerified = verifier.verify(options.signingKey, sigBuf);
543
+ if (!signatureVerified) {
544
+ errors.push('JWT signature verification failed — token may be forged');
545
+ }
546
+ } catch (e) {
547
+ errors.push(`JWT signature verification error: ${e.message}`);
548
+ }
549
+ } else if (options.secret && (alg === 'HS256' || alg === 'HS384' || alg === 'HS512')) {
550
+ // HMAC signature verification
551
+ const hashAlg = alg.replace('HS', 'sha');
552
+ const expectedSig = crypto.createHmac(hashAlg, options.secret).update(signingInput).digest('base64url');
553
+ signatureVerified = crypto.timingSafeEqual(
554
+ Buffer.from(signature, 'utf-8'),
555
+ Buffer.from(expectedSig, 'utf-8')
556
+ );
557
+ if (!signatureVerified) {
558
+ errors.push('JWT HMAC signature verification failed — token may be forged');
559
+ }
560
+ } else if (alg === 'NONE' || alg === '') {
561
+ errors.push('JWT uses "none" algorithm — this is insecure and rejected');
562
+ } else if (!options.signingKey && !options.secret) {
563
+ errors.push('No signing key provided — JWT signature not verified. This is a security risk.');
564
+ }
565
+
566
+ // Validate issuer
567
+ if (this.issuer && claims.iss !== this.issuer) {
568
+ errors.push(`Issuer mismatch: expected "${this.issuer}", got "${claims.iss}"`);
569
+ }
570
+
571
+ // Validate audience
572
+ if (this.clientId) {
573
+ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
574
+ if (!aud.includes(this.clientId)) {
575
+ errors.push(`Audience mismatch: expected "${this.clientId}", got "${claims.aud}"`);
576
+ }
577
+ }
578
+
579
+ // Validate expiration
580
+ const now = Math.floor(Date.now() / 1000);
581
+ if (claims.exp && claims.exp < now) {
582
+ errors.push('Token has expired');
583
+ }
584
+
585
+ // Validate issued-at is not in the future (with 60s clock skew tolerance)
586
+ if (claims.iat && claims.iat > now + 60) {
587
+ errors.push('Token issued-at is in the future');
588
+ }
589
+
590
+ // Validate nonce (replay protection)
591
+ if (options.nonce && claims.nonce !== options.nonce) {
592
+ errors.push(`Nonce mismatch: possible replay attack`);
593
+ }
594
+
595
+ return { valid: errors.length === 0, claims, errors, signatureVerified };
596
+ }
597
+
598
+ /**
599
+ * Extract user info from an access token or id_token.
600
+ * @param {string} accessToken - Access token or id_token.
601
+ * @returns {object} User info {sub, email, name, groups}.
602
+ */
603
+ getUserInfo(accessToken) {
604
+ if (!accessToken || typeof accessToken !== 'string') {
605
+ throw new Error('[Agent Shield] Access token is required');
606
+ }
607
+
608
+ // Try to decode as JWT
609
+ const parts = accessToken.split('.');
610
+ if (parts.length === 3) {
611
+ try {
612
+ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
613
+ return {
614
+ sub: claims.sub || null,
615
+ email: claims.email || null,
616
+ name: claims.name || null,
617
+ groups: claims.groups || []
618
+ };
619
+ } catch (e) {
620
+ // Not a valid JWT, return minimal info
621
+ }
622
+ }
623
+
624
+ // Fallback: return a stub based on the token
625
+ return {
626
+ sub: 'user_' + accessToken.substring(0, 8),
627
+ email: null,
628
+ name: null,
629
+ groups: []
630
+ };
631
+ }
632
+ }
633
+
634
+ // =========================================================================
635
+ // SSOManager
636
+ // =========================================================================
637
+
638
+ /**
639
+ * Main SSO orchestration. Registers identity providers, authenticates
640
+ * users, manages sessions, and maps identities to Agent Shield RBAC roles.
641
+ */
642
+ class SSOManager {
643
+ /**
644
+ * @param {object} [config]
645
+ * @param {Array} [config.providers] - Pre-registered IdP configurations.
646
+ * @param {string} [config.defaultRole] - Default role when no mapping matches.
647
+ * @param {number} [config.sessionTTL] - Session TTL in milliseconds.
648
+ * @param {boolean} [config.auditLog] - Whether to log authentication events.
649
+ */
650
+ constructor(config = {}) {
651
+ this.providers = new Map();
652
+ this.sessions = new Map();
653
+ this.auditEntries = [];
654
+ this.defaultRole = config.defaultRole || 'viewer';
655
+ this.sessionTTL = config.sessionTTL || 3600000;
656
+ this.auditLog = config.auditLog !== undefined ? config.auditLog : true;
657
+ this.identityMapper = new IdentityMapper();
658
+ this.samlParser = new SAMLParser();
659
+ this.oidcHandler = null;
660
+
661
+ // Register any providers passed in config
662
+ if (Array.isArray(config.providers)) {
663
+ for (const provider of config.providers) {
664
+ this.registerProvider(provider);
665
+ }
666
+ }
667
+
668
+ console.log('[Agent Shield] SSOManager initialized');
669
+ }
670
+
671
+ /**
672
+ * Register an identity provider configuration.
673
+ * @param {object} provider - Provider config {id, type, name, metadata}.
674
+ * @returns {SSOManager}
675
+ */
676
+ registerProvider(provider) {
677
+ if (!provider || !provider.id || !provider.type) {
678
+ throw new Error('[Agent Shield] Provider must have id and type');
679
+ }
680
+
681
+ const validTypes = ['saml', 'oidc', 'ldap'];
682
+ if (!validTypes.includes(provider.type)) {
683
+ throw new Error(`[Agent Shield] Invalid provider type: ${provider.type}. Must be one of: ${validTypes.join(', ')}`);
684
+ }
685
+
686
+ this.providers.set(provider.id, {
687
+ ...provider,
688
+ registeredAt: new Date().toISOString()
689
+ });
690
+
691
+ // Initialize OIDC handler if provider is OIDC
692
+ if (provider.type === 'oidc' && provider.metadata) {
693
+ this.oidcHandler = new OIDCHandler({
694
+ clientId: provider.metadata.clientId,
695
+ issuer: provider.metadata.issuer,
696
+ redirectUri: provider.metadata.redirectUri
697
+ });
698
+ }
699
+
700
+ this._audit('provider_registered', { providerId: provider.id, type: provider.type });
701
+ console.log(`[Agent Shield] SSO provider registered: ${provider.name || provider.id} (${provider.type})`);
702
+
703
+ return this;
704
+ }
705
+
706
+ /**
707
+ * Authenticate a user via SAML assertion or OIDC token.
708
+ * @param {string} providerType - 'saml' or 'oidc'.
709
+ * @param {string} assertion - SAML XML assertion or OIDC code/token.
710
+ * @returns {SSOSession}
711
+ */
712
+ authenticate(providerType, assertion) {
713
+ if (!assertion) {
714
+ throw new Error('[Agent Shield] Assertion/token is required');
715
+ }
716
+
717
+ let identity;
718
+
719
+ if (providerType === 'saml') {
720
+ identity = this._authenticateSAML(assertion);
721
+ } else if (providerType === 'oidc') {
722
+ identity = this._authenticateOIDC(assertion);
723
+ } else {
724
+ throw new Error(`[Agent Shield] Unsupported provider type for authentication: ${providerType}`);
725
+ }
726
+
727
+ // Map identity to role
728
+ const mapping = this.mapToRole(identity);
729
+ const session = new SSOSession(identity, mapping.role, mapping.permissions, this.sessionTTL);
730
+
731
+ this.sessions.set(session.id, session);
732
+ this._audit('authentication_success', {
733
+ sessionId: session.id,
734
+ email: identity.email,
735
+ role: mapping.role,
736
+ providerType
737
+ });
738
+
739
+ console.log(`[Agent Shield] SSO authentication successful: ${identity.email || identity.nameId} -> ${mapping.role}`);
740
+
741
+ return session;
742
+ }
743
+
744
+ /**
745
+ * Map an identity to an Agent Shield RBAC role.
746
+ * @param {object} identity - Identity with groups array.
747
+ * @returns {{role: string, permissions: string[]}}
748
+ */
749
+ mapToRole(identity) {
750
+ return this.identityMapper.mapIdentity(identity);
751
+ }
752
+
753
+ /**
754
+ * Retrieve an active session by ID.
755
+ * @param {string} sessionId
756
+ * @returns {SSOSession|null}
757
+ */
758
+ getSession(sessionId) {
759
+ const session = this.sessions.get(sessionId);
760
+ if (!session) return null;
761
+ if (!session.isValid()) {
762
+ this.sessions.delete(sessionId);
763
+ return null;
764
+ }
765
+ return session;
766
+ }
767
+
768
+ /**
769
+ * Revoke an active session.
770
+ * @param {string} sessionId
771
+ * @returns {boolean} Whether the session was found and revoked.
772
+ */
773
+ revokeSession(sessionId) {
774
+ const session = this.sessions.get(sessionId);
775
+ if (!session) return false;
776
+
777
+ session.revoked = true;
778
+ this._audit('session_revoked', { sessionId });
779
+ console.log(`[Agent Shield] Session revoked: ${sessionId}`);
780
+
781
+ return true;
782
+ }
783
+
784
+ /**
785
+ * List all active (non-expired, non-revoked) sessions.
786
+ * @returns {SSOSession[]}
787
+ */
788
+ listActiveSessions() {
789
+ const active = [];
790
+ for (const [id, session] of this.sessions) {
791
+ if (session.isValid()) {
792
+ active.push(session);
793
+ } else {
794
+ this.sessions.delete(id);
795
+ }
796
+ }
797
+ return active;
798
+ }
799
+
800
+ /**
801
+ * Get the authentication audit log.
802
+ * @returns {Array<object>}
803
+ */
804
+ getAuditLog() {
805
+ return [...this.auditEntries];
806
+ }
807
+
808
+ // --- Internal helpers ---
809
+
810
+ _authenticateSAML(xml) {
811
+ const assertion = this.samlParser.parseAssertion(xml);
812
+
813
+ // Find a SAML provider to validate against
814
+ let samlProvider = null;
815
+ for (const [, provider] of this.providers) {
816
+ if (provider.type === 'saml') {
817
+ samlProvider = provider;
818
+ break;
819
+ }
820
+ }
821
+
822
+ if (samlProvider) {
823
+ const validation = this.samlParser.validateAssertion(assertion, samlProvider);
824
+ if (!validation.valid) {
825
+ this._audit('authentication_failed', { errors: validation.errors, providerType: 'saml' });
826
+ throw new Error(`[Agent Shield] SAML validation failed: ${validation.errors.join('; ')}`);
827
+ }
828
+ }
829
+
830
+ return {
831
+ nameId: assertion.subject.nameId,
832
+ email: assertion.attributes.email || assertion.subject.nameId,
833
+ name: assertion.attributes.name,
834
+ groups: assertion.attributes.groups || [],
835
+ roles: assertion.attributes.roles || [],
836
+ provider: 'saml'
837
+ };
838
+ }
839
+
840
+ _authenticateOIDC(codeOrToken) {
841
+ // If it looks like a JWT (has dots), validate directly
842
+ if (codeOrToken.includes('.')) {
843
+ const handler = this.oidcHandler || new OIDCHandler({});
844
+ const validation = handler.validateIdToken(codeOrToken);
845
+ if (!validation.valid) {
846
+ this._audit('authentication_failed', { errors: validation.errors, providerType: 'oidc' });
847
+ throw new Error(`[Agent Shield] OIDC token validation failed: ${validation.errors.join('; ')}`);
848
+ }
849
+ const claims = validation.claims;
850
+ return {
851
+ sub: claims.sub,
852
+ email: claims.email || null,
853
+ name: claims.name || null,
854
+ groups: claims.groups || [],
855
+ roles: claims.roles || [],
856
+ provider: 'oidc'
857
+ };
858
+ }
859
+
860
+ // Otherwise treat as authorization code
861
+ const handler = this.oidcHandler || new OIDCHandler({});
862
+ const tokens = handler.exchangeCode(codeOrToken);
863
+ const validation = handler.validateIdToken(tokens.id_token);
864
+ const claims = validation.claims || {};
865
+
866
+ return {
867
+ sub: claims.sub,
868
+ email: claims.email || null,
869
+ name: claims.name || null,
870
+ groups: claims.groups || [],
871
+ roles: claims.roles || [],
872
+ provider: 'oidc'
873
+ };
874
+ }
875
+
876
+ _audit(event, details) {
877
+ if (!this.auditLog) return;
878
+ this.auditEntries.push({
879
+ timestamp: new Date().toISOString(),
880
+ event,
881
+ details
882
+ });
883
+ }
884
+ }
885
+
886
+ // =========================================================================
887
+ // Exports
888
+ // =========================================================================
889
+
890
+ module.exports = {
891
+ SSOManager,
892
+ SAMLParser,
893
+ OIDCHandler,
894
+ IdentityMapper,
895
+ SSOSession,
896
+ DEFAULT_MAPPINGS
897
+ };