@vellumai/credential-executor 0.10.7 → 0.10.8-dev.202607102228.5945895
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/Dockerfile +1 -1
- package/node_modules/@vellumai/service-contracts/package.json +1 -2
- package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +104 -0
- package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +0 -2
- package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +118 -0
- package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +3 -5
- package/node_modules/@vellumai/service-contracts/src/index.ts +2 -4
- package/node_modules/@vellumai/service-contracts/src/rpc.ts +4 -447
- package/package.json +2 -3
- package/src/__tests__/bulk-set-credentials.test.ts +1 -1
- package/src/__tests__/local-standalone.test.ts +5 -36
- package/src/__tests__/managed-integration.test.ts +112 -91
- package/src/__tests__/managed-reconnect.test.ts +2 -2
- package/src/__tests__/transport.test.ts +23 -27
- package/src/cli.ts +1 -1
- package/src/index.ts +8 -88
- package/src/main.ts +228 -340
- package/src/paths.ts +4 -20
- package/src/server.ts +52 -469
- package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +0 -686
- package/node_modules/@vellumai/service-contracts/src/grants.ts +0 -184
- package/node_modules/@vellumai/service-contracts/src/rendering.ts +0 -135
- package/src/__tests__/command-executor.test.ts +0 -1879
- package/src/__tests__/command-validator.test.ts +0 -1405
- package/src/__tests__/command-workspace.test.ts +0 -1050
- package/src/__tests__/grant-store.test.ts +0 -689
- package/src/__tests__/http-executor.test.ts +0 -1336
- package/src/__tests__/http-policy.test.ts +0 -1069
- package/src/__tests__/local-materializers.test.ts +0 -860
- package/src/__tests__/local-token-refresh.test.ts +0 -361
- package/src/__tests__/manage-secure-command-tool.test.ts +0 -134
- package/src/__tests__/managed-lazy-getters.test.ts +0 -359
- package/src/__tests__/managed-materializers.test.ts +0 -1028
- package/src/__tests__/managed-rejection.test.ts +0 -43
- package/src/__tests__/toolstore.test.ts +0 -773
- package/src/audit/store.ts +0 -188
- package/src/commands/auth-adapters.ts +0 -169
- package/src/commands/egress-hooks.ts +0 -203
- package/src/commands/executor.ts +0 -1155
- package/src/commands/output-scan.ts +0 -157
- package/src/commands/profiles.ts +0 -286
- package/src/commands/validator.ts +0 -702
- package/src/commands/workspace.ts +0 -550
- package/src/grants/index.ts +0 -17
- package/src/grants/persistent-store.ts +0 -309
- package/src/grants/rpc-handlers.ts +0 -293
- package/src/grants/temporary-store.ts +0 -289
- package/src/http/audit.ts +0 -84
- package/src/http/executor.ts +0 -684
- package/src/http/path-template.ts +0 -245
- package/src/http/policy.ts +0 -238
- package/src/http/response-filter.ts +0 -233
- package/src/managed-errors.ts +0 -9
- package/src/managed-lazy-getters.ts +0 -106
- package/src/managed-main.ts +0 -822
- package/src/materializers/local-oauth-lookup.ts +0 -98
- package/src/materializers/local-token-refresh.ts +0 -287
- package/src/materializers/local.ts +0 -316
- package/src/materializers/managed-platform.ts +0 -295
- package/src/subjects/local.ts +0 -177
- package/src/subjects/managed.ts +0 -311
- package/src/subjects/policy.ts +0 -79
- package/src/toolstore/integrity.ts +0 -94
- package/src/toolstore/manifest.ts +0 -154
- package/src/toolstore/publish.ts +0 -571
package/src/http/executor.ts
DELETED
|
@@ -1,684 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP executor for the Credential Execution Service.
|
|
3
|
-
*
|
|
4
|
-
* Implements the full `make_authenticated_request` flow:
|
|
5
|
-
*
|
|
6
|
-
* 1. Resolve the credential handle to a local or managed subject.
|
|
7
|
-
* 2. Check grants (policy evaluation) — block off-grant requests before
|
|
8
|
-
* any network call.
|
|
9
|
-
* 3. Materialise the credential through the appropriate backend.
|
|
10
|
-
* 4. Inject auth into the outbound request according to the subject's
|
|
11
|
-
* handle type.
|
|
12
|
-
* 5. Perform the HTTP request.
|
|
13
|
-
* 6. Reject redirect hops that would violate the grant policy.
|
|
14
|
-
* 7. Filter the response through the PR 21 sanitisation pipeline.
|
|
15
|
-
* 8. Generate a token-free audit summary.
|
|
16
|
-
*
|
|
17
|
-
* Security invariants:
|
|
18
|
-
* - Off-grant requests never reach the network.
|
|
19
|
-
* - Caller-supplied raw auth headers are rejected.
|
|
20
|
-
* - Redirect hops to domains/paths outside the grant's scope are blocked.
|
|
21
|
-
* - The assistant runtime only sees sanitised HTTP results and audit
|
|
22
|
-
* summaries — never raw tokens or secrets.
|
|
23
|
-
* - Audit summaries are always token-free.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import type {
|
|
27
|
-
MakeAuthenticatedRequest,
|
|
28
|
-
MakeAuthenticatedRequestResponse,
|
|
29
|
-
} from "@vellumai/service-contracts/credential-rpc";
|
|
30
|
-
import { HandleType, parseHandle, hashProposal } from "@vellumai/service-contracts/credential-rpc";
|
|
31
|
-
import type { InjectionTemplate } from "@vellumai/credential-storage";
|
|
32
|
-
|
|
33
|
-
import { evaluateHttpPolicy, type PolicyResult } from "./policy.js";
|
|
34
|
-
import { filterHttpResponse, type RawHttpResponse } from "./response-filter.js";
|
|
35
|
-
import { generateHttpAuditSummary } from "./audit.js";
|
|
36
|
-
|
|
37
|
-
import type { AuditStore } from "../audit/store.js";
|
|
38
|
-
import type { PersistentGrantStore } from "../grants/persistent-store.js";
|
|
39
|
-
import type { TemporaryGrantStore } from "../grants/temporary-store.js";
|
|
40
|
-
|
|
41
|
-
import type { LocalMaterialiser, MaterialisedCredential } from "../materializers/local.js";
|
|
42
|
-
import { materializeManagedToken, type ManagedMaterializerOptions } from "../materializers/managed-platform.js";
|
|
43
|
-
import { resolveLocalSubject, type LocalSubjectResolverDeps } from "../subjects/local.js";
|
|
44
|
-
import { checkCredentialPolicy } from "../subjects/policy.js";
|
|
45
|
-
import { resolveManagedSubject, type ManagedSubjectResolverOptions } from "../subjects/managed.js";
|
|
46
|
-
|
|
47
|
-
// ---------------------------------------------------------------------------
|
|
48
|
-
// Auth injection constants
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Headers that are forbidden in caller-supplied requests. This is
|
|
53
|
-
* enforced at the policy layer, but we double-check before injection
|
|
54
|
-
* as defense-in-depth.
|
|
55
|
-
*/
|
|
56
|
-
const AUTH_HEADERS_TO_STRIP = new Set([
|
|
57
|
-
"authorization",
|
|
58
|
-
"cookie",
|
|
59
|
-
"proxy-authorization",
|
|
60
|
-
"x-api-key",
|
|
61
|
-
"x-auth-token",
|
|
62
|
-
]);
|
|
63
|
-
|
|
64
|
-
// ---------------------------------------------------------------------------
|
|
65
|
-
// Executor dependencies
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
|
|
68
|
-
export interface HttpExecutorDeps {
|
|
69
|
-
/** Persistent grant store for policy evaluation. */
|
|
70
|
-
persistentGrantStore: PersistentGrantStore;
|
|
71
|
-
/** Temporary grant store for policy evaluation. */
|
|
72
|
-
temporaryGrantStore: TemporaryGrantStore;
|
|
73
|
-
/** Local materialiser for local_static and local_oauth handles. */
|
|
74
|
-
localMaterialiser: LocalMaterialiser;
|
|
75
|
-
/** Dependencies for local subject resolution. */
|
|
76
|
-
localSubjectDeps: LocalSubjectResolverDeps;
|
|
77
|
-
/** Options for managed subject resolution (null if managed mode is unavailable). */
|
|
78
|
-
managedSubjectOptions?: ManagedSubjectResolverOptions;
|
|
79
|
-
/** Options for managed token materialisation (null if managed mode is unavailable). */
|
|
80
|
-
managedMaterializerOptions?: ManagedMaterializerOptions;
|
|
81
|
-
/** Audit store for persisting token-free audit records. */
|
|
82
|
-
auditStore: AuditStore;
|
|
83
|
-
/** Session ID for audit records, injected per call from the calling connection's SessionContext. */
|
|
84
|
-
sessionId?: string;
|
|
85
|
-
/** Optional custom fetch implementation (for testing). */
|
|
86
|
-
fetch?: typeof globalThis.fetch;
|
|
87
|
-
/** Optional logger. */
|
|
88
|
-
logger?: Pick<Console, "log" | "warn" | "error">;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
// ---------------------------------------------------------------------------
|
|
92
|
-
// Redirect policy
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Maximum number of redirects to follow before aborting.
|
|
97
|
-
*/
|
|
98
|
-
const MAX_REDIRECTS = 5;
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* HTTP status codes that indicate a redirect.
|
|
102
|
-
*/
|
|
103
|
-
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
104
|
-
|
|
105
|
-
// ---------------------------------------------------------------------------
|
|
106
|
-
// Executor implementation
|
|
107
|
-
// ---------------------------------------------------------------------------
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Execute an authenticated HTTP request through the full CES pipeline.
|
|
111
|
-
*
|
|
112
|
-
* This is the handler implementation for the `make_authenticated_request`
|
|
113
|
-
* RPC method. It is pure logic with injected dependencies, making it
|
|
114
|
-
* testable without real network calls or credential stores.
|
|
115
|
-
*/
|
|
116
|
-
export async function executeAuthenticatedHttpRequest(
|
|
117
|
-
request: MakeAuthenticatedRequest,
|
|
118
|
-
deps: HttpExecutorDeps,
|
|
119
|
-
): Promise<MakeAuthenticatedRequestResponse> {
|
|
120
|
-
const logger = deps.logger ?? console;
|
|
121
|
-
|
|
122
|
-
// 1. Parse the handle to determine source (local vs managed)
|
|
123
|
-
const parseResult = parseHandle(request.credentialHandle);
|
|
124
|
-
if (!parseResult.ok) {
|
|
125
|
-
return {
|
|
126
|
-
success: false,
|
|
127
|
-
error: {
|
|
128
|
-
code: "INVALID_HANDLE",
|
|
129
|
-
message: parseResult.error,
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// 2. Evaluate grant policy — blocks off-grant requests before network
|
|
135
|
-
const policyResult = evaluateHttpPolicy(
|
|
136
|
-
{
|
|
137
|
-
credentialHandle: request.credentialHandle,
|
|
138
|
-
method: request.method,
|
|
139
|
-
url: request.url,
|
|
140
|
-
headers: request.headers,
|
|
141
|
-
purpose: request.purpose,
|
|
142
|
-
grantId: request.grantId,
|
|
143
|
-
conversationId: request.conversationId,
|
|
144
|
-
},
|
|
145
|
-
deps.persistentGrantStore,
|
|
146
|
-
deps.temporaryGrantStore,
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
if (!policyResult.allowed) {
|
|
150
|
-
if (policyResult.reason === "forbidden_headers") {
|
|
151
|
-
return {
|
|
152
|
-
success: false,
|
|
153
|
-
error: {
|
|
154
|
-
code: "FORBIDDEN_HEADERS",
|
|
155
|
-
message: `Request contains forbidden auth headers that the agent must not set: ${policyResult.forbiddenHeaders.join(", ")}. CES injects authentication — the caller must not supply raw auth headers.`,
|
|
156
|
-
},
|
|
157
|
-
};
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// approval_required — return the proposal so the assistant can prompt
|
|
161
|
-
return {
|
|
162
|
-
success: false,
|
|
163
|
-
error: {
|
|
164
|
-
code: "APPROVAL_REQUIRED",
|
|
165
|
-
message: `No active grant covers this request. Approval is required.`,
|
|
166
|
-
details: {
|
|
167
|
-
proposal: policyResult.proposal,
|
|
168
|
-
proposalHash: hashProposal(policyResult.proposal),
|
|
169
|
-
},
|
|
170
|
-
},
|
|
171
|
-
};
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const grantId = policyResult.grantId;
|
|
175
|
-
|
|
176
|
-
// 3. Materialise the credential
|
|
177
|
-
const materialiseResult = await materialiseCredential(
|
|
178
|
-
parseResult.handle.type,
|
|
179
|
-
request.credentialHandle,
|
|
180
|
-
deps,
|
|
181
|
-
);
|
|
182
|
-
|
|
183
|
-
if (!materialiseResult.ok) {
|
|
184
|
-
const audit = generateHttpAuditSummary({
|
|
185
|
-
credentialHandle: request.credentialHandle,
|
|
186
|
-
grantId,
|
|
187
|
-
sessionId: deps.sessionId ?? "unknown",
|
|
188
|
-
method: request.method,
|
|
189
|
-
url: request.url,
|
|
190
|
-
success: false,
|
|
191
|
-
errorMessage: materialiseResult.error,
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
try { deps.auditStore.append(audit); } catch { /* audit persistence must not block execution */ }
|
|
195
|
-
|
|
196
|
-
return {
|
|
197
|
-
success: false,
|
|
198
|
-
error: {
|
|
199
|
-
code: "MATERIALISATION_FAILED",
|
|
200
|
-
message: materialiseResult.error,
|
|
201
|
-
},
|
|
202
|
-
auditId: audit.auditId,
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const { credential, secrets } = materialiseResult;
|
|
207
|
-
|
|
208
|
-
// 4. Build the outbound request with injected auth
|
|
209
|
-
const authenticated = buildAuthenticatedRequest(
|
|
210
|
-
request.url,
|
|
211
|
-
request.headers ?? {},
|
|
212
|
-
credential,
|
|
213
|
-
);
|
|
214
|
-
|
|
215
|
-
// 5. Perform the HTTP request with redirect enforcement
|
|
216
|
-
let rawResponse: RawHttpResponse;
|
|
217
|
-
try {
|
|
218
|
-
rawResponse = await performHttpRequest(
|
|
219
|
-
request.method,
|
|
220
|
-
authenticated.url,
|
|
221
|
-
authenticated.headers,
|
|
222
|
-
request.body,
|
|
223
|
-
policyResult,
|
|
224
|
-
request.credentialHandle,
|
|
225
|
-
deps,
|
|
226
|
-
credential,
|
|
227
|
-
request.headers ?? {},
|
|
228
|
-
);
|
|
229
|
-
} catch (err) {
|
|
230
|
-
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
231
|
-
// Sanitise error messages to avoid leaking secrets
|
|
232
|
-
const safeError = sanitiseErrorMessage(errorMessage, secrets);
|
|
233
|
-
|
|
234
|
-
const audit = generateHttpAuditSummary({
|
|
235
|
-
credentialHandle: request.credentialHandle,
|
|
236
|
-
grantId,
|
|
237
|
-
sessionId: deps.sessionId ?? "unknown",
|
|
238
|
-
method: request.method,
|
|
239
|
-
url: request.url,
|
|
240
|
-
success: false,
|
|
241
|
-
errorMessage: safeError,
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
try { deps.auditStore.append(audit); } catch { /* audit persistence must not block execution */ }
|
|
245
|
-
|
|
246
|
-
return {
|
|
247
|
-
success: false,
|
|
248
|
-
error: {
|
|
249
|
-
code: "HTTP_REQUEST_FAILED",
|
|
250
|
-
message: safeError,
|
|
251
|
-
},
|
|
252
|
-
auditId: audit.auditId,
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// 6. Filter the response through the sanitisation pipeline
|
|
257
|
-
const filtered = filterHttpResponse(rawResponse, secrets);
|
|
258
|
-
|
|
259
|
-
// 7. Generate and persist audit summary
|
|
260
|
-
const audit = generateHttpAuditSummary({
|
|
261
|
-
credentialHandle: request.credentialHandle,
|
|
262
|
-
grantId,
|
|
263
|
-
sessionId: deps.sessionId ?? "unknown",
|
|
264
|
-
method: request.method,
|
|
265
|
-
url: request.url,
|
|
266
|
-
success: true,
|
|
267
|
-
statusCode: rawResponse.statusCode,
|
|
268
|
-
});
|
|
269
|
-
|
|
270
|
-
try { deps.auditStore.append(audit); } catch { /* audit persistence must not block execution */ }
|
|
271
|
-
|
|
272
|
-
logger.log(
|
|
273
|
-
`[ces-http] ${request.method} ${request.url} -> ${rawResponse.statusCode} (grant=${grantId})`,
|
|
274
|
-
);
|
|
275
|
-
|
|
276
|
-
return {
|
|
277
|
-
success: true,
|
|
278
|
-
statusCode: filtered.statusCode,
|
|
279
|
-
responseHeaders: filtered.headers,
|
|
280
|
-
responseBody: filtered.body,
|
|
281
|
-
auditId: audit.auditId,
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// ---------------------------------------------------------------------------
|
|
286
|
-
// Credential materialisation dispatch
|
|
287
|
-
// ---------------------------------------------------------------------------
|
|
288
|
-
|
|
289
|
-
interface MaterialiseSuccess {
|
|
290
|
-
ok: true;
|
|
291
|
-
credential: MaterialisedCredential;
|
|
292
|
-
/** Secret values to scrub from response bodies (defense-in-depth). */
|
|
293
|
-
secrets: string[];
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
interface MaterialiseFailure {
|
|
297
|
-
ok: false;
|
|
298
|
-
error: string;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
type MaterialiseResult = MaterialiseSuccess | MaterialiseFailure;
|
|
302
|
-
|
|
303
|
-
async function materialiseCredential(
|
|
304
|
-
handleType: string,
|
|
305
|
-
rawHandle: string,
|
|
306
|
-
deps: HttpExecutorDeps,
|
|
307
|
-
): Promise<MaterialiseResult> {
|
|
308
|
-
switch (handleType) {
|
|
309
|
-
case HandleType.LocalStatic:
|
|
310
|
-
case HandleType.LocalOAuth: {
|
|
311
|
-
// Resolve local subject
|
|
312
|
-
const subjectResult = resolveLocalSubject(rawHandle, deps.localSubjectDeps);
|
|
313
|
-
if (!subjectResult.ok) {
|
|
314
|
-
return { ok: false, error: subjectResult.error };
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Enforce credential-level policies for local static handles.
|
|
318
|
-
// OAuth connections don't carry allowedTools/allowedDomains in the
|
|
319
|
-
// same way, so policy checks are skipped for OAuth.
|
|
320
|
-
if (subjectResult.subject.type === HandleType.LocalStatic) {
|
|
321
|
-
const policyCheck = checkCredentialPolicy(
|
|
322
|
-
subjectResult.subject.metadata,
|
|
323
|
-
"make_authenticated_request",
|
|
324
|
-
);
|
|
325
|
-
if (!policyCheck.ok) {
|
|
326
|
-
return { ok: false, error: policyCheck.error! };
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// Materialise through the local materialiser
|
|
331
|
-
const matResult = await deps.localMaterialiser.materialise(subjectResult.subject);
|
|
332
|
-
if (!matResult.ok) {
|
|
333
|
-
return { ok: false, error: matResult.error };
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
return {
|
|
337
|
-
ok: true,
|
|
338
|
-
credential: matResult.credential,
|
|
339
|
-
secrets: [matResult.credential.value],
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
case HandleType.PlatformOAuth: {
|
|
344
|
-
if (!deps.managedSubjectOptions || !deps.managedMaterializerOptions) {
|
|
345
|
-
return {
|
|
346
|
-
ok: false,
|
|
347
|
-
error: "Managed OAuth is not configured. Platform URL and API key are required.",
|
|
348
|
-
};
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
// Resolve managed subject
|
|
352
|
-
const subjectResult = await resolveManagedSubject(
|
|
353
|
-
rawHandle,
|
|
354
|
-
deps.managedSubjectOptions,
|
|
355
|
-
);
|
|
356
|
-
if (!subjectResult.ok) {
|
|
357
|
-
return { ok: false, error: subjectResult.error.message };
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// Materialise through the managed materialiser
|
|
361
|
-
const matResult = await materializeManagedToken(
|
|
362
|
-
subjectResult.subject,
|
|
363
|
-
deps.managedMaterializerOptions,
|
|
364
|
-
);
|
|
365
|
-
if (!matResult.ok) {
|
|
366
|
-
return { ok: false, error: matResult.error.message };
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
return {
|
|
370
|
-
ok: true,
|
|
371
|
-
credential: {
|
|
372
|
-
value: matResult.token.accessToken,
|
|
373
|
-
handleType: HandleType.PlatformOAuth,
|
|
374
|
-
expiresAt: matResult.token.expiresAt,
|
|
375
|
-
},
|
|
376
|
-
secrets: [matResult.token.accessToken],
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
default:
|
|
381
|
-
return {
|
|
382
|
-
ok: false,
|
|
383
|
-
error: `Unsupported handle type "${handleType}" for HTTP execution`,
|
|
384
|
-
};
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// ---------------------------------------------------------------------------
|
|
389
|
-
// Auth injection
|
|
390
|
-
// ---------------------------------------------------------------------------
|
|
391
|
-
|
|
392
|
-
/**
|
|
393
|
-
* Result of building an authenticated request — may contain a modified URL
|
|
394
|
-
* (e.g. when the credential is injected as a query parameter).
|
|
395
|
-
*/
|
|
396
|
-
interface AuthenticatedRequest {
|
|
397
|
-
headers: Record<string, string>;
|
|
398
|
-
url: string;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/**
|
|
402
|
-
* Build the outbound request by:
|
|
403
|
-
* 1. Stripping any caller-supplied auth headers (defense-in-depth).
|
|
404
|
-
* 2. Injecting the credential using the appropriate strategy.
|
|
405
|
-
*
|
|
406
|
-
* For `local_static` handles, the credential's `injectionTemplates` are
|
|
407
|
-
* checked for a template matching the target URL's hostname. If found,
|
|
408
|
-
* the template controls how the credential is injected (header name,
|
|
409
|
-
* value prefix, or query parameter). If no matching template exists,
|
|
410
|
-
* falls back to `Authorization: Bearer <value>`.
|
|
411
|
-
*
|
|
412
|
-
* OAuth handles always use `Authorization: Bearer <value>`.
|
|
413
|
-
*/
|
|
414
|
-
function buildAuthenticatedRequest(
|
|
415
|
-
url: string,
|
|
416
|
-
callerHeaders: Record<string, string>,
|
|
417
|
-
credential: MaterialisedCredential,
|
|
418
|
-
): AuthenticatedRequest {
|
|
419
|
-
const headers: Record<string, string> = {};
|
|
420
|
-
|
|
421
|
-
// Copy caller headers, stripping any auth headers
|
|
422
|
-
for (const [key, value] of Object.entries(callerHeaders)) {
|
|
423
|
-
if (!AUTH_HEADERS_TO_STRIP.has(key.toLowerCase())) {
|
|
424
|
-
headers[key] = value;
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
let finalUrl = url;
|
|
429
|
-
|
|
430
|
-
// Inject credential based on handle type
|
|
431
|
-
switch (credential.handleType) {
|
|
432
|
-
case HandleType.LocalStatic: {
|
|
433
|
-
// Check for a matching injection template
|
|
434
|
-
const template = findMatchingTemplate(url, credential.injectionTemplates);
|
|
435
|
-
if (template) {
|
|
436
|
-
if (template.injectionType === "header") {
|
|
437
|
-
const headerName = template.headerName ?? "Authorization";
|
|
438
|
-
const prefix = template.valuePrefix ?? "";
|
|
439
|
-
headers[headerName] = `${prefix}${credential.value}`;
|
|
440
|
-
} else if (template.injectionType === "query") {
|
|
441
|
-
const paramName = template.queryParamName ?? "api_key";
|
|
442
|
-
finalUrl = appendQueryParam(url, paramName, credential.value);
|
|
443
|
-
}
|
|
444
|
-
} else {
|
|
445
|
-
// No matching template — fall back to Bearer auth
|
|
446
|
-
headers["Authorization"] = `Bearer ${credential.value}`;
|
|
447
|
-
}
|
|
448
|
-
break;
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
case HandleType.LocalOAuth:
|
|
452
|
-
case HandleType.PlatformOAuth:
|
|
453
|
-
// OAuth tokens are always Bearer tokens.
|
|
454
|
-
headers["Authorization"] = `Bearer ${credential.value}`;
|
|
455
|
-
break;
|
|
456
|
-
|
|
457
|
-
default:
|
|
458
|
-
// Unknown type — inject as Bearer (fail-open on injection is OK
|
|
459
|
-
// because the grant policy already vetted the request).
|
|
460
|
-
headers["Authorization"] = `Bearer ${credential.value}`;
|
|
461
|
-
break;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
return { headers, url: finalUrl };
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
/**
|
|
468
|
-
* Find the first injection template whose `hostPattern` matches the
|
|
469
|
-
* target URL's hostname. Returns undefined if no template matches or
|
|
470
|
-
* no templates are defined.
|
|
471
|
-
*/
|
|
472
|
-
function findMatchingTemplate(
|
|
473
|
-
url: string,
|
|
474
|
-
templates: InjectionTemplate[] | undefined,
|
|
475
|
-
): InjectionTemplate | undefined {
|
|
476
|
-
if (!templates || templates.length === 0) return undefined;
|
|
477
|
-
|
|
478
|
-
let hostname: string;
|
|
479
|
-
try {
|
|
480
|
-
hostname = new URL(url).hostname;
|
|
481
|
-
} catch {
|
|
482
|
-
return undefined;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
return templates.find((t) => matchHostPattern(t.hostPattern, hostname));
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
/**
|
|
489
|
-
* Simple glob-style host pattern matching.
|
|
490
|
-
*
|
|
491
|
-
* Supports:
|
|
492
|
-
* - Exact match: `"api.fal.ai"` matches `"api.fal.ai"`
|
|
493
|
-
* - Leading wildcard: `"*.fal.ai"` matches `"api.fal.ai"`, `"queue.fal.ai"`
|
|
494
|
-
* - Bare wildcard: `"*"` matches everything
|
|
495
|
-
*/
|
|
496
|
-
function matchHostPattern(pattern: string, hostname: string): boolean {
|
|
497
|
-
const lPattern = pattern.toLowerCase();
|
|
498
|
-
const lHostname = hostname.toLowerCase();
|
|
499
|
-
if (lPattern === "*") return true;
|
|
500
|
-
if (lPattern.startsWith("*.")) {
|
|
501
|
-
const suffix = lPattern.slice(1); // e.g. ".fal.ai"
|
|
502
|
-
return lHostname.endsWith(suffix) || lHostname === lPattern.slice(2);
|
|
503
|
-
}
|
|
504
|
-
return lPattern === lHostname;
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/**
|
|
508
|
-
* Append a query parameter to a URL, preserving existing query params.
|
|
509
|
-
*/
|
|
510
|
-
function appendQueryParam(url: string, name: string, value: string): string {
|
|
511
|
-
try {
|
|
512
|
-
const parsed = new URL(url);
|
|
513
|
-
parsed.searchParams.set(name, value);
|
|
514
|
-
return parsed.toString();
|
|
515
|
-
} catch {
|
|
516
|
-
// If URL parsing fails, fall back to naive append
|
|
517
|
-
const separator = url.includes("?") ? "&" : "?";
|
|
518
|
-
return `${url}${separator}${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// ---------------------------------------------------------------------------
|
|
523
|
-
// HTTP request execution with redirect enforcement
|
|
524
|
-
// ---------------------------------------------------------------------------
|
|
525
|
-
|
|
526
|
-
/**
|
|
527
|
-
* Perform an HTTP request, following redirects only when each hop
|
|
528
|
-
* independently satisfies the grant policy.
|
|
529
|
-
*/
|
|
530
|
-
async function performHttpRequest(
|
|
531
|
-
method: string,
|
|
532
|
-
url: string,
|
|
533
|
-
headers: Record<string, string>,
|
|
534
|
-
body: unknown | undefined,
|
|
535
|
-
originalPolicy: PolicyResult & { allowed: true },
|
|
536
|
-
credentialHandle: string,
|
|
537
|
-
deps: HttpExecutorDeps,
|
|
538
|
-
credential?: MaterialisedCredential,
|
|
539
|
-
callerHeaders?: Record<string, string>,
|
|
540
|
-
): Promise<RawHttpResponse> {
|
|
541
|
-
const fetchFn = deps.fetch ?? globalThis.fetch;
|
|
542
|
-
|
|
543
|
-
// Preserve the original caller headers (before auth injection) so that
|
|
544
|
-
// redirect re-authentication starts from a clean slate on each hop.
|
|
545
|
-
// This prevents previously injected auth headers from being treated as
|
|
546
|
-
// caller headers and leaking credentials across redirect hops.
|
|
547
|
-
const originalCallerHeaders = callerHeaders ?? headers;
|
|
548
|
-
|
|
549
|
-
let currentUrl = url;
|
|
550
|
-
let currentMethod = method;
|
|
551
|
-
let currentHeaders = headers;
|
|
552
|
-
let currentBody = body;
|
|
553
|
-
let redirectCount = 0;
|
|
554
|
-
|
|
555
|
-
while (true) {
|
|
556
|
-
// Build fetch options — disable automatic redirect following so we
|
|
557
|
-
// can enforce grant policy on each hop.
|
|
558
|
-
const fetchOptions: RequestInit = {
|
|
559
|
-
method: currentMethod,
|
|
560
|
-
headers: currentHeaders,
|
|
561
|
-
redirect: "manual",
|
|
562
|
-
};
|
|
563
|
-
|
|
564
|
-
if (currentBody !== undefined && currentBody !== null) {
|
|
565
|
-
fetchOptions.body =
|
|
566
|
-
typeof currentBody === "string"
|
|
567
|
-
? currentBody
|
|
568
|
-
: JSON.stringify(currentBody);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
const response = await fetchFn(currentUrl, fetchOptions);
|
|
572
|
-
|
|
573
|
-
// Check for redirect
|
|
574
|
-
if (REDIRECT_STATUSES.has(response.status)) {
|
|
575
|
-
redirectCount++;
|
|
576
|
-
if (redirectCount > MAX_REDIRECTS) {
|
|
577
|
-
throw new Error(
|
|
578
|
-
`Too many redirects (exceeded ${MAX_REDIRECTS}). Aborting.`,
|
|
579
|
-
);
|
|
580
|
-
}
|
|
581
|
-
|
|
582
|
-
const locationHeader = response.headers.get("location");
|
|
583
|
-
if (!locationHeader) {
|
|
584
|
-
throw new Error(
|
|
585
|
-
`Redirect response (${response.status}) missing Location header.`,
|
|
586
|
-
);
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
// Resolve the redirect URL (may be relative)
|
|
590
|
-
const redirectUrl = new URL(locationHeader, currentUrl).toString();
|
|
591
|
-
|
|
592
|
-
// Determine the method that will actually be used on the next hop.
|
|
593
|
-
// 303 converts any method to GET (per RFC 9110 §15.4.4); other
|
|
594
|
-
// redirect statuses preserve the method.
|
|
595
|
-
const nextMethod = response.status === 303 ? "GET" : currentMethod;
|
|
596
|
-
|
|
597
|
-
// Enforce grant policy on the redirect target — the redirect must
|
|
598
|
-
// independently satisfy the same credential handle's grant policy
|
|
599
|
-
// using the method we will actually send.
|
|
600
|
-
// Sanitise purpose string to avoid leaking query-injected secrets.
|
|
601
|
-
const redirectPolicy = evaluateHttpPolicy(
|
|
602
|
-
{
|
|
603
|
-
credentialHandle,
|
|
604
|
-
method: nextMethod,
|
|
605
|
-
url: redirectUrl,
|
|
606
|
-
purpose: `redirect from ${sanitiseUrl(currentUrl)}`,
|
|
607
|
-
},
|
|
608
|
-
deps.persistentGrantStore,
|
|
609
|
-
deps.temporaryGrantStore,
|
|
610
|
-
);
|
|
611
|
-
|
|
612
|
-
if (!redirectPolicy.allowed) {
|
|
613
|
-
throw new Error(
|
|
614
|
-
`Redirect to ${sanitiseUrl(redirectUrl)} denied: the redirect target does not satisfy the grant policy for credential handle "${credentialHandle}".`,
|
|
615
|
-
);
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// Apply the method/body changes for 303 redirects
|
|
619
|
-
if (response.status === 303) {
|
|
620
|
-
currentMethod = "GET";
|
|
621
|
-
currentBody = undefined;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
// Re-apply auth injection for the redirect URL starting from the
|
|
625
|
-
// original caller headers — not currentHeaders which already contain
|
|
626
|
-
// auth injected on the previous hop. This prevents credential leakage
|
|
627
|
-
// across multi-redirect flows.
|
|
628
|
-
if (credential) {
|
|
629
|
-
const reAuthenticated = buildAuthenticatedRequest(
|
|
630
|
-
redirectUrl,
|
|
631
|
-
originalCallerHeaders,
|
|
632
|
-
credential,
|
|
633
|
-
);
|
|
634
|
-
currentUrl = reAuthenticated.url;
|
|
635
|
-
currentHeaders = reAuthenticated.headers;
|
|
636
|
-
} else {
|
|
637
|
-
currentUrl = redirectUrl;
|
|
638
|
-
}
|
|
639
|
-
continue;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
// Not a redirect — read the response
|
|
643
|
-
const responseBody = await response.text();
|
|
644
|
-
const responseHeaders: Record<string, string> = {};
|
|
645
|
-
response.headers.forEach((value, key) => {
|
|
646
|
-
responseHeaders[key] = value;
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
return {
|
|
650
|
-
statusCode: response.status,
|
|
651
|
-
headers: responseHeaders,
|
|
652
|
-
body: responseBody,
|
|
653
|
-
};
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// ---------------------------------------------------------------------------
|
|
658
|
-
// Helpers
|
|
659
|
-
// ---------------------------------------------------------------------------
|
|
660
|
-
|
|
661
|
-
/**
|
|
662
|
-
* Sanitise a URL for error messages by stripping query parameters
|
|
663
|
-
* (which may contain sensitive values).
|
|
664
|
-
*/
|
|
665
|
-
function sanitiseUrl(url: string): string {
|
|
666
|
-
try {
|
|
667
|
-
const parsed = new URL(url);
|
|
668
|
-
return `${parsed.protocol}//${parsed.host}${parsed.pathname}`;
|
|
669
|
-
} catch {
|
|
670
|
-
return "[invalid-url]";
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
/**
|
|
675
|
-
* Sanitise error messages to avoid leaking secret values.
|
|
676
|
-
*/
|
|
677
|
-
function sanitiseErrorMessage(message: string, secrets: string[]): string {
|
|
678
|
-
let result = message;
|
|
679
|
-
for (const secret of secrets) {
|
|
680
|
-
if (secret.length < 8) continue;
|
|
681
|
-
result = result.replaceAll(secret, "[CES:REDACTED]");
|
|
682
|
-
}
|
|
683
|
-
return result;
|
|
684
|
-
}
|