@darbotlabs/darbot-browser-mcp 0.1.1 → 1.3.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 (80) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +249 -158
  3. package/cli.js +1 -1
  4. package/config.d.ts +77 -1
  5. package/index.d.ts +1 -1
  6. package/index.js +1 -1
  7. package/lib/ai/context.js +150 -0
  8. package/lib/ai/guardrails.js +382 -0
  9. package/lib/ai/integration.js +397 -0
  10. package/lib/ai/intent.js +237 -0
  11. package/lib/ai/manualPromise.js +111 -0
  12. package/lib/ai/memory.js +273 -0
  13. package/lib/ai/ml-scorer.js +265 -0
  14. package/lib/ai/orchestrator-tools.js +292 -0
  15. package/lib/ai/orchestrator.js +473 -0
  16. package/lib/ai/planner.js +300 -0
  17. package/lib/ai/reporter.js +493 -0
  18. package/lib/ai/workflow.js +407 -0
  19. package/lib/auth/apiKeyAuth.js +46 -0
  20. package/lib/auth/entraAuth.js +110 -0
  21. package/lib/auth/entraJwtVerifier.js +117 -0
  22. package/lib/auth/index.js +210 -0
  23. package/lib/auth/managedIdentityAuth.js +175 -0
  24. package/lib/auth/mcpOAuthProvider.js +186 -0
  25. package/lib/auth/tunnelAuth.js +120 -0
  26. package/lib/browserContextFactory.js +1 -1
  27. package/lib/browserServer.js +1 -1
  28. package/lib/cdpRelay.js +2 -2
  29. package/lib/common.js +68 -0
  30. package/lib/config.js +62 -3
  31. package/lib/connection.js +1 -1
  32. package/lib/context.js +1 -1
  33. package/lib/fileUtils.js +1 -1
  34. package/lib/guardrails.js +382 -0
  35. package/lib/health.js +178 -0
  36. package/lib/httpServer.js +1 -1
  37. package/lib/index.js +1 -1
  38. package/lib/javascript.js +1 -1
  39. package/lib/manualPromise.js +1 -1
  40. package/lib/memory.js +273 -0
  41. package/lib/openapi.js +373 -0
  42. package/lib/orchestrator.js +473 -0
  43. package/lib/package.js +1 -1
  44. package/lib/pageSnapshot.js +17 -2
  45. package/lib/planner.js +302 -0
  46. package/lib/program.js +17 -5
  47. package/lib/reporter.js +493 -0
  48. package/lib/resources/resource.js +1 -1
  49. package/lib/server.js +5 -3
  50. package/lib/tab.js +1 -1
  51. package/lib/tools/ai-native.js +298 -0
  52. package/lib/tools/autonomous.js +147 -0
  53. package/lib/tools/clock.js +183 -0
  54. package/lib/tools/common.js +1 -1
  55. package/lib/tools/console.js +1 -1
  56. package/lib/tools/diagnostics.js +132 -0
  57. package/lib/tools/dialogs.js +1 -1
  58. package/lib/tools/emulation.js +155 -0
  59. package/lib/tools/files.js +1 -1
  60. package/lib/tools/install.js +1 -1
  61. package/lib/tools/keyboard.js +1 -1
  62. package/lib/tools/navigate.js +1 -1
  63. package/lib/tools/network.js +1 -1
  64. package/lib/tools/pageSnapshot.js +58 -0
  65. package/lib/tools/pdf.js +1 -1
  66. package/lib/tools/profiles.js +76 -25
  67. package/lib/tools/screenshot.js +1 -1
  68. package/lib/tools/scroll.js +93 -0
  69. package/lib/tools/snapshot.js +1 -1
  70. package/lib/tools/storage.js +328 -0
  71. package/lib/tools/tab.js +16 -0
  72. package/lib/tools/tabs.js +1 -1
  73. package/lib/tools/testing.js +1 -1
  74. package/lib/tools/tool.js +1 -1
  75. package/lib/tools/utils.js +1 -1
  76. package/lib/tools/vision.js +1 -1
  77. package/lib/tools/wait.js +1 -1
  78. package/lib/tools.js +22 -1
  79. package/lib/transport.js +251 -31
  80. package/package.json +54 -21
@@ -0,0 +1,407 @@
1
+ /**
2
+ * Copyright (c) DarbotLabs.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ /**
17
+ * Workflow execution engine for automated task sequences
18
+ */
19
+ export class WorkflowEngine {
20
+ templates = new Map();
21
+ executions = new Map();
22
+ constructor() {
23
+ this.registerDefaultTemplates();
24
+ }
25
+ /**
26
+ * Register default workflow templates
27
+ */
28
+ registerDefaultTemplates() {
29
+ // GitHub Issue Management
30
+ this.registerTemplate({
31
+ name: 'github_issue_management',
32
+ description: 'Create, update, or manage GitHub issues',
33
+ requiredParameters: ['repository', 'action'],
34
+ expectedDuration: 60,
35
+ successCriteria: ['Issue created or updated successfully'],
36
+ steps: [
37
+ {
38
+ action: 'navigate',
39
+ parameters: { url: 'https://github.com/{repository}/issues' },
40
+ retryCount: 2,
41
+ timeout: 10000,
42
+ },
43
+ {
44
+ action: 'conditional_click',
45
+ parameters: {
46
+ element: 'New issue button',
47
+ condition: (params) => params.action === 'create',
48
+ },
49
+ },
50
+ {
51
+ action: 'type',
52
+ parameters: {
53
+ element: 'issue title input',
54
+ text: '{title}',
55
+ },
56
+ },
57
+ {
58
+ action: 'type',
59
+ parameters: {
60
+ element: 'issue description textarea',
61
+ text: '{description}',
62
+ },
63
+ },
64
+ {
65
+ action: 'click',
66
+ parameters: { element: 'Submit new issue' },
67
+ validation: result => result.success,
68
+ },
69
+ ],
70
+ });
71
+ // Code Review Workflow
72
+ this.registerTemplate({
73
+ name: 'code_review_workflow',
74
+ description: 'Navigate and review pull requests',
75
+ requiredParameters: ['repository'],
76
+ expectedDuration: 120,
77
+ successCriteria: ['PR reviewed and commented'],
78
+ steps: [
79
+ {
80
+ action: 'navigate',
81
+ parameters: { url: 'https://github.com/{repository}/pulls' },
82
+ },
83
+ {
84
+ action: 'click',
85
+ parameters: { element: 'first pull request in list' },
86
+ },
87
+ {
88
+ action: 'wait_for',
89
+ parameters: { target: 'Files changed tab' },
90
+ },
91
+ {
92
+ action: 'click',
93
+ parameters: { element: 'Files changed tab' },
94
+ },
95
+ {
96
+ action: 'analyze_changes',
97
+ parameters: { focus: 'security and performance' },
98
+ },
99
+ ],
100
+ });
101
+ // Login Workflow
102
+ this.registerTemplate({
103
+ name: 'login_workflow',
104
+ description: 'Automated login to common services',
105
+ requiredParameters: ['service'],
106
+ expectedDuration: 30,
107
+ successCriteria: ['Successfully logged in'],
108
+ steps: [
109
+ {
110
+ action: 'detect_login_form',
111
+ parameters: {},
112
+ },
113
+ {
114
+ action: 'type',
115
+ parameters: {
116
+ element: 'username input',
117
+ text: '{username}',
118
+ },
119
+ },
120
+ {
121
+ action: 'type',
122
+ parameters: {
123
+ element: 'password input',
124
+ text: '{password}',
125
+ },
126
+ },
127
+ {
128
+ action: 'click',
129
+ parameters: { element: 'login button' },
130
+ },
131
+ {
132
+ action: 'wait_for',
133
+ parameters: { target: 'dashboard or home page' },
134
+ timeout: 15000,
135
+ },
136
+ ],
137
+ });
138
+ // Repository Analysis
139
+ this.registerTemplate({
140
+ name: 'repository_analysis',
141
+ description: 'Comprehensive repository health and activity analysis',
142
+ requiredParameters: ['repository'],
143
+ expectedDuration: 90,
144
+ successCriteria: ['Analysis report generated'],
145
+ steps: [
146
+ {
147
+ action: 'navigate',
148
+ parameters: { url: 'https://github.com/{repository}' },
149
+ },
150
+ {
151
+ action: 'analyze_readme',
152
+ parameters: {},
153
+ },
154
+ {
155
+ action: 'click',
156
+ parameters: { element: 'Issues tab' },
157
+ },
158
+ {
159
+ action: 'analyze_issues',
160
+ parameters: {},
161
+ },
162
+ {
163
+ action: 'click',
164
+ parameters: { element: 'Pull requests tab' },
165
+ },
166
+ {
167
+ action: 'analyze_pull_requests',
168
+ parameters: {},
169
+ },
170
+ {
171
+ action: 'generate_report',
172
+ parameters: { format: 'summary' },
173
+ },
174
+ ],
175
+ });
176
+ }
177
+ /**
178
+ * Register a new workflow template
179
+ */
180
+ registerTemplate(template) {
181
+ this.templates.set(template.name, template);
182
+ }
183
+ /**
184
+ * Get available workflow templates
185
+ */
186
+ getTemplates() {
187
+ return Array.from(this.templates.values());
188
+ }
189
+ /**
190
+ * Execute a workflow by name
191
+ */
192
+ async executeWorkflow(context, templateName, parameters) {
193
+ const template = this.templates.get(templateName);
194
+ if (!template)
195
+ throw new Error(`Workflow template '${templateName}' not found`);
196
+ // Validate required parameters
197
+ for (const required of template.requiredParameters) {
198
+ if (!(required in parameters))
199
+ throw new Error(`Missing required parameter: ${required}`);
200
+ }
201
+ const executionId = `${templateName}_${Date.now()}`;
202
+ const execution = {
203
+ templateName,
204
+ status: 'running',
205
+ currentStep: 0,
206
+ startTime: Date.now(),
207
+ parameters,
208
+ results: [],
209
+ errors: [],
210
+ };
211
+ this.executions.set(executionId, execution);
212
+ try {
213
+ for (let i = 0; i < template.steps.length; i++) {
214
+ execution.currentStep = i;
215
+ const step = template.steps[i];
216
+ // Replace parameter placeholders
217
+ const resolvedStep = this.resolveStepParameters(step, parameters);
218
+ try {
219
+ const result = await this.executeStep(context, resolvedStep);
220
+ execution.results.push(result);
221
+ // Validate step result if validation function provided
222
+ if (step.validation && !step.validation(result))
223
+ throw new Error(`Step validation failed for step ${i}`);
224
+ }
225
+ catch (error) {
226
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
227
+ execution.errors.push(`Step ${i}: ${errorMessage}`);
228
+ // Handle error based on step configuration
229
+ const onError = step.onError || 'abort';
230
+ if (onError === 'abort') {
231
+ execution.status = 'failed';
232
+ return execution;
233
+ }
234
+ else if (onError === 'retry' && (step.retryCount || 0) > 0) {
235
+ // Implement retry logic
236
+ for (let retry = 0; retry < (step.retryCount || 0); retry++) {
237
+ try {
238
+ const retryResult = await this.executeStep(context, resolvedStep);
239
+ execution.results.push(retryResult);
240
+ break;
241
+ }
242
+ catch (retryError) {
243
+ if (retry === (step.retryCount || 0) - 1)
244
+ throw retryError;
245
+ }
246
+ }
247
+ }
248
+ // Continue to next step if onError is 'continue'
249
+ }
250
+ }
251
+ execution.status = 'completed';
252
+ }
253
+ catch (error) {
254
+ execution.status = 'failed';
255
+ execution.errors.push(error instanceof Error ? error.message : 'Unknown error');
256
+ }
257
+ return execution;
258
+ }
259
+ /**
260
+ * Resolve parameter placeholders in step
261
+ */
262
+ resolveStepParameters(step, parameters) {
263
+ const resolved = { ...step };
264
+ resolved.parameters = { ...step.parameters };
265
+ // Replace {parameter} placeholders
266
+ for (const [key, value] of Object.entries(resolved.parameters)) {
267
+ if (typeof value === 'string') {
268
+ resolved.parameters[key] = value.replace(/\{(\w+)\}/g, (match, paramName) => {
269
+ return parameters[paramName] || match;
270
+ });
271
+ }
272
+ }
273
+ return resolved;
274
+ }
275
+ /**
276
+ * Map workflow action names to browser tool names
277
+ */
278
+ actionToToolMap = {
279
+ 'navigate': 'browser_navigate',
280
+ 'click': 'browser_click',
281
+ 'conditional_click': 'browser_click',
282
+ 'type': 'browser_type',
283
+ 'wait_for': 'browser_wait_for',
284
+ 'screenshot': 'browser_screenshot',
285
+ 'snapshot': 'browser_snapshot',
286
+ 'detect_login_form': 'browser_snapshot',
287
+ 'analyze_readme': 'browser_snapshot',
288
+ 'analyze_issues': 'browser_snapshot',
289
+ 'analyze_pull_requests': 'browser_snapshot',
290
+ 'analyze_changes': 'browser_snapshot',
291
+ 'generate_report': 'browser_snapshot',
292
+ };
293
+ /**
294
+ * Execute a single workflow step using the browser tool system
295
+ */
296
+ async executeStep(context, step) {
297
+ const startTime = Date.now();
298
+ // Map action to tool name
299
+ const toolName = this.actionToToolMap[step.action] || `browser_${step.action}`;
300
+ // Find the tool in context
301
+ const tool = context.tools.find(t => t.schema.name === toolName);
302
+ if (!tool) {
303
+ return {
304
+ action: step.action,
305
+ toolName,
306
+ success: false,
307
+ error: `Tool '${toolName}' not found for action '${step.action}'`,
308
+ timestamp: startTime,
309
+ duration: Date.now() - startTime,
310
+ };
311
+ }
312
+ // Build tool parameters from step parameters
313
+ const toolParams = {};
314
+ // Map common workflow parameters to tool parameters
315
+ if (step.parameters.url)
316
+ toolParams.url = step.parameters.url;
317
+ if (step.parameters.element)
318
+ toolParams.element = step.parameters.element;
319
+ if (step.parameters.text)
320
+ toolParams.text = step.parameters.text;
321
+ if (step.parameters.target)
322
+ toolParams.text = step.parameters.target; // wait_for uses 'text' param
323
+ // Handle conditional actions
324
+ if (step.action === 'conditional_click' && step.parameters.condition) {
325
+ const conditionFn = step.parameters.condition;
326
+ if (typeof conditionFn === 'function' && !conditionFn(step.parameters)) {
327
+ return {
328
+ action: step.action,
329
+ toolName,
330
+ success: true,
331
+ skipped: true,
332
+ reason: 'Condition not met',
333
+ timestamp: startTime,
334
+ duration: Date.now() - startTime,
335
+ };
336
+ }
337
+ }
338
+ try {
339
+ // Execute the tool through context.run()
340
+ const result = await context.run(tool, toolParams);
341
+ return {
342
+ action: step.action,
343
+ toolName,
344
+ parameters: toolParams,
345
+ success: true,
346
+ result,
347
+ timestamp: startTime,
348
+ duration: Date.now() - startTime,
349
+ };
350
+ }
351
+ catch (error) {
352
+ return {
353
+ action: step.action,
354
+ toolName,
355
+ parameters: toolParams,
356
+ success: false,
357
+ error: error instanceof Error ? error.message : String(error),
358
+ timestamp: startTime,
359
+ duration: Date.now() - startTime,
360
+ };
361
+ }
362
+ }
363
+ /**
364
+ * Get workflow execution status
365
+ */
366
+ getExecution(executionId) {
367
+ return this.executions.get(executionId);
368
+ }
369
+ /**
370
+ * List all active executions
371
+ */
372
+ getActiveExecutions() {
373
+ return Array.from(this.executions.values()).filter(execution => execution.status === 'running' || execution.status === 'paused');
374
+ }
375
+ /**
376
+ * Cancel a running workflow
377
+ */
378
+ cancelExecution(executionId) {
379
+ const execution = this.executions.get(executionId);
380
+ if (execution && execution.status === 'running') {
381
+ execution.status = 'failed';
382
+ execution.errors.push('Workflow cancelled by user');
383
+ return true;
384
+ }
385
+ return false;
386
+ }
387
+ /**
388
+ * Suggest workflows based on current context
389
+ */
390
+ suggestWorkflows(currentUrl, pageContent) {
391
+ const suggestions = [];
392
+ // GitHub-specific suggestions
393
+ if (currentUrl.includes('github.com')) {
394
+ if (currentUrl.includes('/issues'))
395
+ suggestions.push(this.templates.get('github_issue_management'));
396
+ if (currentUrl.includes('/pulls'))
397
+ suggestions.push(this.templates.get('code_review_workflow'));
398
+ suggestions.push(this.templates.get('repository_analysis'));
399
+ }
400
+ // Login form detection
401
+ if (pageContent.includes('password') && pageContent.includes('login'))
402
+ suggestions.push(this.templates.get('login_workflow'));
403
+ return suggestions.filter(Boolean);
404
+ }
405
+ }
406
+ // Global workflow engine instance
407
+ export const workflowEngine = new WorkflowEngine();
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Copyright (c) DarbotLabs.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ export class ApiKeyAuthenticator {
17
+ _enabled;
18
+ _keys;
19
+ constructor(config) {
20
+ this._enabled = !!config.enabled;
21
+ this._keys = new Set((config.keys || []).map(k => k.trim()).filter(Boolean));
22
+ }
23
+ get enabled() {
24
+ return this._enabled;
25
+ }
26
+ /**
27
+ * Returns true when a provided X-API-Key matches one of the configured keys.
28
+ *
29
+ * If auth is disabled, returns true.
30
+ */
31
+ authenticate(req) {
32
+ if (!this._enabled)
33
+ return true;
34
+ const apiKeyHeader = req.headers['x-api-key'];
35
+ const apiKey = Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader;
36
+ if (!apiKey)
37
+ return false;
38
+ return this._keys.has(apiKey);
39
+ }
40
+ }
41
+ export function createApiKeyAuthenticatorFromEnv() {
42
+ return new ApiKeyAuthenticator({
43
+ enabled: process.env.API_KEY_AUTH_ENABLED === 'true',
44
+ keys: (process.env.API_KEYS || '').split(',').map(s => s.trim()).filter(Boolean),
45
+ });
46
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Copyright (c) DarbotLabs.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import { verifyEntraJwt } from './entraJwtVerifier.js';
17
+ /**
18
+ * Middleware for Microsoft Entra ID authentication
19
+ * Validates JWT tokens from Microsoft identity platform
20
+ */
21
+ export class EntraIDAuthenticator {
22
+ config;
23
+ constructor(config) {
24
+ this.config = config;
25
+ }
26
+ /**
27
+ * Validates the authorization header and extracts user information
28
+ */
29
+ async authenticate(req) {
30
+ if (!this.config.enabled) {
31
+ // Return a default user when authentication is disabled (for development)
32
+ return {
33
+ userId: 'dev-user',
34
+ tenantId: 'dev-tenant',
35
+ roles: ['user'],
36
+ permissions: ['browser:read', 'browser:write']
37
+ };
38
+ }
39
+ const authHeader = req.headers.authorization;
40
+ if (!authHeader || !authHeader.startsWith('Bearer '))
41
+ return null;
42
+ const token = authHeader.substring(7);
43
+ return await this.validateToken(token);
44
+ }
45
+ /**
46
+ * Validates JWT token from Microsoft identity platform
47
+ */
48
+ async validateToken(token) {
49
+ try {
50
+ const tenantId = this.config.tenantId;
51
+ const clientId = this.config.clientId;
52
+ if (!tenantId || !clientId)
53
+ throw new Error('Entra auth is enabled but AZURE_TENANT_ID or AZURE_CLIENT_ID is not configured');
54
+ const payload = await verifyEntraJwt(token, { tenantId, clientId });
55
+ const tid = payload.tid || tenantId;
56
+ const oid = payload.oid;
57
+ const sub = payload.sub;
58
+ const userId = oid || sub;
59
+ if (!userId)
60
+ return null;
61
+ const rolesClaim = payload.roles;
62
+ const roles = Array.isArray(rolesClaim)
63
+ ? rolesClaim.filter((r) => typeof r === 'string')
64
+ : [];
65
+ const scp = payload.scp;
66
+ const permissions = scp ? scp.split(' ').map(s => s.trim()).filter(Boolean) : [];
67
+ return {
68
+ userId,
69
+ tenantId: tid,
70
+ roles,
71
+ permissions,
72
+ };
73
+ }
74
+ catch (error) {
75
+ // TODO: Replace with proper logging system
76
+ // eslint-disable-next-line no-console
77
+ console.error('Token validation failed:', error);
78
+ return null;
79
+ }
80
+ }
81
+ /**
82
+ * Middleware function for HTTP requests
83
+ */
84
+ middleware() {
85
+ return async (req, res, next) => {
86
+ const user = await this.authenticate(req);
87
+ if (!user && this.config.enabled) {
88
+ res.statusCode = 401;
89
+ res.setHeader('Content-Type', 'application/json');
90
+ res.end(JSON.stringify({ error: 'Unauthorized', message: 'Valid authentication required' }));
91
+ return;
92
+ }
93
+ // Attach user to request for downstream handlers
94
+ req.user = user;
95
+ next();
96
+ };
97
+ }
98
+ }
99
+ /**
100
+ * Creates an Entra ID authenticator from environment variables
101
+ */
102
+ export function createEntraIDAuthenticator() {
103
+ const config = {
104
+ tenantId: process.env.AZURE_TENANT_ID,
105
+ clientId: process.env.AZURE_CLIENT_ID,
106
+ clientSecret: process.env.AZURE_CLIENT_SECRET,
107
+ enabled: process.env.ENTRA_AUTH_ENABLED === 'true'
108
+ };
109
+ return new EntraIDAuthenticator(config);
110
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Copyright (c) DarbotLabs.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+ import { ConfidentialClientApplication, LogLevel } from '@azure/msal-node';
17
+ // Cache MSAL client instances per tenant/client combination
18
+ const msalClientCache = new Map();
19
+ function getMsalClient(config) {
20
+ const { tenantId, clientId, clientSecret } = config;
21
+ const cacheKey = `${tenantId}:${clientId}`;
22
+ let client = msalClientCache.get(cacheKey);
23
+ if (!client) {
24
+ const msalConfig = {
25
+ auth: {
26
+ clientId,
27
+ authority: `https://login.microsoftonline.com/${tenantId}`,
28
+ clientSecret: clientSecret || process.env.AZURE_CLIENT_SECRET || '',
29
+ },
30
+ system: {
31
+ loggerOptions: {
32
+ loggerCallback(loglevel, message, containsPii) {
33
+ if (containsPii || loglevel > LogLevel.Warning)
34
+ return;
35
+ // eslint-disable-next-line no-console
36
+ console.error(`MSAL [${LogLevel[loglevel]}]: ${message}`);
37
+ },
38
+ piiLoggingEnabled: false,
39
+ logLevel: process.env.NODE_ENV === 'production' ? LogLevel.Warning : LogLevel.Info,
40
+ },
41
+ },
42
+ };
43
+ client = new ConfidentialClientApplication(msalConfig);
44
+ msalClientCache.set(cacheKey, client);
45
+ }
46
+ return client;
47
+ }
48
+ /**
49
+ * Decodes JWT payload without verification (for extracting claims after OBO validation)
50
+ */
51
+ function decodeJwtPayload(token) {
52
+ const parts = token.split('.');
53
+ if (parts.length !== 3)
54
+ throw new Error('Invalid JWT format');
55
+ const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
56
+ const payloadJson = Buffer.from(payloadBase64, 'base64').toString('utf8');
57
+ return JSON.parse(payloadJson);
58
+ }
59
+ /**
60
+ * Validates an Entra ID JWT token using MSAL On-Behalf-Of flow.
61
+ * This validates the token by attempting to exchange it, proving it's valid.
62
+ * If OBO fails (e.g., no client secret), falls back to basic claim validation.
63
+ */
64
+ export async function verifyEntraJwt(token, config) {
65
+ const { tenantId, clientId, clientSecret } = config;
66
+ if (!tenantId || !clientId)
67
+ throw new Error('Entra JWT validation misconfigured: missing tenantId or clientId');
68
+ // Decode token to extract claims
69
+ const payload = decodeJwtPayload(token);
70
+ // Validate basic claims
71
+ const issuerV2 = `https://login.microsoftonline.com/${tenantId}/v2.0`;
72
+ const issuerV1 = `https://sts.windows.net/${tenantId}/`;
73
+ const validIssuers = [issuerV2, issuerV1];
74
+ if (payload.iss && !validIssuers.includes(payload.iss))
75
+ throw new Error(`Invalid token issuer: ${payload.iss}`);
76
+ // Validate audience
77
+ const validAudiences = [clientId, `api://${clientId}`];
78
+ const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
79
+ const hasValidAudience = aud.some(a => a && validAudiences.includes(a));
80
+ if (!hasValidAudience)
81
+ throw new Error(`Invalid token audience: ${payload.aud}`);
82
+ // Validate expiration
83
+ const now = Math.floor(Date.now() / 1000);
84
+ if (payload.exp && payload.exp < now)
85
+ throw new Error('Token has expired');
86
+ if (payload.nbf && payload.nbf > now)
87
+ throw new Error('Token not yet valid');
88
+ // If we have a client secret, validate token via OBO flow (cryptographic validation)
89
+ const secret = clientSecret || process.env.AZURE_CLIENT_SECRET;
90
+ if (secret) {
91
+ try {
92
+ const msalClient = getMsalClient({ ...config, clientSecret: secret });
93
+ // Attempt OBO to validate the token - this proves token signature is valid
94
+ // We request the same scope to validate without actually needing a downstream API
95
+ await msalClient.acquireTokenOnBehalfOf({
96
+ oboAssertion: token,
97
+ scopes: [`api://${clientId}/.default`],
98
+ });
99
+ }
100
+ catch (oboError) {
101
+ // AADSTS65001 means the token is valid but user hasn't consented to the scope
102
+ // AADSTS50013 means assertion audience doesn't match - token may be for different app
103
+ // Other errors may indicate invalid token
104
+ const errorCode = oboError?.errorCode || '';
105
+ const errorMessage = oboError?.message || '';
106
+ // These error codes indicate the token itself is valid, just scope/consent issues
107
+ const validTokenErrors = ['AADSTS65001', 'AADSTS50013', 'AADSTS700024'];
108
+ const isValidTokenError = validTokenErrors.some(code => errorCode.includes(code) || errorMessage.includes(code));
109
+ if (!isValidTokenError) {
110
+ // Token is actually invalid
111
+ throw new Error(`Token validation failed: ${errorMessage}`);
112
+ }
113
+ // Token is valid, just can't do OBO for scope reasons - that's OK
114
+ }
115
+ }
116
+ return payload;
117
+ }