@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.
Files changed (65) hide show
  1. package/Dockerfile +1 -1
  2. package/node_modules/@vellumai/service-contracts/package.json +1 -2
  3. package/node_modules/@vellumai/service-contracts/src/__tests__/attachment-naming.test.ts +104 -0
  4. package/node_modules/@vellumai/service-contracts/src/__tests__/contracts.test.ts +0 -2
  5. package/node_modules/@vellumai/service-contracts/src/attachment-naming.ts +118 -0
  6. package/node_modules/@vellumai/service-contracts/src/credential-rpc.ts +3 -5
  7. package/node_modules/@vellumai/service-contracts/src/index.ts +2 -4
  8. package/node_modules/@vellumai/service-contracts/src/rpc.ts +4 -447
  9. package/package.json +2 -3
  10. package/src/__tests__/bulk-set-credentials.test.ts +1 -1
  11. package/src/__tests__/local-standalone.test.ts +5 -36
  12. package/src/__tests__/managed-integration.test.ts +112 -91
  13. package/src/__tests__/managed-reconnect.test.ts +2 -2
  14. package/src/__tests__/transport.test.ts +23 -27
  15. package/src/cli.ts +1 -1
  16. package/src/index.ts +8 -88
  17. package/src/main.ts +228 -340
  18. package/src/paths.ts +4 -20
  19. package/src/server.ts +52 -469
  20. package/node_modules/@vellumai/service-contracts/src/__tests__/grants.test.ts +0 -686
  21. package/node_modules/@vellumai/service-contracts/src/grants.ts +0 -184
  22. package/node_modules/@vellumai/service-contracts/src/rendering.ts +0 -135
  23. package/src/__tests__/command-executor.test.ts +0 -1879
  24. package/src/__tests__/command-validator.test.ts +0 -1405
  25. package/src/__tests__/command-workspace.test.ts +0 -1050
  26. package/src/__tests__/grant-store.test.ts +0 -689
  27. package/src/__tests__/http-executor.test.ts +0 -1336
  28. package/src/__tests__/http-policy.test.ts +0 -1069
  29. package/src/__tests__/local-materializers.test.ts +0 -860
  30. package/src/__tests__/local-token-refresh.test.ts +0 -361
  31. package/src/__tests__/manage-secure-command-tool.test.ts +0 -134
  32. package/src/__tests__/managed-lazy-getters.test.ts +0 -359
  33. package/src/__tests__/managed-materializers.test.ts +0 -1028
  34. package/src/__tests__/managed-rejection.test.ts +0 -43
  35. package/src/__tests__/toolstore.test.ts +0 -773
  36. package/src/audit/store.ts +0 -188
  37. package/src/commands/auth-adapters.ts +0 -169
  38. package/src/commands/egress-hooks.ts +0 -203
  39. package/src/commands/executor.ts +0 -1155
  40. package/src/commands/output-scan.ts +0 -157
  41. package/src/commands/profiles.ts +0 -286
  42. package/src/commands/validator.ts +0 -702
  43. package/src/commands/workspace.ts +0 -550
  44. package/src/grants/index.ts +0 -17
  45. package/src/grants/persistent-store.ts +0 -309
  46. package/src/grants/rpc-handlers.ts +0 -293
  47. package/src/grants/temporary-store.ts +0 -289
  48. package/src/http/audit.ts +0 -84
  49. package/src/http/executor.ts +0 -684
  50. package/src/http/path-template.ts +0 -245
  51. package/src/http/policy.ts +0 -238
  52. package/src/http/response-filter.ts +0 -233
  53. package/src/managed-errors.ts +0 -9
  54. package/src/managed-lazy-getters.ts +0 -106
  55. package/src/managed-main.ts +0 -822
  56. package/src/materializers/local-oauth-lookup.ts +0 -98
  57. package/src/materializers/local-token-refresh.ts +0 -287
  58. package/src/materializers/local.ts +0 -316
  59. package/src/materializers/managed-platform.ts +0 -295
  60. package/src/subjects/local.ts +0 -177
  61. package/src/subjects/managed.ts +0 -311
  62. package/src/subjects/policy.ts +0 -79
  63. package/src/toolstore/integrity.ts +0 -94
  64. package/src/toolstore/manifest.ts +0 -154
  65. package/src/toolstore/publish.ts +0 -571
@@ -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
- }