@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.
- package/LICENSE +1 -1
- package/README.md +249 -158
- package/cli.js +1 -1
- package/config.d.ts +77 -1
- package/index.d.ts +1 -1
- package/index.js +1 -1
- package/lib/ai/context.js +150 -0
- package/lib/ai/guardrails.js +382 -0
- package/lib/ai/integration.js +397 -0
- package/lib/ai/intent.js +237 -0
- package/lib/ai/manualPromise.js +111 -0
- package/lib/ai/memory.js +273 -0
- package/lib/ai/ml-scorer.js +265 -0
- package/lib/ai/orchestrator-tools.js +292 -0
- package/lib/ai/orchestrator.js +473 -0
- package/lib/ai/planner.js +300 -0
- package/lib/ai/reporter.js +493 -0
- package/lib/ai/workflow.js +407 -0
- package/lib/auth/apiKeyAuth.js +46 -0
- package/lib/auth/entraAuth.js +110 -0
- package/lib/auth/entraJwtVerifier.js +117 -0
- package/lib/auth/index.js +210 -0
- package/lib/auth/managedIdentityAuth.js +175 -0
- package/lib/auth/mcpOAuthProvider.js +186 -0
- package/lib/auth/tunnelAuth.js +120 -0
- package/lib/browserContextFactory.js +1 -1
- package/lib/browserServer.js +1 -1
- package/lib/cdpRelay.js +2 -2
- package/lib/common.js +68 -0
- package/lib/config.js +62 -3
- package/lib/connection.js +1 -1
- package/lib/context.js +1 -1
- package/lib/fileUtils.js +1 -1
- package/lib/guardrails.js +382 -0
- package/lib/health.js +178 -0
- package/lib/httpServer.js +1 -1
- package/lib/index.js +1 -1
- package/lib/javascript.js +1 -1
- package/lib/manualPromise.js +1 -1
- package/lib/memory.js +273 -0
- package/lib/openapi.js +373 -0
- package/lib/orchestrator.js +473 -0
- package/lib/package.js +1 -1
- package/lib/pageSnapshot.js +17 -2
- package/lib/planner.js +302 -0
- package/lib/program.js +17 -5
- package/lib/reporter.js +493 -0
- package/lib/resources/resource.js +1 -1
- package/lib/server.js +5 -3
- package/lib/tab.js +1 -1
- package/lib/tools/ai-native.js +298 -0
- package/lib/tools/autonomous.js +147 -0
- package/lib/tools/clock.js +183 -0
- package/lib/tools/common.js +1 -1
- package/lib/tools/console.js +1 -1
- package/lib/tools/diagnostics.js +132 -0
- package/lib/tools/dialogs.js +1 -1
- package/lib/tools/emulation.js +155 -0
- package/lib/tools/files.js +1 -1
- package/lib/tools/install.js +1 -1
- package/lib/tools/keyboard.js +1 -1
- package/lib/tools/navigate.js +1 -1
- package/lib/tools/network.js +1 -1
- package/lib/tools/pageSnapshot.js +58 -0
- package/lib/tools/pdf.js +1 -1
- package/lib/tools/profiles.js +76 -25
- package/lib/tools/screenshot.js +1 -1
- package/lib/tools/scroll.js +93 -0
- package/lib/tools/snapshot.js +1 -1
- package/lib/tools/storage.js +328 -0
- package/lib/tools/tab.js +16 -0
- package/lib/tools/tabs.js +1 -1
- package/lib/tools/testing.js +1 -1
- package/lib/tools/tool.js +1 -1
- package/lib/tools/utils.js +1 -1
- package/lib/tools/vision.js +1 -1
- package/lib/tools/wait.js +1 -1
- package/lib/tools.js +22 -1
- package/lib/transport.js +251 -31
- 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
|
+
}
|