@kya-os/mcp-i-cloudflare 1.5.10-canary.9 → 1.6.1

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 (102) hide show
  1. package/README.md +130 -0
  2. package/dist/__tests__/e2e/test-config.d.ts +37 -0
  3. package/dist/__tests__/e2e/test-config.d.ts.map +1 -0
  4. package/dist/__tests__/e2e/test-config.js +62 -0
  5. package/dist/__tests__/e2e/test-config.js.map +1 -0
  6. package/dist/adapter.d.ts +44 -1
  7. package/dist/adapter.d.ts.map +1 -1
  8. package/dist/adapter.js +712 -112
  9. package/dist/adapter.js.map +1 -1
  10. package/dist/agent.d.ts +117 -25
  11. package/dist/agent.d.ts.map +1 -1
  12. package/dist/agent.js +664 -40
  13. package/dist/agent.js.map +1 -1
  14. package/dist/app.d.ts +0 -8
  15. package/dist/app.d.ts.map +1 -1
  16. package/dist/app.js +348 -119
  17. package/dist/app.js.map +1 -1
  18. package/dist/cache/kv-oauth-config-cache.d.ts +47 -0
  19. package/dist/cache/kv-oauth-config-cache.d.ts.map +1 -0
  20. package/dist/cache/kv-oauth-config-cache.js +82 -0
  21. package/dist/cache/kv-oauth-config-cache.js.map +1 -0
  22. package/dist/cache/kv-tool-protection-cache.d.ts +26 -1
  23. package/dist/cache/kv-tool-protection-cache.d.ts.map +1 -1
  24. package/dist/cache/kv-tool-protection-cache.js +19 -11
  25. package/dist/cache/kv-tool-protection-cache.js.map +1 -1
  26. package/dist/config.d.ts.map +1 -1
  27. package/dist/config.js +39 -14
  28. package/dist/config.js.map +1 -1
  29. package/dist/helpers/env-mapper.d.ts +60 -1
  30. package/dist/helpers/env-mapper.d.ts.map +1 -1
  31. package/dist/helpers/env-mapper.js +136 -6
  32. package/dist/helpers/env-mapper.js.map +1 -1
  33. package/dist/index.d.ts +4 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +16 -3
  36. package/dist/index.js.map +1 -1
  37. package/dist/runtime/audit-logger.d.ts +96 -0
  38. package/dist/runtime/audit-logger.d.ts.map +1 -0
  39. package/dist/runtime/audit-logger.js +276 -0
  40. package/dist/runtime/audit-logger.js.map +1 -0
  41. package/dist/runtime/oauth-handler.d.ts +5 -0
  42. package/dist/runtime/oauth-handler.d.ts.map +1 -1
  43. package/dist/runtime/oauth-handler.js +287 -35
  44. package/dist/runtime/oauth-handler.js.map +1 -1
  45. package/dist/runtime.d.ts +12 -1
  46. package/dist/runtime.d.ts.map +1 -1
  47. package/dist/runtime.js +34 -4
  48. package/dist/runtime.js.map +1 -1
  49. package/dist/server.d.ts +7 -0
  50. package/dist/server.d.ts.map +1 -1
  51. package/dist/server.js +120 -29
  52. package/dist/server.js.map +1 -1
  53. package/dist/services/admin.service.d.ts +1 -3
  54. package/dist/services/admin.service.d.ts.map +1 -1
  55. package/dist/services/admin.service.js +175 -146
  56. package/dist/services/admin.service.js.map +1 -1
  57. package/dist/services/consent-audit.service.d.ts +91 -0
  58. package/dist/services/consent-audit.service.d.ts.map +1 -0
  59. package/dist/services/consent-audit.service.js +243 -0
  60. package/dist/services/consent-audit.service.js.map +1 -0
  61. package/dist/services/consent-config.service.d.ts +2 -2
  62. package/dist/services/consent-config.service.d.ts.map +1 -1
  63. package/dist/services/consent-config.service.js +55 -28
  64. package/dist/services/consent-config.service.js.map +1 -1
  65. package/dist/services/consent-page-renderer.d.ts +14 -0
  66. package/dist/services/consent-page-renderer.d.ts.map +1 -1
  67. package/dist/services/consent-page-renderer.js +54 -27
  68. package/dist/services/consent-page-renderer.js.map +1 -1
  69. package/dist/services/consent.service.d.ts +93 -8
  70. package/dist/services/consent.service.d.ts.map +1 -1
  71. package/dist/services/consent.service.js +1817 -553
  72. package/dist/services/consent.service.js.map +1 -1
  73. package/dist/services/delegation.service.d.ts.map +1 -1
  74. package/dist/services/delegation.service.js +67 -29
  75. package/dist/services/delegation.service.js.map +1 -1
  76. package/dist/services/idp-token-storage.d.ts +68 -0
  77. package/dist/services/idp-token-storage.d.ts.map +1 -0
  78. package/dist/services/idp-token-storage.js +157 -0
  79. package/dist/services/idp-token-storage.js.map +1 -0
  80. package/dist/services/oauth-service.d.ts +66 -0
  81. package/dist/services/oauth-service.d.ts.map +1 -0
  82. package/dist/services/oauth-service.js +223 -0
  83. package/dist/services/oauth-service.js.map +1 -0
  84. package/dist/services/proof.service.d.ts +8 -6
  85. package/dist/services/proof.service.d.ts.map +1 -1
  86. package/dist/services/proof.service.js +131 -75
  87. package/dist/services/proof.service.js.map +1 -1
  88. package/dist/services/tool-context-builder.d.ts +55 -0
  89. package/dist/services/tool-context-builder.d.ts.map +1 -0
  90. package/dist/services/tool-context-builder.js +124 -0
  91. package/dist/services/tool-context-builder.js.map +1 -0
  92. package/dist/types/tool-context.d.ts +35 -0
  93. package/dist/types/tool-context.d.ts.map +1 -0
  94. package/dist/types/tool-context.js +13 -0
  95. package/dist/types/tool-context.js.map +1 -0
  96. package/dist/types.d.ts +31 -2
  97. package/dist/types.d.ts.map +1 -1
  98. package/dist/utils/oauth-service-registry.d.ts +65 -0
  99. package/dist/utils/oauth-service-registry.d.ts.map +1 -0
  100. package/dist/utils/oauth-service-registry.js +125 -0
  101. package/dist/utils/oauth-service-registry.js.map +1 -0
  102. package/package.json +27 -60
@@ -10,22 +10,174 @@ import { ConsentConfigService } from "./consent-config.service";
10
10
  import { ConsentPageRenderer } from "./consent-page-renderer";
11
11
  import { DEFAULT_AGENTSHIELD_URL, DEFAULT_SESSION_CACHE_TTL, } from "../constants";
12
12
  import { STORAGE_KEYS } from "../constants/storage-keys";
13
- import { loadDay0Config } from "../utils/day0-config";
13
+ import { loadDay0Config, getDelegationFieldName } from "../utils/day0-config";
14
14
  import { validateConsentApprovalRequest, } from "@kya-os/contracts/consent";
15
15
  import { AGENTSHIELD_ENDPOINTS, createDelegationAPIResponseSchema, createDelegationResponseSchema, } from "@kya-os/contracts/agentshield-api";
16
16
  import { UserDidManager } from "@kya-os/mcp-i-core";
17
17
  import { WebCryptoProvider } from "../providers/crypto";
18
+ import { ConsentAuditService } from "./consent-audit.service";
19
+ import { CloudflareProofGenerator } from "../proof-generator";
20
+ import { ProofService } from "./proof.service";
21
+ import { fetchRemoteConfig } from "@kya-os/mcp-i-core";
18
22
  export class ConsentService {
19
23
  configService;
20
24
  renderer;
21
25
  env;
22
26
  runtime;
23
27
  userDidManager; // Cached instance for consistent DID generation
24
- constructor(env, runtime) {
28
+ // Audit service - lazy initialized
29
+ auditService;
30
+ auditInitPromise; // Cache promise to prevent race conditions
31
+ // Phase 2: Provider resolution services (optional for backward compatibility)
32
+ providerResolver;
33
+ providerRegistry;
34
+ /**
35
+ * ✅ FIXED: Constructor takes env: CloudflareEnv, not config
36
+ * Phase 2: Optional providerResolver and providerRegistry for tool-specific providers
37
+ */
38
+ constructor(env, runtime, providerResolver, providerRegistry) {
25
39
  this.env = env;
26
40
  this.runtime = runtime;
27
41
  this.configService = new ConsentConfigService(env);
28
42
  this.renderer = new ConsentPageRenderer();
43
+ this.providerResolver = providerResolver;
44
+ this.providerRegistry = providerRegistry;
45
+ // No initialization here - keep constructor synchronous
46
+ }
47
+ /**
48
+ * Get or initialize audit service (lazy initialization)
49
+ *
50
+ * Fetches config from remote API when projectId is available.
51
+ * Uses promise caching to prevent race conditions.
52
+ *
53
+ * @param projectId - Project ID from consent request (required for config fetch)
54
+ */
55
+ async getAuditService(projectId) {
56
+ // Already initialized
57
+ if (this.auditService) {
58
+ return this.auditService;
59
+ }
60
+ // No runtime - audit not available
61
+ if (!this.runtime) {
62
+ return undefined;
63
+ }
64
+ // Initialization in progress - wait for it
65
+ if (this.auditInitPromise) {
66
+ await this.auditInitPromise;
67
+ return this.auditService;
68
+ }
69
+ // Start initialization (with projectId for config fetch)
70
+ this.auditInitPromise = this.initializeAuditService(projectId);
71
+ try {
72
+ await this.auditInitPromise;
73
+ }
74
+ catch (error) {
75
+ console.warn("[ConsentService] Audit service initialization failed:", error);
76
+ // Don't throw - audit failures shouldn't break consent flow
77
+ }
78
+ return this.auditService;
79
+ }
80
+ /**
81
+ * Initialize audit service - fetches config from remote API
82
+ *
83
+ * ⚠️ CRITICAL: Fetches config from remote API using fetchRemoteConfig()
84
+ * This is the ONLY way to get CloudflareRuntimeConfig per requirement.
85
+ */
86
+ async initializeAuditService(projectId) {
87
+ if (!this.runtime) {
88
+ return;
89
+ }
90
+ try {
91
+ // ✅ CRITICAL: Fetch config from remote API
92
+ const config = await this.getConfigFromRemoteAPI(projectId);
93
+ if (!config?.proofing?.enabled) {
94
+ console.log("[ConsentService] Proofing not enabled in remote config");
95
+ return; // Proofing not enabled
96
+ }
97
+ // Get identity (async - requires runtime to be initialized)
98
+ const identity = await this.runtime.getIdentity();
99
+ // ✅ FIXED: CloudflareProofGenerator only takes identity, not providers
100
+ const proofGenerator = new CloudflareProofGenerator(identity);
101
+ // Get audit logger
102
+ const auditLogger = this.runtime.getAuditLogger();
103
+ if (!auditLogger) {
104
+ console.warn("[ConsentService] AuditLogger not available");
105
+ return;
106
+ }
107
+ // Create audit service with fetched config
108
+ this.auditService = new ConsentAuditService(new ProofService(config, this.runtime), auditLogger, proofGenerator, config, // ✅ Config fetched from remote API
109
+ this.runtime);
110
+ console.log("[ConsentService] Audit service initialized successfully");
111
+ }
112
+ catch (error) {
113
+ console.error("[ConsentService] Failed to initialize audit service:", error);
114
+ // Don't throw - audit failures shouldn't break consent flow
115
+ }
116
+ }
117
+ /**
118
+ * Fetch CloudflareRuntimeConfig from remote API (AgentShield)
119
+ *
120
+ * ⚠️ CRITICAL: Config MUST be fetched from remote API, not constructed from env.
121
+ *
122
+ * Uses existing `fetchRemoteConfig()` from `@kya-os/mcp-i-core/config/remote-config`
123
+ * which handles caching, error handling, and API communication.
124
+ *
125
+ * @param projectId - Project ID from consent request
126
+ * @returns Runtime config or undefined if unavailable
127
+ */
128
+ async getConfigFromRemoteAPI(projectId) {
129
+ if (!this.env.AGENTSHIELD_API_KEY) {
130
+ console.warn("[ConsentService] No API key for runtime config fetch");
131
+ return undefined;
132
+ }
133
+ try {
134
+ // Create KV cache adapter
135
+ const cache = this.env.TOOL_PROTECTION_KV
136
+ ? {
137
+ get: async (key) => {
138
+ return ((await this.env.TOOL_PROTECTION_KV.get(key, "text")) || null);
139
+ },
140
+ set: async (key, value, ttl) => {
141
+ await this.env.TOOL_PROTECTION_KV.put(key, value, {
142
+ expirationTtl: Math.floor(ttl / 1000),
143
+ });
144
+ },
145
+ }
146
+ : undefined;
147
+ const config = await fetchRemoteConfig({
148
+ apiUrl: this.env.AGENTSHIELD_API_URL || "https://kya.vouched.id",
149
+ apiKey: this.env.AGENTSHIELD_API_KEY,
150
+ projectId, // ✅ Use projectId from consent request
151
+ cacheTtl: 300000, // 5 minutes
152
+ fetchProvider: fetch,
153
+ }, cache);
154
+ // Populate serverDid from runtime identity if missing or empty
155
+ // This prevents AgentShield validation errors (serverDid must be at least 1 character)
156
+ // Note: MCPIConfig.identity is RuntimeIdentityConfig (no serverDid), but AgentShield
157
+ // may return MCPIServerConfig format (has serverDid). We need to handle both.
158
+ if (config && config.identity) {
159
+ const identityConfig = config.identity; // Type assertion for serverDid field
160
+ if (!identityConfig.serverDid || identityConfig.serverDid === "") {
161
+ try {
162
+ const runtimeIdentity = await this.runtime?.getIdentity();
163
+ if (runtimeIdentity?.did) {
164
+ identityConfig.serverDid = runtimeIdentity.did;
165
+ console.log("[ConsentService] Populated serverDid from runtime identity");
166
+ }
167
+ }
168
+ catch (error) {
169
+ console.warn("[ConsentService] Failed to get runtime identity for serverDid:", error);
170
+ }
171
+ }
172
+ }
173
+ // fetchRemoteConfig returns MCPIConfig | null
174
+ // CloudflareRuntimeConfig extends MCPIConfig, so cast is safe
175
+ return config;
176
+ }
177
+ catch (error) {
178
+ console.warn("[ConsentService] Error fetching runtime config:", error);
179
+ return undefined;
180
+ }
29
181
  }
30
182
  /**
31
183
  * Get or generate User DID for a session
@@ -37,14 +189,32 @@ export class ConsentService {
37
189
  * @param oauthIdentity - Optional OAuth provider identity
38
190
  * @returns User DID (did:key format)
39
191
  */
192
+ /**
193
+ * Get user DID for a session
194
+ *
195
+ * Public method to retrieve user DID from session storage or OAuth identity mapping.
196
+ * Used by OAuth handler to get userDid when OAuth linking fails.
197
+ *
198
+ * @param sessionId - Session ID
199
+ * @param oauthIdentity - Optional OAuth provider identity
200
+ * @returns User DID (did:key format)
201
+ */
40
202
  async getUserDidForSession(sessionId, oauthIdentity) {
203
+ // Handle null explicitly (from JSON parsing)
204
+ const hasOAuthIdentity = oauthIdentity &&
205
+ typeof oauthIdentity === "object" &&
206
+ oauthIdentity.provider &&
207
+ oauthIdentity.subject;
41
208
  // If OAuth identity provided, check for existing mapping first
42
- if (oauthIdentity && this.env.DELEGATION_STORAGE) {
209
+ if (hasOAuthIdentity && this.env.DELEGATION_STORAGE) {
43
210
  try {
44
211
  const oauthKey = STORAGE_KEYS.oauthIdentity(oauthIdentity.provider, oauthIdentity.subject);
45
212
  const mappedUserDid = await this.env.DELEGATION_STORAGE.get(oauthKey, "text");
46
213
  if (mappedUserDid) {
47
- console.log("[ConsentService] Found persistent User DID from OAuth mapping");
214
+ console.log("[ConsentService] Found persistent User DID from OAuth mapping:", {
215
+ provider: oauthIdentity.provider,
216
+ userDid: mappedUserDid.substring(0, 20) + "...",
217
+ });
48
218
  return mappedUserDid;
49
219
  }
50
220
  }
@@ -53,6 +223,10 @@ export class ConsentService {
53
223
  // Continue with ephemeral DID generation
54
224
  }
55
225
  }
226
+ else if (oauthIdentity === null) {
227
+ // Explicitly handle null case (no OAuth)
228
+ console.log("[ConsentService] No OAuth identity provided (null), generating ephemeral DID");
229
+ }
56
230
  // Continue with existing ephemeral DID generation logic
57
231
  if (!this.env.DELEGATION_STORAGE) {
58
232
  // No storage - use cached UserDidManager instance for consistent DID generation
@@ -96,10 +270,34 @@ export class ConsentService {
96
270
  delete: async (key) => {
97
271
  await this.env.DELEGATION_STORAGE.delete(`userDid:${key}`);
98
272
  },
273
+ // OAuth-based lookup for persistent user DID
274
+ getByOAuth: async (provider, subject) => {
275
+ try {
276
+ const oauthKey = STORAGE_KEYS.oauthIdentity(provider, subject);
277
+ const userDid = await this.env.DELEGATION_STORAGE.get(oauthKey, "text");
278
+ return userDid || null;
279
+ }
280
+ catch {
281
+ return null;
282
+ }
283
+ },
284
+ // OAuth-based storage for persistent user DID mapping
285
+ setByOAuth: async (provider, subject, did, ttl) => {
286
+ try {
287
+ const oauthKey = STORAGE_KEYS.oauthIdentity(provider, subject);
288
+ await this.env.DELEGATION_STORAGE.put(oauthKey, did, {
289
+ expirationTtl: ttl || 90 * 24 * 60 * 60, // Default 90 days for persistent mapping
290
+ });
291
+ }
292
+ catch (error) {
293
+ console.warn("[ConsentService] Failed to store OAuth mapping:", error);
294
+ throw error;
295
+ }
296
+ },
99
297
  },
100
298
  });
101
299
  }
102
- const userDid = await this.userDidManager.getOrCreateUserDid(sessionId);
300
+ const userDid = await this.userDidManager.getOrCreateUserDid(sessionId, oauthIdentity);
103
301
  // Cache in session storage
104
302
  try {
105
303
  const existingSession = (await this.env.DELEGATION_STORAGE.get(sessionKey, "json"));
@@ -124,13 +322,45 @@ export class ConsentService {
124
322
  *
125
323
  * @param projectId - Project ID to check
126
324
  * @param oauthIdentity - Optional OAuth identity from cookie
325
+ * @param toolOAuthProvider - Optional tool-specific OAuth provider (takes precedence)
127
326
  * @returns True if OAuth redirect is required
128
327
  */
129
- async isOAuthRequired(projectId, oauthIdentity) {
328
+ async isOAuthRequired(projectId, oauthIdentity, toolOAuthProvider, toolProtection) {
130
329
  // If OAuth identity is already present, OAuth is not required
131
330
  if (oauthIdentity && oauthIdentity.provider && oauthIdentity.subject) {
132
331
  return false;
133
332
  }
333
+ // If tool specifically requires an OAuth provider, it's required immediately
334
+ if (toolOAuthProvider) {
335
+ return true;
336
+ }
337
+ // Phase 2: If toolOAuthProvider is undefined but tool requires delegation,
338
+ // use ProviderResolver to check if a provider can be resolved
339
+ if (!toolOAuthProvider &&
340
+ toolProtection &&
341
+ toolProtection.requiresDelegation) {
342
+ if (this.providerResolver) {
343
+ try {
344
+ const resolvedProvider = await this.providerResolver.resolveProvider(toolProtection, projectId);
345
+ if (resolvedProvider) {
346
+ console.log("[ConsentService] OAuth required: ProviderResolver resolved provider", {
347
+ projectId,
348
+ provider: resolvedProvider,
349
+ toolRequiresDelegation: toolProtection.requiresDelegation,
350
+ hasToolOAuthProvider: !!toolOAuthProvider,
351
+ });
352
+ return true;
353
+ }
354
+ }
355
+ catch (error) {
356
+ // ProviderResolver failed - log but continue to fallback check
357
+ console.warn("[ConsentService] ProviderResolver failed to resolve provider, falling back to project config check:", {
358
+ projectId,
359
+ error: error instanceof Error ? error.message : String(error),
360
+ });
361
+ }
362
+ }
363
+ }
134
364
  // Check project config to see if OAuth is configured
135
365
  try {
136
366
  const agentShieldUrl = this.env.AGENTSHIELD_API_URL || DEFAULT_AGENTSHIELD_URL;
@@ -143,8 +373,7 @@ export class ConsentService {
143
373
  const configUrl = `${agentShieldUrl}/api/v1/bouncer/projects/${projectId}/config`;
144
374
  const response = await fetch(configUrl, {
145
375
  headers: {
146
- "X-API-Key": apiKey,
147
- "X-Project-Id": projectId,
376
+ Authorization: `Bearer ${apiKey}`,
148
377
  "Content-Type": "application/json",
149
378
  },
150
379
  });
@@ -170,34 +399,197 @@ export class ConsentService {
170
399
  * Creates the OAuth authorization URL with proper state parameter
171
400
  * for redirecting to OAuth provider.
172
401
  *
402
+ * ✅ CSRF Protection: If oauthSecurityService is provided, state is stored securely
403
+ * in KV storage and validated on callback. Otherwise, falls back to base64-encoded
404
+ * state (less secure, but backward compatible).
405
+ *
173
406
  * @param projectId - Project ID
174
407
  * @param agentDid - Agent DID
175
408
  * @param sessionId - Session ID
176
409
  * @param scopes - Requested scopes
177
410
  * @param serverUrl - Server URL for callback
411
+ * @param oauthSecurityService - Optional OAuthSecurityService for CSRF protection
178
412
  * @returns OAuth authorization URL
179
413
  */
180
- buildOAuthUrl(projectId, agentDid, sessionId, scopes, serverUrl) {
414
+ async buildOAuthUrl(projectId, agentDid, sessionId, scopes, serverUrl, provider, // Phase 2: Optional provider name (resolved if not provided)
415
+ oauthSecurityService) {
181
416
  const agentShieldUrl = this.env.AGENTSHIELD_API_URL || DEFAULT_AGENTSHIELD_URL;
182
417
  // Generate a temporary delegation ID for state (will be created after OAuth)
183
418
  const delegationId = `temp-${Date.now()}`;
184
- // Build state parameter with required fields
185
- const state = {
419
+ // Phase 1: Generate PKCE challenge if OAuthSecurityService is available
420
+ let codeVerifier;
421
+ let codeChallenge;
422
+ if (oauthSecurityService) {
423
+ try {
424
+ const pkceChallenge = await oauthSecurityService.generatePKCEChallenge();
425
+ codeVerifier = pkceChallenge.verifier;
426
+ codeChallenge = pkceChallenge.challenge;
427
+ }
428
+ catch (error) {
429
+ console.warn("[ConsentService] Failed to generate PKCE challenge:", error);
430
+ }
431
+ }
432
+ // Build state data with required fields
433
+ const stateData = {
186
434
  project_id: projectId,
187
435
  agent_did: agentDid,
188
436
  session_id: sessionId,
189
437
  delegation_id: delegationId,
190
438
  scopes: scopes,
439
+ storedAt: Date.now(),
191
440
  };
192
- // Encode state as base64 (Cloudflare Workers compatible)
193
- const stateParam = btoa(JSON.stringify(state));
194
- // Build OAuth authorization URL
441
+ // Store PKCE code_verifier in state for token exchange
442
+ if (codeVerifier) {
443
+ stateData.code_verifier = codeVerifier;
444
+ stateData.code_challenge = codeChallenge;
445
+ stateData.redirect_uri = `${serverUrl}/oauth/callback`;
446
+ }
447
+ let stateParam;
448
+ if (oauthSecurityService && this.env.DELEGATION_STORAGE) {
449
+ // ✅ CSRF Protection: Generate secure random state value and store state data securely
450
+ const randomBytes = crypto.getRandomValues(new Uint8Array(32));
451
+ const stateValue = btoa(String.fromCharCode(...randomBytes))
452
+ .replace(/\+/g, "-")
453
+ .replace(/\//g, "_")
454
+ .replace(/=/g, "");
455
+ // Store state data securely in KV (10 minute TTL for OAuth flow)
456
+ await oauthSecurityService.storeOAuthState(stateValue, stateData, 600);
457
+ stateParam = stateValue;
458
+ console.log("[ConsentService] 🔒 SECURITY EVENT: OAuth state stored securely:", {
459
+ projectId,
460
+ agentDid: agentDid.substring(0, 20) + "...",
461
+ sessionId: sessionId.substring(0, 20) + "...",
462
+ stateValue: stateValue.substring(0, 20) + "...",
463
+ timestamp: new Date().toISOString(),
464
+ eventType: "oauth_state_stored",
465
+ ttl: 600,
466
+ });
467
+ }
468
+ else {
469
+ // Fallback: Encode state as base64 (less secure, but backward compatible)
470
+ if (!oauthSecurityService) {
471
+ console.warn("[ConsentService] ⚠️ SECURITY WARNING: OAuthSecurityService not provided, using insecure state encoding");
472
+ }
473
+ stateParam = btoa(JSON.stringify(stateData));
474
+ }
475
+ // Phase 3: Resolve provider config and extract custom parameters
476
+ let providerConfig = null;
477
+ if (provider && this.providerRegistry) {
478
+ providerConfig = this.providerRegistry.getProvider(provider);
479
+ }
480
+ // Phase 3: Check if we should use direct OAuth URL (bypass AgentShield bouncer)
481
+ // This is for custom providers that need direct OAuth URLs
482
+ if (providerConfig &&
483
+ providerConfig.proxyMode === true &&
484
+ !providerConfig.supportsPKCE) {
485
+ // Use providerConfig.clientId for custom providers, not projectId
486
+ const oauthClientId = providerConfig.clientId || projectId;
487
+ return this.buildDirectOAuthUrl(providerConfig, oauthClientId, `${serverUrl}/oauth/callback`, scopes, stateParam, codeChallenge);
488
+ }
489
+ // Phase 3: Validate custom parameters don't conflict with reserved parameters
490
+ const RESERVED_PARAMS = [
491
+ "response_type",
492
+ "client_id",
493
+ "redirect_uri",
494
+ "scope",
495
+ "state",
496
+ "code_challenge",
497
+ "code_challenge_method",
498
+ ];
499
+ if (providerConfig?.customParams) {
500
+ for (const [key, value] of Object.entries(providerConfig.customParams)) {
501
+ const normalizedKey = key.toLowerCase();
502
+ if (RESERVED_PARAMS.includes(normalizedKey)) {
503
+ throw new Error(`Custom parameter "${key}" conflicts with reserved OAuth parameter. Reserved parameters: ${RESERVED_PARAMS.join(", ")}`);
504
+ }
505
+ // Validate value is a string before calling string methods
506
+ if (typeof value !== "string") {
507
+ throw new Error(`Custom parameter "${key}" must be a string, got ${typeof value}`);
508
+ }
509
+ if (!value || value.trim().length === 0) {
510
+ throw new Error(`Custom parameter "${key}" has empty value`);
511
+ }
512
+ }
513
+ }
514
+ // Build OAuth authorization URL (AgentShield bouncer endpoint)
195
515
  const oauthUrl = new URL(`${agentShieldUrl}/bouncer/oauth/authorize`);
196
516
  oauthUrl.searchParams.set("response_type", "code");
197
517
  oauthUrl.searchParams.set("client_id", projectId); // Use projectId as client_id
198
518
  oauthUrl.searchParams.set("redirect_uri", `${serverUrl}/oauth/callback`);
199
519
  oauthUrl.searchParams.set("scope", scopes.join(" "));
200
520
  oauthUrl.searchParams.set("state", stateParam);
521
+ // ✅ Phase 1: Add PKCE challenge to authorization URL if available
522
+ if (codeChallenge) {
523
+ oauthUrl.searchParams.set("code_challenge", codeChallenge);
524
+ oauthUrl.searchParams.set("code_challenge_method", "S256");
525
+ }
526
+ // Phase 3: Inject custom parameters (after standard params, before PKCE params)
527
+ if (providerConfig?.customParams) {
528
+ for (const [key, value] of Object.entries(providerConfig.customParams)) {
529
+ oauthUrl.searchParams.set(key, value);
530
+ }
531
+ }
532
+ return oauthUrl.toString();
533
+ }
534
+ /**
535
+ * Build direct OAuth URL (bypasses AgentShield bouncer endpoint)
536
+ *
537
+ * Used for custom providers that need direct OAuth URLs (e.g., self-hosted OAuth servers).
538
+ * Triggered when providerConfig.proxyMode === true && !providerConfig.supportsPKCE
539
+ *
540
+ * @param providerConfig - Provider configuration
541
+ * @param clientId - OAuth client ID
542
+ * @param redirectUri - Redirect URI for OAuth callback
543
+ * @param scopes - Requested scopes
544
+ * @param state - OAuth state parameter
545
+ * @param codeChallenge - Optional PKCE code challenge
546
+ * @returns Direct OAuth authorization URL
547
+ */
548
+ buildDirectOAuthUrl(providerConfig, clientId, redirectUri, scopes, state, codeChallenge) {
549
+ // Validate custom parameters don't conflict with reserved parameters
550
+ const RESERVED_PARAMS = [
551
+ "response_type",
552
+ "client_id",
553
+ "redirect_uri",
554
+ "scope",
555
+ "state",
556
+ "code_challenge",
557
+ "code_challenge_method",
558
+ ];
559
+ if (providerConfig.customParams) {
560
+ for (const [key, value] of Object.entries(providerConfig.customParams)) {
561
+ const normalizedKey = key.toLowerCase();
562
+ if (RESERVED_PARAMS.includes(normalizedKey)) {
563
+ throw new Error(`Custom parameter "${key}" conflicts with reserved OAuth parameter. Reserved parameters: ${RESERVED_PARAMS.join(", ")}`);
564
+ }
565
+ // Validate value is a string before calling string methods
566
+ if (typeof value !== "string") {
567
+ throw new Error(`Custom parameter "${key}" must be a string, got ${typeof value}`);
568
+ }
569
+ if (!value || value.trim().length === 0) {
570
+ throw new Error(`Custom parameter "${key}" has empty value`);
571
+ }
572
+ }
573
+ }
574
+ // Build OAuth URL directly to provider's authorization endpoint
575
+ const oauthUrl = new URL(providerConfig.authorizationUrl);
576
+ // Include standard OAuth params
577
+ oauthUrl.searchParams.set("response_type", providerConfig.responseType || "code");
578
+ oauthUrl.searchParams.set("client_id", clientId);
579
+ oauthUrl.searchParams.set("redirect_uri", redirectUri);
580
+ oauthUrl.searchParams.set("scope", scopes.join(" "));
581
+ oauthUrl.searchParams.set("state", state);
582
+ // Include PKCE params if supported and challenge provided
583
+ if (providerConfig.supportsPKCE && codeChallenge) {
584
+ oauthUrl.searchParams.set("code_challenge", codeChallenge);
585
+ oauthUrl.searchParams.set("code_challenge_method", "S256");
586
+ }
587
+ // Include custom parameters (after standard params, before PKCE params)
588
+ if (providerConfig.customParams) {
589
+ for (const [key, value] of Object.entries(providerConfig.customParams)) {
590
+ oauthUrl.searchParams.set(key, value);
591
+ }
592
+ }
201
593
  return oauthUrl.toString();
202
594
  }
203
595
  /**
@@ -312,76 +704,12 @@ export class ConsentService {
312
704
  const agentDid = params.get("agent_did");
313
705
  const sessionId = params.get("session_id");
314
706
  const projectId = params.get("project_id");
315
- // Validate required parameters with detailed error messages
316
- const missingParams = [];
317
- if (!tool)
318
- missingParams.push("tool");
319
- if (!agentDid)
320
- missingParams.push("agent_did");
321
- if (!sessionId)
322
- missingParams.push("session_id");
323
- if (!projectId)
324
- missingParams.push("project_id");
325
- if (missingParams.length > 0) {
326
- console.error("[ConsentService] Missing required parameters:", {
327
- missing: missingParams,
328
- received: {
329
- tool: tool || null,
330
- agent_did: agentDid || null,
331
- session_id: sessionId || null,
332
- project_id: projectId || null,
333
- },
334
- url: request.url,
335
- });
707
+ // Validate required parameters
708
+ if (!tool || !agentDid || !sessionId || !projectId) {
336
709
  return new Response(JSON.stringify({
337
710
  success: false,
338
- error: `Missing required parameters: ${missingParams.join(", ")}`,
711
+ error: "Missing required parameters",
339
712
  error_code: "missing_parameters",
340
- missing_parameters: missingParams,
341
- received_parameters: {
342
- tool: tool || null,
343
- agent_did: agentDid || null,
344
- session_id: sessionId || null,
345
- project_id: projectId || null,
346
- },
347
- }), {
348
- status: 400,
349
- headers: { "Content-Type": "application/json" },
350
- });
351
- }
352
- // Validate parameter formats
353
- const validationErrors = [];
354
- if (agentDid && !agentDid.startsWith("did:")) {
355
- validationErrors.push("agent_did must be a valid DID (starting with 'did:')");
356
- }
357
- if (sessionId && sessionId.length < 10) {
358
- validationErrors.push("session_id appears to be invalid (too short)");
359
- }
360
- if (projectId && projectId.length < 1) {
361
- validationErrors.push("project_id appears to be invalid (empty)");
362
- }
363
- if (validationErrors.length > 0) {
364
- console.error("[ConsentService] Parameter validation failed:", {
365
- errors: validationErrors,
366
- received: {
367
- tool,
368
- agent_did: agentDid,
369
- session_id: sessionId,
370
- project_id: projectId,
371
- },
372
- url: request.url,
373
- });
374
- return new Response(JSON.stringify({
375
- success: false,
376
- error: "Invalid parameter format",
377
- error_code: "validation_error",
378
- validation_errors: validationErrors,
379
- received_parameters: {
380
- tool,
381
- agent_did: agentDid,
382
- session_id: sessionId,
383
- project_id: projectId,
384
- },
385
713
  }), {
386
714
  status: 400,
387
715
  headers: { "Content-Type": "application/json" },
@@ -404,7 +732,6 @@ export class ConsentService {
404
732
  }
405
733
  }
406
734
  // Get consent config from AgentShield or defaults
407
- // Note: projectId is validated above and guaranteed to be non-null at this point
408
735
  const consentConfig = await this.configService.getConsentConfig(projectId);
409
736
  // Build server URL from request origin
410
737
  // Priority: 1) env var, 2) request origin, 3) error if neither available
@@ -447,401 +774,1263 @@ export class ConsentService {
447
774
  oauthIdentity = parsed;
448
775
  }
449
776
  }
450
- catch (parseError) {
451
- console.warn("[ConsentService] Invalid OAuth cookie format:", parseError);
777
+ catch (parseError) {
778
+ console.warn("[ConsentService] Invalid OAuth cookie format:", parseError);
779
+ }
780
+ }
781
+ }
782
+ }
783
+ catch (error) {
784
+ console.warn("[ConsentService] Failed to extract OAuth cookie:", error);
785
+ // Non-fatal - continue without OAuth identity
786
+ }
787
+ // Check if OAuth is required (after extracting OAuth identity)
788
+ // Check both project-level config AND tool-specific protection
789
+ const toolProtectionService = this.runtime?.config
790
+ ?.toolProtectionService;
791
+ let toolOAuthProvider;
792
+ let protection;
793
+ if (toolProtectionService && this.runtime) {
794
+ const agentDidObj = await this.runtime.getIdentity();
795
+ protection = await toolProtectionService.checkToolProtection(tool, agentDidObj.did);
796
+ toolOAuthProvider = protection?.oauthProvider;
797
+ }
798
+ const oauthRequired = await this.isOAuthRequired(projectId, oauthIdentity, toolOAuthProvider, protection);
799
+ // Phase 2: Resolve provider for tool if providerResolver is available
800
+ let provider;
801
+ if (this.providerResolver && this.runtime) {
802
+ try {
803
+ const agentDidObj = await this.runtime.getIdentity();
804
+ const toolProtectionService = this.runtime.config
805
+ ?.toolProtectionService;
806
+ if (toolProtectionService) {
807
+ const protection = await toolProtectionService.checkToolProtection(tool, agentDidObj.did);
808
+ if (protection) {
809
+ provider = await this.providerResolver.resolveProvider(protection, projectId);
810
+ }
811
+ }
812
+ }
813
+ catch (error) {
814
+ // Non-fatal - continue without provider info
815
+ console.warn("[ConsentService] Failed to resolve provider for consent page:", error);
816
+ }
817
+ }
818
+ let resolvedOAuthUrl;
819
+ let isOAuthRequired = false;
820
+ if (oauthRequired) {
821
+ // OAuth is required - redirect to OAuth provider instead of showing consent page
822
+ // Note: oauthSecurityService is optional - if not provided, falls back to insecure encoding
823
+ const oauthSecurityService = this.env.DELEGATION_STORAGE
824
+ ? new (await import("./oauth-security.service")).OAuthSecurityService(this.env.DELEGATION_STORAGE, this.env.OAUTH_ENCRYPTION_SECRET)
825
+ : undefined;
826
+ // Build OAuth URL for redirection
827
+ // Note: We're building it here, but we'll pass it to the renderer
828
+ // instead of redirecting immediately, so the user sees the consent page first.
829
+ resolvedOAuthUrl = await this.buildOAuthUrl(projectId, agentDid, sessionId, scopes, serverUrl, undefined, // provider - will be resolved by ProviderResolver if available
830
+ oauthSecurityService);
831
+ isOAuthRequired = true;
832
+ console.log("[ConsentService] 🔒 SECURITY EVENT: OAuth required, preparing consent page with redirect:", {
833
+ projectId,
834
+ agentDid: agentDid.substring(0, 20) + "...",
835
+ oauthUrl: resolvedOAuthUrl.substring(0, 100) + "...",
836
+ hasSecureState: !!oauthSecurityService,
837
+ timestamp: new Date().toISOString(),
838
+ eventType: "oauth_redirect_prepared",
839
+ });
840
+ }
841
+ // ✅ Lazy initialization with projectId
842
+ const auditService = await this.getAuditService(projectId);
843
+ // Log page view event (if audit service available)
844
+ if (auditService) {
845
+ await auditService
846
+ .logConsentPageView({
847
+ sessionId,
848
+ agentDid,
849
+ targetTools: [tool], // Wrap in array
850
+ scopes,
851
+ projectId,
852
+ // oauthProvider: provider, // TODO: Enable logging of resolved provider once type is updated
853
+ })
854
+ .catch((err) => {
855
+ // Structured error logging
856
+ console.error("[ConsentService] Audit logging failed", {
857
+ eventType: "consent:page_viewed",
858
+ sessionId,
859
+ error: err instanceof Error ? err.message : String(err),
860
+ stack: err instanceof Error ? err.stack : undefined,
861
+ });
862
+ // Don't throw - audit failures shouldn't break consent flow
863
+ });
864
+ }
865
+ // Build consent page config
866
+ // Type assertion to handle updated schema before rebuild
867
+ const pageConfig = {
868
+ tool,
869
+ toolDescription,
870
+ scopes,
871
+ agentDid,
872
+ sessionId,
873
+ projectId,
874
+ serverUrl,
875
+ provider, // Phase 2: Include provider if resolved
876
+ branding: consentConfig.branding,
877
+ terms: consentConfig.terms,
878
+ customFields: consentConfig.customFields,
879
+ autoClose: consentConfig.ui?.autoClose,
880
+ // Pass OAuth details for client-side handling
881
+ oauthRequired: isOAuthRequired,
882
+ oauthUrl: resolvedOAuthUrl,
883
+ };
884
+ // Render page with OAuth identity (if available)
885
+ const html = this.renderer.render(pageConfig, oauthIdentity);
886
+ return new Response(html, {
887
+ status: 200,
888
+ headers: {
889
+ "Content-Type": "text/html; charset=utf-8",
890
+ "Cache-Control": "no-cache, no-store, must-revalidate",
891
+ },
892
+ });
893
+ }
894
+ catch (error) {
895
+ console.error("[ConsentService] Error rendering consent page:", error);
896
+ return new Response(JSON.stringify({
897
+ success: false,
898
+ error: "Failed to render consent page",
899
+ error_code: "render_error",
900
+ }), {
901
+ status: 500,
902
+ headers: { "Content-Type": "application/json" },
903
+ });
904
+ }
905
+ }
906
+ /**
907
+ * Parse request body from JSON or FormData
908
+ *
909
+ * Handles both JSON and FormData/multipart requests, converting
910
+ * FormData fields to the correct format for ConsentApprovalRequest.
911
+ *
912
+ * @param request - Request to parse
913
+ * @returns Parsed body object
914
+ */
915
+ async parseRequestBody(request) {
916
+ const contentType = request.headers.get("content-type") || "";
917
+ // Helper function to parse form fields into body object
918
+ const parseFormFields = (entries) => {
919
+ const body = {};
920
+ for (const [key, value] of entries) {
921
+ // Handle special fields that need parsing
922
+ if (key === "scopes" || key === "scopes[]") {
923
+ // Scopes come as JSON string
924
+ try {
925
+ body["scopes"] = JSON.parse(value);
926
+ }
927
+ catch {
928
+ // If not JSON, treat as single scope or array
929
+ body["scopes"] = value.includes(",") ? value.split(",") : [value];
930
+ }
931
+ }
932
+ else if (key === "oauth_identity" || key === "oauth_identity_json") {
933
+ // OAuth identity comes as JSON string
934
+ try {
935
+ const parsed = JSON.parse(value);
936
+ body["oauth_identity"] = parsed || null;
937
+ }
938
+ catch {
939
+ // Invalid JSON, set to null
940
+ body["oauth_identity"] = null;
941
+ }
942
+ }
943
+ else if (key === "termsAccepted") {
944
+ // Convert checkbox values to boolean
945
+ body[key] =
946
+ value === "on" ||
947
+ value === "true" ||
948
+ value === "1" ||
949
+ value === "yes";
950
+ }
951
+ else if (key === "customFields") {
952
+ // Custom fields come as JSON string
953
+ try {
954
+ body[key] = JSON.parse(value);
955
+ }
956
+ catch {
957
+ // If not JSON, skip
958
+ }
959
+ }
960
+ else {
961
+ // Regular string fields
962
+ body[key] = value;
963
+ }
964
+ }
965
+ // Default termsAccepted to true if not provided (common for checkboxes)
966
+ if (!("termsAccepted" in body)) {
967
+ body.termsAccepted = true;
968
+ }
969
+ // Default scopes to empty array if not provided (required by schema but can be empty)
970
+ if (!("scopes" in body)) {
971
+ body.scopes = [];
972
+ }
973
+ return body;
974
+ };
975
+ // Parse URL-encoded form data (application/x-www-form-urlencoded)
976
+ // When Content-Type is URL-encoded but FormData object is passed, try FormData parsing first
977
+ if (contentType.includes("application/x-www-form-urlencoded")) {
978
+ // Try FormData parsing first (FormData object might have been passed)
979
+ try {
980
+ const clonedRequest = request.clone();
981
+ const formData = await clonedRequest.formData();
982
+ const body = {};
983
+ let hasValidFields = false;
984
+ // Check if FormData parsing returned malformed data (entire multipart body as single entry)
985
+ // Type assertion: FormData.entries() exists at runtime in Cloudflare Workers
986
+ const entriesArray = Array.from(formData.entries());
987
+ if (entriesArray.length === 1 &&
988
+ typeof entriesArray[0][0] === "string" &&
989
+ entriesArray[0][0].includes("Content-Disposition") &&
990
+ typeof entriesArray[0][1] === "string" &&
991
+ entriesArray[0][1].toString().includes("Content-Disposition")) {
992
+ // Manually parse multipart format from the value string
993
+ // The key contains the first boundary and Content-Disposition header start
994
+ // The value contains the rest of the first field and all subsequent fields
995
+ const keyPart = entriesArray[0][0];
996
+ const valuePart = entriesArray[0][1];
997
+ // Fix: If key ends with "name" and value starts with '"fieldname"', combine them
998
+ let multipartBody;
999
+ if (keyPart.trim().endsWith("name") &&
1000
+ valuePart.trim().startsWith('"')) {
1001
+ // Key ends with "name", value starts with '"fieldname"', combine with = between them
1002
+ multipartBody = keyPart + "=" + valuePart;
1003
+ }
1004
+ else {
1005
+ multipartBody = keyPart + valuePart;
1006
+ }
1007
+ // Match: Content-Disposition header with field name, then capture value until next boundary or end
1008
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1009
+ let match;
1010
+ while ((match = fieldRegex.exec(multipartBody)) !== null) {
1011
+ const fieldName = match[1];
1012
+ const fieldValue = match[2].trim(); // Remove trailing newlines
1013
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1014
+ try {
1015
+ body["scopes"] = JSON.parse(fieldValue);
1016
+ }
1017
+ catch {
1018
+ body["scopes"] = fieldValue.includes(",")
1019
+ ? fieldValue.split(",")
1020
+ : [fieldValue];
1021
+ }
1022
+ }
1023
+ else if (fieldName === "oauth_identity" ||
1024
+ fieldName === "oauth_identity_json") {
1025
+ try {
1026
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1027
+ }
1028
+ catch {
1029
+ body["oauth_identity"] = null;
1030
+ }
1031
+ }
1032
+ else if (fieldName === "termsAccepted") {
1033
+ body[fieldName] =
1034
+ fieldValue === "on" ||
1035
+ fieldValue === "true" ||
1036
+ fieldValue === "1" ||
1037
+ fieldValue === "yes";
1038
+ }
1039
+ else if (fieldName === "customFields") {
1040
+ try {
1041
+ body[fieldName] = JSON.parse(fieldValue);
1042
+ }
1043
+ catch {
1044
+ // Skip
1045
+ }
1046
+ }
1047
+ else {
1048
+ body[fieldName] = fieldValue;
1049
+ }
1050
+ hasValidFields = true;
1051
+ }
1052
+ }
1053
+ else {
1054
+ // Normal FormData parsing
1055
+ // Type assertion: FormData.entries() exists at runtime in Cloudflare Workers
1056
+ for (const [key, value] of formData.entries()) {
1057
+ if (value instanceof File) {
1058
+ continue;
1059
+ }
1060
+ // Extract field name from malformed keys (when FormData is passed with wrong Content-Type)
1061
+ let fieldName = key;
1062
+ if (key.includes("Content-Disposition") && key.includes('name="')) {
1063
+ // Extract field name from Content-Disposition header
1064
+ const nameMatch = key.match(/name="([^"]+)"/);
1065
+ if (nameMatch && nameMatch[1]) {
1066
+ fieldName = nameMatch[1];
1067
+ }
1068
+ else {
1069
+ // Skip if we can't extract a valid field name
1070
+ continue;
1071
+ }
1072
+ }
1073
+ else if (key.includes("------") ||
1074
+ key.includes("\r\n") ||
1075
+ key.trim() === "") {
1076
+ // Skip boundary strings and invalid keys
1077
+ continue;
1078
+ }
1079
+ hasValidFields = true;
1080
+ const stringValue = value.toString();
1081
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1082
+ try {
1083
+ body["scopes"] = JSON.parse(stringValue);
1084
+ }
1085
+ catch {
1086
+ body["scopes"] = stringValue.includes(",")
1087
+ ? stringValue.split(",")
1088
+ : [stringValue];
1089
+ }
1090
+ }
1091
+ else if (fieldName === "oauth_identity" ||
1092
+ fieldName === "oauth_identity_json") {
1093
+ try {
1094
+ body["oauth_identity"] = JSON.parse(stringValue) || null;
1095
+ }
1096
+ catch {
1097
+ body["oauth_identity"] = null;
1098
+ }
1099
+ }
1100
+ else if (fieldName === "termsAccepted") {
1101
+ body[fieldName] =
1102
+ stringValue === "on" ||
1103
+ stringValue === "true" ||
1104
+ stringValue === "1" ||
1105
+ stringValue === "yes";
1106
+ }
1107
+ else if (fieldName === "customFields") {
1108
+ try {
1109
+ body[fieldName] = JSON.parse(stringValue);
1110
+ }
1111
+ catch {
1112
+ // Skip
1113
+ }
1114
+ }
1115
+ else {
1116
+ body[fieldName] = stringValue;
1117
+ }
1118
+ }
1119
+ }
1120
+ if (hasValidFields && Object.keys(body).length > 0) {
1121
+ if (!("termsAccepted" in body)) {
1122
+ body.termsAccepted = true;
1123
+ }
1124
+ // Default scopes to empty array if not provided
1125
+ if (!("scopes" in body)) {
1126
+ body.scopes = [];
1127
+ }
1128
+ return body;
1129
+ }
1130
+ }
1131
+ catch (formDataError) {
1132
+ // FormData parsing failed, try URL-encoded text parsing
1133
+ console.warn("[ConsentService] FormData parsing failed, trying URL-encoded text:", formDataError);
1134
+ }
1135
+ // Try URL-encoded text parsing as fallback
1136
+ try {
1137
+ const text = await request.clone().text();
1138
+ const params = new URLSearchParams(text);
1139
+ // Type assertion: URLSearchParams.entries() exists at runtime
1140
+ return parseFormFields(params.entries());
1141
+ }
1142
+ catch (urlEncodedError) {
1143
+ // Both failed, fall through to JSON parsing
1144
+ console.warn("[ConsentService] URL-encoded parsing also failed:", urlEncodedError);
1145
+ }
1146
+ }
1147
+ // Parse multipart FormData (multipart/form-data or when FormData object is passed with other Content-Type)
1148
+ // Include text/plain here to handle cases where FormData is passed with mismatched Content-Type
1149
+ if (contentType.includes("multipart/form-data") ||
1150
+ contentType.includes("form") ||
1151
+ contentType === "" ||
1152
+ contentType.includes("text/plain") ||
1153
+ contentType.includes("text")) {
1154
+ // Check if multipart/form-data Content-Type is missing boundary
1155
+ // If so, try to parse as text first (FormData might have been serialized incorrectly)
1156
+ if (contentType.includes("multipart/form-data") &&
1157
+ !contentType.includes("boundary=")) {
1158
+ try {
1159
+ const textRequest = request.clone();
1160
+ const text = await textRequest.text();
1161
+ if (text && text.length > 0) {
1162
+ // Try to manually parse multipart format from text
1163
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1164
+ const body = {};
1165
+ let match;
1166
+ let hasValidFields = false;
1167
+ while ((match = fieldRegex.exec(text)) !== null) {
1168
+ const fieldName = match[1];
1169
+ const fieldValue = match[2].trim();
1170
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1171
+ try {
1172
+ body["scopes"] = JSON.parse(fieldValue);
1173
+ }
1174
+ catch {
1175
+ body["scopes"] = fieldValue.includes(",")
1176
+ ? fieldValue.split(",")
1177
+ : [fieldValue];
1178
+ }
1179
+ }
1180
+ else if (fieldName === "oauth_identity" ||
1181
+ fieldName === "oauth_identity_json") {
1182
+ try {
1183
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1184
+ }
1185
+ catch {
1186
+ body["oauth_identity"] = null;
1187
+ }
1188
+ }
1189
+ else if (fieldName === "termsAccepted") {
1190
+ body[fieldName] =
1191
+ fieldValue === "on" ||
1192
+ fieldValue === "true" ||
1193
+ fieldValue === "1" ||
1194
+ fieldValue === "yes";
1195
+ }
1196
+ else if (fieldName === "customFields") {
1197
+ try {
1198
+ body[fieldName] = JSON.parse(fieldValue);
1199
+ }
1200
+ catch {
1201
+ // Skip
1202
+ }
1203
+ }
1204
+ else {
1205
+ body[fieldName] = fieldValue;
1206
+ }
1207
+ hasValidFields = true;
1208
+ }
1209
+ if (hasValidFields && Object.keys(body).length > 0) {
1210
+ if (!("termsAccepted" in body)) {
1211
+ body.termsAccepted = true;
1212
+ }
1213
+ // Default scopes to empty array if not provided
1214
+ if (!("scopes" in body)) {
1215
+ body.scopes = [];
1216
+ }
1217
+ return body;
1218
+ }
1219
+ }
1220
+ }
1221
+ catch {
1222
+ // If text parsing fails, fall through to FormData parsing
1223
+ }
1224
+ }
1225
+ try {
1226
+ const clonedRequest = request.clone();
1227
+ let formData;
1228
+ try {
1229
+ formData = await clonedRequest.formData();
1230
+ }
1231
+ catch (formDataError) {
1232
+ // Handle FormData parsing errors (missing boundary, wrong Content-Type, etc.)
1233
+ const errorMessage = formDataError instanceof Error
1234
+ ? formDataError.message
1235
+ : String(formDataError);
1236
+ const errorCause = formDataError instanceof Error && "cause" in formDataError
1237
+ ? formDataError.cause instanceof Error
1238
+ ? formDataError.cause.message
1239
+ : String(formDataError.cause)
1240
+ : "";
1241
+ if (errorMessage.includes("missing boundary") ||
1242
+ errorMessage.includes("boundary") ||
1243
+ errorMessage.includes("Content-Type was not one of") ||
1244
+ errorCause.includes("missing boundary") ||
1245
+ errorCause.includes("boundary")) {
1246
+ // When boundary is missing, try to parse as URL-encoded form data instead
1247
+ // This handles the case where FormData was passed but Content-Type doesn't include boundary
1248
+ try {
1249
+ const textRequest = request.clone();
1250
+ const text = await textRequest.text();
1251
+ // If text parsing succeeds, try URL-encoded parsing
1252
+ if (text && text.length > 0) {
1253
+ try {
1254
+ const params = new URLSearchParams(text);
1255
+ return parseFormFields(params.entries());
1256
+ }
1257
+ catch {
1258
+ // If URL-encoded parsing fails, the text might be multipart format
1259
+ // Try to manually parse multipart format from text
1260
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1261
+ const body = {};
1262
+ let match;
1263
+ let hasValidFields = false;
1264
+ while ((match = fieldRegex.exec(text)) !== null) {
1265
+ const fieldName = match[1];
1266
+ const fieldValue = match[2].trim();
1267
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1268
+ try {
1269
+ body["scopes"] = JSON.parse(fieldValue);
1270
+ }
1271
+ catch {
1272
+ body["scopes"] = fieldValue.includes(",")
1273
+ ? fieldValue.split(",")
1274
+ : [fieldValue];
1275
+ }
1276
+ }
1277
+ else if (fieldName === "oauth_identity" ||
1278
+ fieldName === "oauth_identity_json") {
1279
+ try {
1280
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1281
+ }
1282
+ catch {
1283
+ body["oauth_identity"] = null;
1284
+ }
1285
+ }
1286
+ else if (fieldName === "termsAccepted") {
1287
+ body[fieldName] =
1288
+ fieldValue === "on" ||
1289
+ fieldValue === "true" ||
1290
+ fieldValue === "1" ||
1291
+ fieldValue === "yes";
1292
+ }
1293
+ else if (fieldName === "customFields") {
1294
+ try {
1295
+ body[fieldName] = JSON.parse(fieldValue);
1296
+ }
1297
+ catch {
1298
+ // Skip
1299
+ }
1300
+ }
1301
+ else {
1302
+ body[fieldName] = fieldValue;
1303
+ }
1304
+ hasValidFields = true;
1305
+ }
1306
+ if (hasValidFields && Object.keys(body).length > 0) {
1307
+ if (!("termsAccepted" in body)) {
1308
+ body.termsAccepted = true;
1309
+ }
1310
+ // Default scopes to empty array if not provided
1311
+ if (!("scopes" in body)) {
1312
+ body.scopes = [];
1313
+ }
1314
+ return body;
1315
+ }
1316
+ }
1317
+ }
1318
+ }
1319
+ catch {
1320
+ // If all parsing attempts fail, rethrow the original error
1321
+ }
1322
+ }
1323
+ throw formDataError;
1324
+ }
1325
+ const body = {};
1326
+ let hasValidFields = false;
1327
+ // Check if FormData parsing returned malformed data (entire multipart body as single entry)
1328
+ // Type assertion: FormData.entries() exists at runtime in Cloudflare Workers
1329
+ const entriesArray = Array.from(formData.entries());
1330
+ if (entriesArray.length === 1 &&
1331
+ typeof entriesArray[0][0] === "string" &&
1332
+ entriesArray[0][0].includes("Content-Disposition") &&
1333
+ typeof entriesArray[0][1] === "string" &&
1334
+ entriesArray[0][1].toString().includes("Content-Disposition")) {
1335
+ // Manually parse multipart format from the value string
1336
+ const keyPart = entriesArray[0][0];
1337
+ const valuePart = entriesArray[0][1];
1338
+ // Fix: If key ends with "name" and value starts with '"fieldname"', combine them
1339
+ let multipartBody;
1340
+ if (keyPart.trim().endsWith("name") &&
1341
+ valuePart.trim().startsWith('"')) {
1342
+ multipartBody = keyPart + "=" + valuePart;
1343
+ }
1344
+ else {
1345
+ multipartBody = keyPart + valuePart;
1346
+ }
1347
+ // Match: Content-Disposition header with field name, then capture value until next boundary or end
1348
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1349
+ let match;
1350
+ while ((match = fieldRegex.exec(multipartBody)) !== null) {
1351
+ const fieldName = match[1];
1352
+ const fieldValue = match[2].trim();
1353
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1354
+ try {
1355
+ body["scopes"] = JSON.parse(fieldValue);
1356
+ }
1357
+ catch {
1358
+ body["scopes"] = fieldValue.includes(",")
1359
+ ? fieldValue.split(",")
1360
+ : [fieldValue];
1361
+ }
1362
+ }
1363
+ else if (fieldName === "oauth_identity" ||
1364
+ fieldName === "oauth_identity_json") {
1365
+ try {
1366
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1367
+ }
1368
+ catch {
1369
+ body["oauth_identity"] = null;
1370
+ }
1371
+ }
1372
+ else if (fieldName === "termsAccepted") {
1373
+ body[fieldName] =
1374
+ fieldValue === "on" ||
1375
+ fieldValue === "true" ||
1376
+ fieldValue === "1" ||
1377
+ fieldValue === "yes";
1378
+ }
1379
+ else if (fieldName === "customFields") {
1380
+ try {
1381
+ body[fieldName] = JSON.parse(fieldValue);
1382
+ }
1383
+ catch {
1384
+ // Skip
1385
+ }
1386
+ }
1387
+ else {
1388
+ body[fieldName] = fieldValue;
1389
+ }
1390
+ hasValidFields = true;
1391
+ }
1392
+ }
1393
+ else {
1394
+ // Normal FormData parsing
1395
+ // Type assertion: FormData.entries() exists at runtime in Cloudflare Workers
1396
+ for (const [key, value] of formData.entries()) {
1397
+ if (value instanceof File) {
1398
+ continue;
1399
+ }
1400
+ // Extract field name from malformed keys (when FormData is passed with wrong Content-Type)
1401
+ let fieldName = key;
1402
+ if (key.includes("Content-Disposition") && key.includes('name="')) {
1403
+ // Extract field name from Content-Disposition header
1404
+ const nameMatch = key.match(/name="([^"]+)"/);
1405
+ if (nameMatch && nameMatch[1]) {
1406
+ fieldName = nameMatch[1];
1407
+ }
1408
+ else {
1409
+ // Skip if we can't extract a valid field name
1410
+ continue;
1411
+ }
1412
+ }
1413
+ else if (key.includes("------") ||
1414
+ key.includes("\r\n") ||
1415
+ key.trim() === "") {
1416
+ // Skip boundary strings and invalid keys
1417
+ continue;
1418
+ }
1419
+ hasValidFields = true;
1420
+ const stringValue = value.toString();
1421
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1422
+ try {
1423
+ body["scopes"] = JSON.parse(stringValue);
1424
+ }
1425
+ catch {
1426
+ body["scopes"] = stringValue.includes(",")
1427
+ ? stringValue.split(",")
1428
+ : [stringValue];
1429
+ }
1430
+ }
1431
+ else if (fieldName === "oauth_identity" ||
1432
+ fieldName === "oauth_identity_json") {
1433
+ try {
1434
+ body["oauth_identity"] = JSON.parse(stringValue) || null;
1435
+ }
1436
+ catch {
1437
+ body["oauth_identity"] = null;
1438
+ }
1439
+ }
1440
+ else if (fieldName === "termsAccepted") {
1441
+ body[fieldName] =
1442
+ stringValue === "on" ||
1443
+ stringValue === "true" ||
1444
+ stringValue === "1" ||
1445
+ stringValue === "yes";
1446
+ }
1447
+ else if (fieldName === "customFields") {
1448
+ try {
1449
+ body[fieldName] = JSON.parse(stringValue);
1450
+ }
1451
+ catch {
1452
+ // Skip
1453
+ }
1454
+ }
1455
+ else {
1456
+ body[fieldName] = stringValue;
1457
+ }
1458
+ }
1459
+ }
1460
+ if (hasValidFields && Object.keys(body).length > 0) {
1461
+ if (!("termsAccepted" in body)) {
1462
+ body.termsAccepted = true;
1463
+ }
1464
+ // Default scopes to empty array if not provided
1465
+ if (!("scopes" in body)) {
1466
+ body.scopes = [];
1467
+ }
1468
+ return body;
1469
+ }
1470
+ }
1471
+ catch (formDataError) {
1472
+ console.warn("[ConsentService] FormData parsing failed:", formDataError);
1473
+ // Fall through to JSON parsing
1474
+ }
1475
+ }
1476
+ // Default to JSON parsing
1477
+ try {
1478
+ return await request.json();
1479
+ }
1480
+ catch (error) {
1481
+ // If JSON parsing fails and content-type suggests form data, try FormData parsing as fallback
1482
+ // Special handling for text/plain with FormData body - try text parsing first
1483
+ if (contentType === "text/plain") {
1484
+ try {
1485
+ const textRequest = request.clone();
1486
+ const text = await textRequest.text();
1487
+ if (text && text.length > 0) {
1488
+ // Try URL-encoded parsing first
1489
+ try {
1490
+ const params = new URLSearchParams(text);
1491
+ const body = parseFormFields(params.entries());
1492
+ if (Object.keys(body).length > 0) {
1493
+ return body;
1494
+ }
1495
+ }
1496
+ catch {
1497
+ // URL-encoded parsing failed, try multipart format
1498
+ }
1499
+ // Try multipart format parsing (FormData serializes to multipart)
1500
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1501
+ const body = {};
1502
+ let match;
1503
+ let hasValidFields = false;
1504
+ while ((match = fieldRegex.exec(text)) !== null) {
1505
+ const fieldName = match[1];
1506
+ const fieldValue = match[2].trim();
1507
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1508
+ try {
1509
+ body["scopes"] = JSON.parse(fieldValue);
1510
+ }
1511
+ catch {
1512
+ body["scopes"] = fieldValue.includes(",")
1513
+ ? fieldValue.split(",")
1514
+ : [fieldValue];
1515
+ }
1516
+ }
1517
+ else if (fieldName === "oauth_identity" ||
1518
+ fieldName === "oauth_identity_json") {
1519
+ try {
1520
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1521
+ }
1522
+ catch {
1523
+ body["oauth_identity"] = null;
1524
+ }
1525
+ }
1526
+ else if (fieldName === "termsAccepted") {
1527
+ body[fieldName] =
1528
+ fieldValue === "on" ||
1529
+ fieldValue === "true" ||
1530
+ fieldValue === "1" ||
1531
+ fieldValue === "yes";
1532
+ }
1533
+ else if (fieldName === "customFields") {
1534
+ try {
1535
+ body[fieldName] = JSON.parse(fieldValue);
1536
+ }
1537
+ catch {
1538
+ // Skip
1539
+ }
1540
+ }
1541
+ else {
1542
+ body[fieldName] = fieldValue;
1543
+ }
1544
+ hasValidFields = true;
1545
+ }
1546
+ if (hasValidFields && Object.keys(body).length > 0) {
1547
+ if (!("termsAccepted" in body)) {
1548
+ body.termsAccepted = true;
1549
+ }
1550
+ // Default scopes to empty array if not provided
1551
+ if (!("scopes" in body)) {
1552
+ body.scopes = [];
1553
+ }
1554
+ return body;
452
1555
  }
453
1556
  }
454
1557
  }
1558
+ catch {
1559
+ // Text parsing failed, fall through to FormData parsing
1560
+ }
455
1561
  }
456
- catch (error) {
457
- console.warn("[ConsentService] Failed to extract OAuth cookie:", error);
458
- // Non-fatal - continue without OAuth identity
459
- }
460
- // At this point, all required parameters are validated and non-null
461
- // TypeScript doesn't know this, so we assert non-null
462
- const validatedProjectId = projectId;
463
- const validatedAgentDid = agentDid;
464
- const validatedSessionId = sessionId;
465
- // Check if OAuth is required (after extracting OAuth identity)
466
- const oauthRequired = await this.isOAuthRequired(validatedProjectId, oauthIdentity);
467
- if (oauthRequired) {
468
- // OAuth is required - redirect to OAuth provider instead of showing consent page
469
- const oauthUrl = this.buildOAuthUrl(validatedProjectId, validatedAgentDid, validatedSessionId, scopes, serverUrl);
470
- console.log("[ConsentService] OAuth required, redirecting to OAuth provider:", {
471
- projectId: validatedProjectId,
472
- agentDid: validatedAgentDid.substring(0, 20) + "...",
473
- oauthUrl: oauthUrl.substring(0, 100) + "...",
474
- });
475
- return Response.redirect(oauthUrl, 302);
476
- }
477
- // Build consent page config
478
- const pageConfig = {
479
- tool: tool,
480
- toolDescription,
481
- scopes,
482
- agentDid: validatedAgentDid,
483
- sessionId: validatedSessionId,
484
- projectId: validatedProjectId,
485
- serverUrl,
486
- branding: consentConfig.branding,
487
- terms: consentConfig.terms,
488
- customFields: consentConfig.customFields,
489
- autoClose: consentConfig.ui?.autoClose,
490
- };
491
- // Render page with OAuth identity (if available)
492
- const html = this.renderer.render(pageConfig, oauthIdentity);
493
- return new Response(html, {
494
- status: 200,
495
- headers: {
496
- "Content-Type": "text/html; charset=utf-8",
497
- "Cache-Control": "no-cache, no-store, must-revalidate",
498
- },
499
- });
500
- }
501
- catch (error) {
502
- console.error("[ConsentService] Error rendering consent page:", error);
503
- return new Response(JSON.stringify({
504
- success: false,
505
- error: "Failed to render consent page",
506
- error_code: "render_error",
507
- }), {
508
- status: 500,
509
- headers: { "Content-Type": "application/json" },
510
- });
511
- }
512
- }
513
- /**
514
- * Handle consent approval
515
- *
516
- * Validates request, creates delegation via AgentShield API,
517
- * stores token in KV, and returns success response.
518
- *
519
- * @param request - Approval request
520
- * @returns JSON response
521
- */
522
- async handleApproval(request) {
523
- try {
524
- // Parse request body - handle both JSON and FormData
525
- const contentType = request.headers.get("content-type") || "";
526
- let body;
527
- if (contentType.includes("application/json")) {
528
- // JSON request (from JavaScript fetch)
529
- body = await request.json();
530
- }
531
- else if (contentType.includes("application/x-www-form-urlencoded") ||
532
- contentType.includes("multipart/form-data")) {
533
- // Try FormData first (works for both multipart and url-encoded when FormData object is used)
1562
+ if (!contentType.includes("application/json") &&
1563
+ (contentType.includes("form") ||
1564
+ contentType === "" ||
1565
+ contentType.includes("text"))) {
534
1566
  try {
535
- // Don't clone - read FormData directly (cloning might consume the body)
536
- const formData = await request.formData();
537
- // Check if FormData actually has values (if Content-Type mismatch, formData might be empty or malformed)
538
- // Try to get a value and also check entries iterator
539
- const toolValue = formData.get("tool");
540
- // FormData.entries() exists but TypeScript may not recognize it - use type assertion
541
- const formDataEntries = formData.entries ? Array.from(formData.entries()) : [];
542
- // Check if FormData is properly parsed (not malformed)
543
- // Malformed FormData happens when Content-Type says url-encoded but body is multipart
544
- // In that case, entries might exist but contain raw multipart data instead of key-value pairs
545
- const isMalformed = formDataEntries.length > 0 && formDataEntries.some(([key]) => key.includes("Content-Disposition") || key.includes("formdata-"));
546
- const hasFormDataValues = toolValue !== null && !isMalformed;
547
- if (hasFormDataValues) {
548
- // Parse scopes safely
549
- let scopes = [];
550
- const scopesValue = formData.get("scopes");
551
- if (scopesValue) {
552
- try {
553
- if (typeof scopesValue === "string") {
554
- scopes = JSON.parse(scopesValue);
1567
+ // Clone request ONCE before trying any parsing, so we can reuse it if FormData parsing fails
1568
+ const fallbackRequest = request.clone();
1569
+ // Try FormData parsing as fallback (even if Content-Type doesn't match)
1570
+ let formData;
1571
+ try {
1572
+ formData = await fallbackRequest.formData();
1573
+ }
1574
+ catch (formDataParseError) {
1575
+ // If FormData parsing fails, try reading as text and parsing manually
1576
+ // This handles cases where Content-Type doesn't match (e.g., text/plain with FormData body)
1577
+ try {
1578
+ // Use a fresh clone for text parsing (body might be consumed by failed FormData attempt)
1579
+ const textRequest = request.clone();
1580
+ const text = await textRequest.text();
1581
+ if (text && text.length > 0) {
1582
+ // First try URL-encoded parsing (simpler format)
1583
+ try {
1584
+ const params = new URLSearchParams(text);
1585
+ const body = parseFormFields(params.entries());
1586
+ if (Object.keys(body).length > 0) {
1587
+ return body;
1588
+ }
1589
+ }
1590
+ catch {
1591
+ // URL-encoded parsing failed, try multipart format
1592
+ }
1593
+ // Try to manually parse multipart format from text
1594
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1595
+ const body = {};
1596
+ let match;
1597
+ let hasValidFields = false;
1598
+ while ((match = fieldRegex.exec(text)) !== null) {
1599
+ const fieldName = match[1];
1600
+ const fieldValue = match[2].trim();
1601
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1602
+ try {
1603
+ body["scopes"] = JSON.parse(fieldValue);
1604
+ }
1605
+ catch {
1606
+ body["scopes"] = fieldValue.includes(",")
1607
+ ? fieldValue.split(",")
1608
+ : [fieldValue];
1609
+ }
1610
+ }
1611
+ else if (fieldName === "oauth_identity" ||
1612
+ fieldName === "oauth_identity_json") {
1613
+ try {
1614
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1615
+ }
1616
+ catch {
1617
+ body["oauth_identity"] = null;
1618
+ }
1619
+ }
1620
+ else if (fieldName === "termsAccepted") {
1621
+ body[fieldName] =
1622
+ fieldValue === "on" ||
1623
+ fieldValue === "true" ||
1624
+ fieldValue === "1" ||
1625
+ fieldValue === "yes";
1626
+ }
1627
+ else if (fieldName === "customFields") {
1628
+ try {
1629
+ body[fieldName] = JSON.parse(fieldValue);
1630
+ }
1631
+ catch {
1632
+ // Skip
1633
+ }
1634
+ }
1635
+ else {
1636
+ body[fieldName] = fieldValue;
1637
+ }
1638
+ hasValidFields = true;
555
1639
  }
556
- else {
557
- scopes = Array.isArray(scopesValue) ? scopesValue : [];
1640
+ if (hasValidFields && Object.keys(body).length > 0) {
1641
+ if (!("termsAccepted" in body)) {
1642
+ body.termsAccepted = true;
1643
+ }
1644
+ // Default scopes to empty array if not provided
1645
+ if (!("scopes" in body)) {
1646
+ body.scopes = [];
1647
+ }
1648
+ return body;
558
1649
  }
559
1650
  }
560
- catch {
561
- if (typeof scopesValue === "string") {
562
- scopes = scopesValue
563
- .split(",")
564
- .map((s) => s.trim())
565
- .filter((s) => s.length > 0);
1651
+ }
1652
+ catch {
1653
+ // If text parsing also fails, rethrow original FormData error
1654
+ }
1655
+ throw formDataParseError;
1656
+ }
1657
+ // Check if FormData has valid entries (might be empty if Content-Type mismatch)
1658
+ const entriesArray = Array.from(formData.entries());
1659
+ if (entriesArray.length === 0) {
1660
+ // FormData parsing succeeded but returned no entries - try text parsing
1661
+ // This handles cases where Content-Type doesn't match (e.g., text/plain with FormData body)
1662
+ try {
1663
+ const textRequest = request.clone();
1664
+ const text = await textRequest.text();
1665
+ if (text && text.length > 0) {
1666
+ // Try URL-encoded parsing first
1667
+ try {
1668
+ const params = new URLSearchParams(text);
1669
+ const body = parseFormFields(params.entries());
1670
+ if (Object.keys(body).length > 0) {
1671
+ return body;
1672
+ }
1673
+ }
1674
+ catch {
1675
+ // URL-encoded parsing failed, try multipart format
1676
+ }
1677
+ // Try multipart format parsing
1678
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1679
+ const body = {};
1680
+ let match;
1681
+ let hasValidFields = false;
1682
+ while ((match = fieldRegex.exec(text)) !== null) {
1683
+ const fieldName = match[1];
1684
+ const fieldValue = match[2].trim();
1685
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1686
+ try {
1687
+ body["scopes"] = JSON.parse(fieldValue);
1688
+ }
1689
+ catch {
1690
+ body["scopes"] = fieldValue.includes(",")
1691
+ ? fieldValue.split(",")
1692
+ : [fieldValue];
1693
+ }
1694
+ }
1695
+ else if (fieldName === "oauth_identity" ||
1696
+ fieldName === "oauth_identity_json") {
1697
+ try {
1698
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1699
+ }
1700
+ catch {
1701
+ body["oauth_identity"] = null;
1702
+ }
1703
+ }
1704
+ else if (fieldName === "termsAccepted") {
1705
+ body[fieldName] =
1706
+ fieldValue === "on" ||
1707
+ fieldValue === "true" ||
1708
+ fieldValue === "1" ||
1709
+ fieldValue === "yes";
1710
+ }
1711
+ else if (fieldName === "customFields") {
1712
+ try {
1713
+ body[fieldName] = JSON.parse(fieldValue);
1714
+ }
1715
+ catch {
1716
+ // Skip
1717
+ }
1718
+ }
1719
+ else {
1720
+ body[fieldName] = fieldValue;
1721
+ }
1722
+ hasValidFields = true;
1723
+ }
1724
+ if (hasValidFields && Object.keys(body).length > 0) {
1725
+ if (!("termsAccepted" in body)) {
1726
+ body.termsAccepted = true;
1727
+ }
1728
+ // Default scopes to empty array if not provided
1729
+ if (!("scopes" in body)) {
1730
+ body.scopes = [];
1731
+ }
1732
+ return body;
566
1733
  }
567
1734
  }
1735
+ // If text parsing failed or found no valid fields, throw error to trigger fallback
1736
+ throw new Error("FormData parsing returned empty entries and text parsing found no valid fields");
1737
+ }
1738
+ catch (textParseError) {
1739
+ // Text parsing failed or found no valid fields - throw error to trigger JSON fallback
1740
+ // This ensures we don't continue with empty FormData entries
1741
+ throw new Error(`Failed to parse FormData: ${textParseError instanceof Error ? textParseError.message : "Unknown error"}`);
1742
+ }
1743
+ }
1744
+ const body = {};
1745
+ // Type assertion: FormData.entries() exists at runtime in Cloudflare Workers
1746
+ let hasValidEntries = false;
1747
+ for (const [key, value] of formData.entries()) {
1748
+ if (value instanceof File)
1749
+ continue;
1750
+ // Extract field name from malformed keys
1751
+ let fieldName = key;
1752
+ if (key.includes("Content-Disposition") && key.includes('name="')) {
1753
+ const nameMatch = key.match(/name="([^"]+)"/);
1754
+ if (nameMatch && nameMatch[1]) {
1755
+ fieldName = nameMatch[1];
1756
+ }
1757
+ else {
1758
+ continue;
1759
+ }
1760
+ }
1761
+ else if (key.includes("------") ||
1762
+ key.includes("\r\n") ||
1763
+ key.trim() === "" ||
1764
+ key.includes("formdata-undici")) {
1765
+ // Malformed key - likely from text/plain Content-Type mismatch
1766
+ // Skip this entry and fall back to text parsing
1767
+ continue;
568
1768
  }
569
- // Extract FormData values
570
- const agentDidValue = formData.get("agent_did");
571
- const sessionIdValue = formData.get("session_id");
572
- const projectIdValue = formData.get("project_id");
573
- const tool = toolValue && typeof toolValue === "string"
574
- ? toolValue
575
- : toolValue
576
- ? String(toolValue)
577
- : "";
578
- const agentDid = agentDidValue && typeof agentDidValue === "string"
579
- ? agentDidValue
580
- : agentDidValue
581
- ? String(agentDidValue)
582
- : "";
583
- const sessionId = sessionIdValue && typeof sessionIdValue === "string"
584
- ? sessionIdValue
585
- : sessionIdValue
586
- ? String(sessionIdValue)
587
- : "";
588
- const projectId = projectIdValue && typeof projectIdValue === "string"
589
- ? projectIdValue
590
- : projectIdValue
591
- ? String(projectIdValue)
592
- : "";
593
- const termsAcceptedValue = formData.get("termsAccepted");
594
- const termsAccepted = formData.has("termsAccepted")
595
- ? termsAcceptedValue === "on" || termsAcceptedValue === "true"
596
- : true;
597
- body = {
598
- tool,
599
- scopes,
600
- agent_did: agentDid,
601
- session_id: sessionId,
602
- project_id: projectId,
603
- termsAccepted,
604
- customFields: {},
605
- };
606
- // Extract OAuth identity if present
607
- const oauthIdentityJson = formData.get("oauth_identity_json");
608
- if (oauthIdentityJson && typeof oauthIdentityJson === "string") {
1769
+ // If we have a valid field name, mark as having valid entries
1770
+ if (fieldName && fieldName !== key) {
1771
+ hasValidEntries = true;
1772
+ }
1773
+ else if (fieldName &&
1774
+ !key.includes("Content-Disposition") &&
1775
+ !key.includes("------") &&
1776
+ !key.includes("\r\n")) {
1777
+ hasValidEntries = true;
1778
+ }
1779
+ // Process the field value
1780
+ const stringValue = value.toString();
1781
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
609
1782
  try {
610
- const parsed = JSON.parse(oauthIdentityJson);
611
- if (parsed && parsed.provider && parsed.subject) {
612
- body.oauth_identity = parsed;
613
- }
1783
+ body["scopes"] = JSON.parse(stringValue);
614
1784
  }
615
1785
  catch {
616
- // Ignore invalid OAuth identity
1786
+ body["scopes"] = stringValue.includes(",")
1787
+ ? stringValue.split(",")
1788
+ : [stringValue];
617
1789
  }
618
1790
  }
619
- }
620
- else {
621
- // FormData is empty or malformed - this happens when Content-Type says url-encoded but body is FormData
622
- // Try to parse the raw body text as multipart form data manually
623
- if (contentType.includes("application/x-www-form-urlencoded")) {
624
- // FormData with wrong content-type - parse raw body
625
- throw new Error("FormData malformed, parse raw body");
1791
+ else if (fieldName === "oauth_identity" ||
1792
+ fieldName === "oauth_identity_json") {
1793
+ try {
1794
+ body["oauth_identity"] = JSON.parse(stringValue) || null;
1795
+ }
1796
+ catch {
1797
+ body["oauth_identity"] = null;
1798
+ }
1799
+ }
1800
+ else if (fieldName === "termsAccepted") {
1801
+ body[fieldName] =
1802
+ stringValue === "on" ||
1803
+ stringValue === "true" ||
1804
+ stringValue === "1" ||
1805
+ stringValue === "yes";
1806
+ }
1807
+ else if (fieldName === "customFields") {
1808
+ try {
1809
+ body[fieldName] = JSON.parse(stringValue);
1810
+ }
1811
+ catch {
1812
+ // Skip
1813
+ }
626
1814
  }
627
1815
  else {
628
- // For multipart/form-data, FormData should work - if it's empty, that's an error
629
- throw new Error("FormData empty for multipart/form-data");
1816
+ body[fieldName] = stringValue;
630
1817
  }
631
1818
  }
632
- }
633
- catch (formDataError) {
634
- // Fallback: Try to parse raw body text
635
- // This handles cases where FormData is malformed due to Content-Type mismatch
636
- if (contentType.includes("application/x-www-form-urlencoded")) {
1819
+ // If we didn't get any valid entries (all keys were malformed), try text parsing
1820
+ if (!hasValidEntries || Object.keys(body).length === 0) {
1821
+ // Try text parsing as fallback when FormData parsing returns no valid entries
637
1822
  try {
638
- // Try URLSearchParams first (for true url-encoded data)
639
- const formText = await request.clone().text();
640
- // Check if it's actually multipart data (FormData with wrong content-type)
641
- const isMultipart = formText.includes("Content-Disposition: form-data");
642
- if (isMultipart) {
643
- // Parse multipart form data manually using regex
644
- // Match pattern: Content-Disposition: form-data; name="fieldname"\r\n\r\nvalue
645
- const data = {};
646
- const fieldPattern = /Content-Disposition:\s*form-data;\s*name="([^"]+)"[\r\n]+(?:Content-Type:[^\r\n]+[\r\n]+)?[\r\n]+([^\r\n]+(?:[\r\n]+(?!Content-Disposition|--)[^\r\n]+)*)/gi;
1823
+ const textRequest = request.clone();
1824
+ const text = await textRequest.text();
1825
+ if (text && text.length > 0) {
1826
+ // Try multipart format parsing from text
1827
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1828
+ const textBody = {};
647
1829
  let match;
648
- while ((match = fieldPattern.exec(formText)) !== null) {
1830
+ let hasValidFields = false;
1831
+ while ((match = fieldRegex.exec(text)) !== null) {
649
1832
  const fieldName = match[1];
650
- let value = match[2];
651
- // Clean up value - remove trailing boundary markers and extra whitespace
652
- value = value.replace(/\r?\n--[-]+.*$/g, "").trim();
653
- if (value) {
654
- data[fieldName] = value;
1833
+ const fieldValue = match[2].trim();
1834
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1835
+ try {
1836
+ textBody["scopes"] = JSON.parse(fieldValue);
1837
+ }
1838
+ catch {
1839
+ textBody["scopes"] = fieldValue.includes(",")
1840
+ ? fieldValue.split(",")
1841
+ : [fieldValue];
1842
+ }
655
1843
  }
656
- }
657
- // Fallback: if regex didn't work, try simpler pattern
658
- if (Object.keys(data).length === 0) {
659
- // Try simpler pattern: name="field" followed by value on next line
660
- const simplePattern = /name="([^"]+)"[\r\n]+[\r\n]+([^\r\n]+)/g;
661
- let simpleMatch;
662
- while ((simpleMatch = simplePattern.exec(formText)) !== null) {
663
- const fieldName = simpleMatch[1];
664
- let value = simpleMatch[2].trim();
665
- // Skip if it looks like a boundary
666
- if (!value.startsWith("--")) {
667
- data[fieldName] = value;
1844
+ else if (fieldName === "oauth_identity" ||
1845
+ fieldName === "oauth_identity_json") {
1846
+ try {
1847
+ textBody["oauth_identity"] =
1848
+ JSON.parse(fieldValue) || null;
1849
+ }
1850
+ catch {
1851
+ textBody["oauth_identity"] = null;
668
1852
  }
669
1853
  }
670
- }
671
- let scopes = [];
672
- if (data.scopes) {
673
- try {
674
- scopes = JSON.parse(data.scopes);
1854
+ else if (fieldName === "termsAccepted") {
1855
+ textBody[fieldName] =
1856
+ fieldValue === "on" ||
1857
+ fieldValue === "true" ||
1858
+ fieldValue === "1" ||
1859
+ fieldValue === "yes";
1860
+ }
1861
+ else if (fieldName === "customFields") {
1862
+ try {
1863
+ textBody[fieldName] = JSON.parse(fieldValue);
1864
+ }
1865
+ catch {
1866
+ // Skip
1867
+ }
675
1868
  }
676
- catch {
677
- scopes = data.scopes.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1869
+ else {
1870
+ textBody[fieldName] = fieldValue;
678
1871
  }
1872
+ hasValidFields = true;
679
1873
  }
680
- body = {
681
- tool: data.tool || "",
682
- scopes,
683
- agent_did: data.agent_did || "",
684
- session_id: data.session_id || "",
685
- project_id: data.project_id || "",
686
- termsAccepted: data.termsAccepted === "on" || data.termsAccepted === "true" || !("termsAccepted" in data),
687
- customFields: {},
688
- };
689
- }
690
- else {
691
- // True URL-encoded data
692
- const params = new URLSearchParams(formText);
693
- let scopes = [];
694
- const scopesValue = params.get("scopes");
695
- if (scopesValue) {
696
- try {
697
- scopes = JSON.parse(scopesValue);
1874
+ if (hasValidFields && Object.keys(textBody).length > 0) {
1875
+ if (!("termsAccepted" in textBody)) {
1876
+ textBody.termsAccepted = true;
698
1877
  }
699
- catch {
700
- scopes = scopesValue.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
1878
+ if (!("scopes" in textBody)) {
1879
+ textBody.scopes = [];
701
1880
  }
1881
+ return textBody;
702
1882
  }
703
- body = {
704
- tool: params.get("tool") || "",
705
- scopes,
706
- agent_did: params.get("agent_did") || "",
707
- session_id: params.get("session_id") || "",
708
- project_id: params.get("project_id") || "",
709
- termsAccepted: params.get("termsAccepted") === "on" ||
710
- params.get("termsAccepted") === "true" ||
711
- !params.has("termsAccepted"),
712
- customFields: {},
713
- };
714
1883
  }
715
1884
  }
716
- catch (textError) {
717
- // If text parsing also fails, throw validation error
718
- throw new Error("Failed to parse form data");
1885
+ catch {
1886
+ // Text parsing also failed, fall through to throw error
719
1887
  }
1888
+ throw new Error("FormData parsing returned only malformed entries - falling back to text parsing");
720
1889
  }
721
- else {
722
- // For multipart/form-data, if FormData failed completely, we can't parse it
723
- throw new Error("Failed to parse FormData");
1890
+ if (!("termsAccepted" in body)) {
1891
+ body.termsAccepted = true;
724
1892
  }
1893
+ // Default scopes to empty array if not provided
1894
+ if (!("scopes" in body)) {
1895
+ body.scopes = [];
1896
+ }
1897
+ return body;
725
1898
  }
726
- }
727
- else {
728
- // Try JSON first, fallback to form data
729
- try {
730
- body = await request.json();
731
- }
732
- catch {
733
- const formData = await request.formData();
734
- // Parse scopes safely - handle JSON parse errors
735
- let scopes = [];
736
- const scopesValue = formData.get("scopes");
737
- if (scopesValue) {
1899
+ catch (formError) {
1900
+ // FormData parsing failed - if it was due to malformed entries, try text parsing
1901
+ const errorMessage = formError instanceof Error ? formError.message : String(formError);
1902
+ if (errorMessage.includes("malformed entries") ||
1903
+ errorMessage.includes("empty entries") ||
1904
+ errorMessage.includes("falling back to text parsing")) {
1905
+ // Try text parsing as last resort
738
1906
  try {
739
- if (typeof scopesValue === "string") {
740
- scopes = JSON.parse(scopesValue);
741
- }
742
- else {
743
- scopes = Array.isArray(scopesValue) ? scopesValue : [];
1907
+ const textRequest = request.clone();
1908
+ const text = await textRequest.text();
1909
+ if (text && text.length > 0) {
1910
+ // Try multipart format parsing
1911
+ const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
1912
+ const body = {};
1913
+ let match;
1914
+ let hasValidFields = false;
1915
+ while ((match = fieldRegex.exec(text)) !== null) {
1916
+ const fieldName = match[1];
1917
+ const fieldValue = match[2].trim();
1918
+ if (fieldName === "scopes" || fieldName === "scopes[]") {
1919
+ try {
1920
+ body["scopes"] = JSON.parse(fieldValue);
1921
+ }
1922
+ catch {
1923
+ body["scopes"] = fieldValue.includes(",")
1924
+ ? fieldValue.split(",")
1925
+ : [fieldValue];
1926
+ }
1927
+ }
1928
+ else if (fieldName === "oauth_identity" ||
1929
+ fieldName === "oauth_identity_json") {
1930
+ try {
1931
+ body["oauth_identity"] = JSON.parse(fieldValue) || null;
1932
+ }
1933
+ catch {
1934
+ body["oauth_identity"] = null;
1935
+ }
1936
+ }
1937
+ else if (fieldName === "termsAccepted") {
1938
+ body[fieldName] =
1939
+ fieldValue === "on" ||
1940
+ fieldValue === "true" ||
1941
+ fieldValue === "1" ||
1942
+ fieldValue === "yes";
1943
+ }
1944
+ else if (fieldName === "customFields") {
1945
+ try {
1946
+ body[fieldName] = JSON.parse(fieldValue);
1947
+ }
1948
+ catch {
1949
+ // Skip
1950
+ }
1951
+ }
1952
+ else {
1953
+ body[fieldName] = fieldValue;
1954
+ }
1955
+ hasValidFields = true;
1956
+ }
1957
+ if (hasValidFields && Object.keys(body).length > 0) {
1958
+ if (!("termsAccepted" in body)) {
1959
+ body.termsAccepted = true;
1960
+ }
1961
+ // Default scopes to empty array if not provided
1962
+ if (!("scopes" in body)) {
1963
+ body.scopes = [];
1964
+ }
1965
+ return body;
1966
+ }
744
1967
  }
745
1968
  }
746
1969
  catch {
747
- // If JSON parse fails, try parsing as comma-separated string
748
- if (typeof scopesValue === "string") {
749
- scopes = scopesValue
750
- .split(",")
751
- .map((s) => s.trim())
752
- .filter((s) => s.length > 0);
753
- }
1970
+ // Text parsing also failed
754
1971
  }
755
1972
  }
756
- // Extract FormData values and ensure correct types for validation
757
- // FormData.get() returns FormDataEntryValue | null (FormDataEntryValue = string | File)
758
- const toolValue = formData.get("tool");
759
- const agentDidValue = formData.get("agent_did");
760
- const sessionIdValue = formData.get("session_id");
761
- const projectIdValue = formData.get("project_id");
762
- // Convert to strings, handling null and File cases
763
- const tool = toolValue && typeof toolValue === "string"
764
- ? toolValue
765
- : toolValue
766
- ? String(toolValue)
767
- : "";
768
- const agentDid = agentDidValue && typeof agentDidValue === "string"
769
- ? agentDidValue
770
- : agentDidValue
771
- ? String(agentDidValue)
772
- : "";
773
- const sessionId = sessionIdValue && typeof sessionIdValue === "string"
774
- ? sessionIdValue
775
- : sessionIdValue
776
- ? String(sessionIdValue)
777
- : "";
778
- const projectId = projectIdValue && typeof projectIdValue === "string"
779
- ? projectIdValue
780
- : projectIdValue
781
- ? String(projectIdValue)
782
- : "";
783
- body = {
784
- tool,
785
- scopes: Array.isArray(scopes) ? scopes : [],
786
- agent_did: agentDid,
787
- session_id: sessionId,
788
- project_id: projectId,
789
- // termsAccepted: default to true if checkbox not present (no terms configured)
790
- termsAccepted: formData.has("termsAccepted")
791
- ? formData.get("termsAccepted") === "on" ||
792
- formData.get("termsAccepted") === "true"
793
- : true, // Default to true if no checkbox (no terms configured)
794
- customFields: {},
795
- };
1973
+ // Both failed, throw original error
1974
+ }
1975
+ }
1976
+ throw new Error(`Failed to parse request body: ${error instanceof Error ? error.message : "Unknown error"}`);
1977
+ }
1978
+ }
1979
+ /**
1980
+ * Handle consent approval
1981
+ *
1982
+ * Validates request, creates delegation via AgentShield API,
1983
+ * stores token in KV, and returns success response.
1984
+ *
1985
+ * @param request - Approval request
1986
+ * @returns JSON response
1987
+ */
1988
+ async handleApproval(request) {
1989
+ console.log("[ConsentService] Approval request received");
1990
+ try {
1991
+ // Parse and validate request body (supports both JSON and FormData)
1992
+ const body = await this.parseRequestBody(request);
1993
+ console.log("[ConsentService] Request body parsed:", {
1994
+ hasBody: !!body,
1995
+ bodyKeys: Object.keys(body || {}),
1996
+ hasOAuthIdentity: !!body?.oauth_identity,
1997
+ });
1998
+ // Convert null oauth_identity to undefined for proper schema validation
1999
+ // Zod's .nullish() should handle null, but converting to undefined is more explicit
2000
+ // and avoids potential edge cases with FormData parsing
2001
+ if (body &&
2002
+ typeof body === "object" &&
2003
+ body !== null &&
2004
+ "oauth_identity" in body) {
2005
+ const bodyObj = body;
2006
+ if (bodyObj.oauth_identity === null) {
2007
+ bodyObj.oauth_identity = undefined;
796
2008
  }
797
2009
  }
798
2010
  const validation = validateConsentApprovalRequest(body);
799
2011
  if (!validation.success) {
800
- // Always log validation errors for debugging (critical for troubleshooting)
801
- const bodyObj = body;
802
2012
  console.error("[ConsentService] Approval request validation failed:", {
803
2013
  errors: validation.error.errors,
804
- receivedBody: {
805
- tool: typeof bodyObj.tool,
806
- scopes: Array.isArray(bodyObj.scopes)
807
- ? `array[${bodyObj.scopes.length}]`
808
- : typeof bodyObj.scopes,
809
- agent_did: typeof bodyObj.agent_did,
810
- session_id: typeof bodyObj.session_id,
811
- project_id: typeof bodyObj.project_id,
812
- termsAccepted: typeof bodyObj.termsAccepted,
813
- customFields: typeof bodyObj.customFields,
814
- oauth_identity: typeof bodyObj.oauth_identity,
815
- },
816
- rawBody: JSON.stringify(bodyObj).substring(0, 500), // First 500 chars for debugging
2014
+ receivedBody: body,
817
2015
  });
818
- // Format validation errors for better readability
819
- const formattedErrors = validation.error.errors.map((err) => ({
820
- field: err.path.join("."),
821
- message: err.message,
822
- code: err.code,
823
- }));
824
2016
  return new Response(JSON.stringify({
825
2017
  success: false,
826
- error: "Invalid request format",
2018
+ error: "Invalid request",
827
2019
  error_code: "validation_error",
828
- details: formattedErrors,
829
- received_types: {
830
- tool: typeof bodyObj.tool,
831
- scopes: Array.isArray(bodyObj.scopes)
832
- ? `array[${bodyObj.scopes.length}]`
833
- : typeof bodyObj.scopes,
834
- agent_did: typeof bodyObj.agent_did,
835
- session_id: typeof bodyObj.session_id,
836
- project_id: typeof bodyObj.project_id,
837
- termsAccepted: typeof bodyObj.termsAccepted,
838
- },
2020
+ details: validation.error.errors,
839
2021
  }), {
840
2022
  status: 400,
841
2023
  headers: { "Content-Type": "application/json" },
842
2024
  });
843
2025
  }
844
2026
  const approvalRequest = validation.data;
2027
+ console.log("[ConsentService] Approval request validated:", {
2028
+ agentDid: approvalRequest.agent_did?.substring(0, 20) + "...",
2029
+ sessionId: approvalRequest.session_id?.substring(0, 20) + "...",
2030
+ scopes: approvalRequest.scopes,
2031
+ hasOAuthIdentity: !!approvalRequest.oauth_identity,
2032
+ oauthProvider: approvalRequest.oauth_identity?.provider,
2033
+ });
845
2034
  // Validate terms acceptance if required
846
2035
  const consentConfig = await this.configService.getConsentConfig(approvalRequest.project_id);
847
2036
  if (consentConfig.terms?.required && !approvalRequest.termsAccepted) {
@@ -854,27 +2043,103 @@ export class ConsentService {
854
2043
  headers: { "Content-Type": "application/json" },
855
2044
  });
856
2045
  }
2046
+ // ✅ Extract projectId from approval request
2047
+ const projectId = approvalRequest.project_id;
2048
+ // ✅ Lazy initialization with projectId
2049
+ const auditService = await this.getAuditService(projectId);
2050
+ // Check if user needs credentials before delegation
2051
+ const needsCredentials = !approvalRequest.user_did && !approvalRequest.oauth_identity;
2052
+ if (needsCredentials && auditService) {
2053
+ await auditService
2054
+ .logCredentialRequired({
2055
+ sessionId: approvalRequest.session_id,
2056
+ agentDid: approvalRequest.agent_did,
2057
+ targetTools: [approvalRequest.tool], // Array
2058
+ scopes: approvalRequest.scopes,
2059
+ projectId,
2060
+ oauthProvider: approvalRequest.oauth_identity?.provider,
2061
+ })
2062
+ .catch((err) => {
2063
+ console.error("[ConsentService] Failed to log credential required", {
2064
+ eventType: "consent:credential_required",
2065
+ sessionId: approvalRequest.session_id,
2066
+ error: err instanceof Error ? err.message : String(err),
2067
+ });
2068
+ });
2069
+ // Note: We don't redirect here - the consent flow continues
2070
+ // The credential_required event is just for audit tracking
2071
+ }
857
2072
  // Create delegation via AgentShield API
2073
+ console.log("[ConsentService] Creating delegation...");
858
2074
  const delegationResult = await this.createDelegation(approvalRequest);
859
2075
  if (!delegationResult.success) {
860
- // Map API error codes to delegation_creation_failed for consistency
861
- // Tests expect delegation_creation_failed when delegation creation fails
862
- const errorCode = delegationResult.error_code === "validation_error" ||
863
- delegationResult.error_code === "api_error" ||
864
- delegationResult.error_code === "INTERNAL_SERVER_ERROR"
865
- ? "delegation_creation_failed"
866
- : delegationResult.error_code || "delegation_creation_failed";
2076
+ console.error("[ConsentService] Delegation creation failed:", {
2077
+ error: delegationResult.error,
2078
+ error_code: delegationResult.error_code,
2079
+ });
867
2080
  return new Response(JSON.stringify({
868
2081
  success: false,
869
2082
  error: delegationResult.error || "Failed to create delegation",
870
- error_code: errorCode,
2083
+ error_code: delegationResult.error_code || "delegation_creation_failed",
871
2084
  }), {
872
2085
  status: 500,
873
2086
  headers: { "Content-Type": "application/json" },
874
2087
  });
875
2088
  }
2089
+ console.log("[ConsentService] ✅ Delegation created successfully:", {
2090
+ delegationId: delegationResult.delegation_id?.substring(0, 20) + "...",
2091
+ });
876
2092
  // Store delegation token in KV
877
2093
  await this.storeDelegationToken(approvalRequest.session_id, approvalRequest.agent_did, delegationResult.delegation_token, delegationResult.delegation_id);
2094
+ // ✅ After successful delegation creation - log audit events
2095
+ if (auditService && delegationResult.success) {
2096
+ try {
2097
+ // Get userDid (may have been generated during delegation creation)
2098
+ // getUserDidForSession can work without DELEGATION_STORAGE (uses in-memory UserDidManager)
2099
+ let userDid;
2100
+ if (approvalRequest.session_id) {
2101
+ try {
2102
+ userDid = await this.getUserDidForSession(approvalRequest.session_id, approvalRequest.oauth_identity || undefined);
2103
+ }
2104
+ catch (error) {
2105
+ console.warn("[ConsentService] Failed to get userDid for audit logging:", error);
2106
+ // Continue without userDid - audit events can still be logged
2107
+ }
2108
+ }
2109
+ await auditService.logConsentApproval({
2110
+ sessionId: approvalRequest.session_id,
2111
+ userDid,
2112
+ agentDid: approvalRequest.agent_did,
2113
+ targetTools: [approvalRequest.tool], // Array
2114
+ scopes: approvalRequest.scopes,
2115
+ delegationId: delegationResult.delegation_id,
2116
+ projectId,
2117
+ termsAccepted: approvalRequest.termsAccepted || false,
2118
+ oauthIdentity: approvalRequest.oauth_identity
2119
+ ? {
2120
+ provider: approvalRequest.oauth_identity.provider,
2121
+ identifier: approvalRequest.oauth_identity.subject,
2122
+ }
2123
+ : undefined,
2124
+ });
2125
+ await auditService.logDelegationCreated({
2126
+ sessionId: approvalRequest.session_id,
2127
+ delegationId: delegationResult.delegation_id,
2128
+ agentDid: approvalRequest.agent_did,
2129
+ userDid,
2130
+ targetTools: [approvalRequest.tool], // Array
2131
+ scopes: approvalRequest.scopes,
2132
+ projectId,
2133
+ });
2134
+ }
2135
+ catch (error) {
2136
+ console.error("[ConsentService] Audit failed but continuing", {
2137
+ sessionId: approvalRequest.session_id,
2138
+ error: error instanceof Error ? error.message : String(error),
2139
+ eventTypes: ["consent:approved", "consent:delegation_created"],
2140
+ });
2141
+ }
2142
+ }
878
2143
  // Return success response
879
2144
  const response = {
880
2145
  success: true,
@@ -887,26 +2152,11 @@ export class ConsentService {
887
2152
  });
888
2153
  }
889
2154
  catch (error) {
890
- // Enhanced error logging for debugging
891
- const errorMessage = error instanceof Error ? error.message : String(error);
892
- const errorStack = error instanceof Error ? error.stack : undefined;
893
- const errorName = error instanceof Error ? error.name : typeof error;
894
- console.error("[ConsentService] Error handling approval:", {
895
- name: errorName,
896
- message: errorMessage,
897
- stack: errorStack,
898
- error: error instanceof Error
899
- ? {
900
- name: error.name,
901
- message: error.message,
902
- stack: error.stack,
903
- }
904
- : error,
905
- });
2155
+ console.error("[ConsentService] Error handling approval:", error);
906
2156
  return new Response(JSON.stringify({
907
2157
  success: false,
908
- error: errorMessage || "An internal error occurred",
909
- error_code: "INTERNAL_SERVER_ERROR",
2158
+ error: "Internal server error",
2159
+ error_code: "internal_error",
910
2160
  }), {
911
2161
  status: 500,
912
2162
  headers: { "Content-Type": "application/json" },
@@ -933,23 +2183,39 @@ export class ConsentService {
933
2183
  try {
934
2184
  // Load Day0 configuration to determine field name and API capabilities
935
2185
  await loadDay0Config(this.env.DELEGATION_STORAGE);
936
- // AgentShield API only accepts "custom_fields", not "metadata"
937
- // See: packages/contracts/src/agentshield-api/schemas.ts - createDelegationRequestSchema
938
- // Day0 config might return "metadata" for backward compatibility, but we must use "custom_fields" for AgentShield
939
- const fieldName = "custom_fields";
2186
+ const fieldName = await getDelegationFieldName(this.env.DELEGATION_STORAGE);
940
2187
  // Get userDID from session or generate new ephemeral DID
941
2188
  // Phase 4 PR #3: Use OAuth identity if provided in approval request
2189
+ // CRITICAL: Must work with or without OAuth identity
2190
+ // CRITICAL: Always generate ephemeral DID if session_id is available, even without DELEGATION_STORAGE
942
2191
  let userDid;
943
- if (this.env.DELEGATION_STORAGE && request.session_id) {
2192
+ if (request.session_id) {
944
2193
  try {
945
- // Pass OAuth identity if available in approval request (convert null to undefined)
946
- userDid = await this.getUserDidForSession(request.session_id, request.oauth_identity ?? undefined);
2194
+ console.log("[ConsentService] Getting User DID for session:", {
2195
+ sessionId: request.session_id.substring(0, 20) + "...",
2196
+ hasOAuthIdentity: !!request.oauth_identity,
2197
+ oauthProvider: request.oauth_identity?.provider,
2198
+ hasStorage: !!this.env.DELEGATION_STORAGE,
2199
+ });
2200
+ // Pass OAuth identity if available in approval request (can be null/undefined)
2201
+ // getUserDidForSession can work without DELEGATION_STORAGE (uses in-memory UserDidManager)
2202
+ userDid = await this.getUserDidForSession(request.session_id, request.oauth_identity || undefined // Explicitly handle null as undefined
2203
+ );
2204
+ console.log("[ConsentService] User DID retrieved:", {
2205
+ userDid: userDid?.substring(0, 20) + "...",
2206
+ hasUserDid: !!userDid,
2207
+ });
947
2208
  }
948
2209
  catch (error) {
949
- console.warn("[ConsentService] Failed to get/generate userDid:", error);
950
- // Continue without userDid - delegation will use ephemeral placeholder
2210
+ console.error("[ConsentService] Failed to get/generate userDid:", error);
2211
+ // Continue without userDid - delegation will work without user_identifier
2212
+ // This is valid for non-OAuth scenarios, but we should log this as a warning
2213
+ console.warn("[ConsentService] Delegation will be created without user_identifier - this may affect user tracking");
951
2214
  }
952
2215
  }
2216
+ else {
2217
+ console.log("[ConsentService] No session_id provided - skipping User DID generation");
2218
+ }
953
2219
  const expiresInDays = 7; // Default to 7 days
954
2220
  // Build delegation request with error-based format detection
955
2221
  // Try full format first, fallback to simplified format on error
@@ -966,10 +2232,6 @@ export class ConsentService {
966
2232
  // Error-based format detection: try request format, fallback on error
967
2233
  const response = await this.tryAPICall(agentShieldUrl, apiKey, delegationRequest);
968
2234
  if (!response.success) {
969
- console.error("[ConsentService] Delegation creation failed:", {
970
- error: response.error,
971
- error_code: response.error_code,
972
- });
973
2235
  return response;
974
2236
  }
975
2237
  const responseData = response.data;
@@ -999,15 +2261,6 @@ export class ConsentService {
999
2261
  delegation_token: delegationId, // Use delegation_id as token for direct API calls
1000
2262
  };
1001
2263
  }
1002
- // Log validation errors for debugging
1003
- if (wrappedParse.success === false) {
1004
- console.warn("[ConsentService] Wrapped format validation failed:", {
1005
- errors: wrappedParse.error.errors,
1006
- responseData: typeof responseData === "object"
1007
- ? JSON.stringify(responseData).substring(0, 500)
1008
- : String(responseData).substring(0, 500),
1009
- });
1010
- }
1011
2264
  // Try unwrapped format (also valid per contracts)
1012
2265
  const unwrappedParse = createDelegationResponseSchema.safeParse(responseData);
1013
2266
  if (unwrappedParse.success) {
@@ -1024,15 +2277,6 @@ export class ConsentService {
1024
2277
  delegation_token: delegationId, // Use delegation_id as token for direct API calls
1025
2278
  };
1026
2279
  }
1027
- // Log validation errors for debugging
1028
- if (unwrappedParse.success === false) {
1029
- console.warn("[ConsentService] Unwrapped format validation failed:", {
1030
- errors: unwrappedParse.error.errors,
1031
- responseData: typeof responseData === "object"
1032
- ? JSON.stringify(responseData).substring(0, 500)
1033
- : String(responseData).substring(0, 500),
1034
- });
1035
- }
1036
2280
  // Fallback: Try to extract delegation_id from common alternative formats
1037
2281
  // (for backward compatibility during API transitions)
1038
2282
  const data = responseData.data || responseData;
@@ -1042,17 +2286,7 @@ export class ConsentService {
1042
2286
  data?.id ||
1043
2287
  delegationObj?.id;
1044
2288
  if (!delegationId) {
1045
- console.error("[ConsentService] Invalid response format - missing delegation_id:", {
1046
- responseData: typeof responseData === "object"
1047
- ? JSON.stringify(responseData)
1048
- : String(responseData),
1049
- wrappedParseErrors: wrappedParse.success === false
1050
- ? wrappedParse.error.errors
1051
- : undefined,
1052
- unwrappedParseErrors: unwrappedParse.success === false
1053
- ? unwrappedParse.error.errors
1054
- : undefined,
1055
- });
2289
+ console.error("[ConsentService] Invalid response format - missing delegation_id:", responseData);
1056
2290
  return {
1057
2291
  success: false,
1058
2292
  error: "Invalid API response format - missing delegation_id",
@@ -1190,10 +2424,14 @@ export class ConsentService {
1190
2424
  scopes: request.scopes,
1191
2425
  expires_in_days: expiresInDays,
1192
2426
  };
1193
- // Note: session_id and project_id are NOT in createDelegationSchema
1194
- // - project_id is extracted from API key context by AgentShield middleware
1195
- // - session_id is not needed for delegation creation
1196
- // These fields are removed to match AgentShield API schema exactly
2427
+ // Include session_id if provided
2428
+ if (request.session_id) {
2429
+ baseRequest.session_id = request.session_id;
2430
+ }
2431
+ // Include project_id if provided
2432
+ if (request.project_id) {
2433
+ baseRequest.project_id = request.project_id;
2434
+ }
1197
2435
  // Check cached format preference
1198
2436
  const cacheKey = STORAGE_KEYS.formatPreference();
1199
2437
  let cachedFormat = null;
@@ -1210,26 +2448,34 @@ export class ConsentService {
1210
2448
  // Ignore cache errors
1211
2449
  }
1212
2450
  }
1213
- // AgentShield API only accepts simplified format
1214
- // The full DelegationRecord format is not supported by the API
1215
- // See: packages/contracts/src/agentshield-api/schemas.ts - createDelegationRequestSchema
1216
- // Note: "full" format cache entries are ignored - always use simplified format
1217
- // Note: fieldName parameter is ignored - always use "custom_fields" for AgentShield API
1218
- return this.buildSimplifiedFormatRequest(request, userDid, expiresInDays, "custom_fields" // Always use custom_fields for AgentShield API
1219
- );
2451
+ // If we have a cached preference, use it directly
2452
+ if (cachedFormat === "full") {
2453
+ return this.buildFullFormatRequest(request, userDid, expiresInDays);
2454
+ }
2455
+ else if (cachedFormat === "simplified") {
2456
+ return this.buildSimplifiedFormatRequest(request, userDid, expiresInDays, fieldName);
2457
+ }
2458
+ // No cache - return request that will be tried with error-based detection
2459
+ return {
2460
+ _tryFormats: true,
2461
+ fullFormat: await this.buildFullFormatRequest(request, userDid, expiresInDays),
2462
+ simplifiedFormat: this.buildSimplifiedFormatRequest(request, userDid, expiresInDays, fieldName),
2463
+ };
1220
2464
  }
1221
2465
  /**
1222
2466
  * Build full DelegationRecord format request (future format)
1223
2467
  */
1224
2468
  async buildFullFormatRequest(request, userDid, expiresInDays) {
1225
2469
  const notAfter = Date.now() + expiresInDays * 24 * 60 * 60 * 1000;
2470
+ // Defensive check: ensure scopes is always defined (should be guaranteed by validation)
2471
+ const scopes = request.scopes ?? [];
1226
2472
  return {
1227
2473
  delegation: {
1228
2474
  id: crypto.randomUUID(),
1229
2475
  issuerDid: userDid || "did:key:z6MkEphemeral", // Use ephemeral if no userDid
1230
2476
  subjectDid: request.agent_did,
1231
2477
  constraints: {
1232
- scopes: request.scopes,
2478
+ scopes,
1233
2479
  notAfter,
1234
2480
  notBefore: Date.now(),
1235
2481
  },
@@ -1254,21 +2500,38 @@ export class ConsentService {
1254
2500
  }
1255
2501
  /**
1256
2502
  * Build simplified format request with proper field name
2503
+ *
2504
+ * CRITICAL: This method MUST NOT include session_id or project_id in the request body.
2505
+ * These fields are NOT part of AgentShield's createDelegationSchema:
2506
+ * - project_id is extracted from API key context by AgentShield middleware
2507
+ * - session_id is not needed for delegation creation
2508
+ *
2509
+ * Including these fields will cause validation errors (400 Bad Request).
1257
2510
  */
1258
2511
  buildSimplifiedFormatRequest(request, userDid, expiresInDays, fieldName) {
2512
+ // Build request with ONLY fields that are in AgentShield's schema
2513
+ // Defensive check: ensure scopes is always defined (should be guaranteed by validation)
2514
+ const scopes = request.scopes ?? [];
1259
2515
  const simplifiedRequest = {
1260
2516
  agent_did: request.agent_did,
1261
- scopes: request.scopes,
2517
+ scopes,
1262
2518
  expires_in_days: expiresInDays,
1263
2519
  };
1264
2520
  // Include user_identifier if we have userDid (matches AgentShield schema)
1265
- // Note: session_id and project_id are NOT in createDelegationSchema
1266
- // - project_id is extracted from API key context by AgentShield
1267
- // - session_id is not needed for delegation creation
2521
+ // CRITICAL: user_identifier is optional - delegation works without it
1268
2522
  if (userDid) {
1269
2523
  simplifiedRequest.user_identifier = userDid;
1270
- // AgentShield API only accepts "custom_fields", not "metadata"
1271
- // Always use "custom_fields" regardless of Day0 config
2524
+ console.log("[ConsentService] Including user_identifier in delegation request:", {
2525
+ userDid: userDid.substring(0, 20) + "...",
2526
+ });
2527
+ }
2528
+ else {
2529
+ console.log("[ConsentService] No user_identifier (no OAuth or ephemeral DID) - delegation will proceed without it");
2530
+ }
2531
+ // AgentShield API only accepts "custom_fields", not "metadata"
2532
+ // Always use "custom_fields" regardless of Day0 config
2533
+ if (userDid) {
2534
+ // Include issuer_did and subject_did in custom_fields when we have userDid
1272
2535
  simplifiedRequest.custom_fields = {
1273
2536
  issuer_did: userDid,
1274
2537
  subject_did: request.agent_did,
@@ -1282,40 +2545,65 @@ export class ConsentService {
1282
2545
  // Include custom_fields from request even if no userDid
1283
2546
  simplifiedRequest.custom_fields = request.customFields;
1284
2547
  }
2548
+ // If no userDid and no customFields, custom_fields is omitted (valid)
2549
+ // EXPLICIT SAFEGUARD: Remove session_id and project_id if they somehow got added
2550
+ // This should never happen, but provides defense-in-depth
2551
+ delete simplifiedRequest.session_id;
2552
+ delete simplifiedRequest.project_id;
1285
2553
  return simplifiedRequest;
1286
2554
  }
1287
2555
  /**
1288
- * Make API call (simplified format only)
1289
- *
1290
- * Note: AgentShield API only accepts simplified format.
1291
- * The full DelegationRecord format is not supported.
2556
+ * Try API call with error-based format detection
1292
2557
  */
1293
2558
  async tryAPICall(agentShieldUrl, apiKey, request) {
1294
- // AgentShield API only accepts simplified format
1295
- // No format detection needed - always use simplified format
2559
+ // Handle format detection
2560
+ if (request._tryFormats && request.fullFormat && request.simplifiedFormat) {
2561
+ // Try full format first
2562
+ const fullResponse = await this.makeAPICall(agentShieldUrl, apiKey, request.fullFormat);
2563
+ if (fullResponse.success ||
2564
+ fullResponse.error_code !== "validation_error") {
2565
+ // Full format worked or failed for non-format reasons
2566
+ await this.cacheFormatPreference("full");
2567
+ return fullResponse;
2568
+ }
2569
+ // Full format failed with validation error, try simplified
2570
+ console.log("[ConsentService] Full format failed, trying simplified format...");
2571
+ const simplifiedResponse = await this.makeAPICall(agentShieldUrl, apiKey, request.simplifiedFormat);
2572
+ if (simplifiedResponse.success) {
2573
+ await this.cacheFormatPreference("simplified");
2574
+ }
2575
+ return simplifiedResponse;
2576
+ }
2577
+ // Direct call (format already determined)
1296
2578
  return this.makeAPICall(agentShieldUrl, apiKey, request);
1297
2579
  }
1298
2580
  /**
1299
2581
  * Make API call and parse response
2582
+ *
2583
+ * CRITICAL: This method ensures session_id and project_id are never sent to AgentShield.
2584
+ * These fields are NOT part of the createDelegationSchema and will cause validation errors.
1300
2585
  */
1301
2586
  async makeAPICall(agentShieldUrl, apiKey, requestBody) {
1302
2587
  try {
1303
- const url = `${agentShieldUrl}${AGENTSHIELD_ENDPOINTS.DELEGATIONS_CREATE}`;
1304
- const requestId = crypto.randomUUID();
1305
- console.log("[ConsentService] Making API call:", {
1306
- url,
1307
- method: "POST",
1308
- requestId,
1309
- bodyKeys: Object.keys(requestBody),
1310
- });
1311
- const response = await fetch(url, {
2588
+ // FINAL SAFEGUARD: Remove session_id and project_id if they somehow got added
2589
+ // This provides defense-in-depth protection
2590
+ const sanitizedBody = { ...requestBody };
2591
+ if ("session_id" in sanitizedBody) {
2592
+ console.warn("[ConsentService] ⚠️ session_id detected in request body - removing (not in schema)");
2593
+ delete sanitizedBody.session_id;
2594
+ }
2595
+ if ("project_id" in sanitizedBody) {
2596
+ console.warn("[ConsentService] ⚠️ project_id detected in request body - removing (not in schema)");
2597
+ delete sanitizedBody.project_id;
2598
+ }
2599
+ const response = await fetch(`${agentShieldUrl}${AGENTSHIELD_ENDPOINTS.DELEGATIONS_CREATE}`, {
1312
2600
  method: "POST",
1313
2601
  headers: {
1314
- "X-API-Key": apiKey,
2602
+ Authorization: `Bearer ${apiKey}`,
1315
2603
  "Content-Type": "application/json",
1316
- "X-Request-ID": requestId,
2604
+ "X-Request-ID": crypto.randomUUID(),
1317
2605
  },
1318
- body: JSON.stringify(requestBody),
2606
+ body: JSON.stringify(sanitizedBody),
1319
2607
  });
1320
2608
  const responseText = await response.text();
1321
2609
  let responseData;
@@ -1325,41 +2613,17 @@ export class ConsentService {
1325
2613
  catch {
1326
2614
  responseData = responseText;
1327
2615
  }
1328
- console.log("[ConsentService] API response:", {
1329
- status: response.status,
1330
- statusText: response.statusText,
1331
- responseData: typeof responseData === "string"
1332
- ? responseData.substring(0, 200)
1333
- : JSON.stringify(responseData).substring(0, 200),
1334
- });
1335
- // Check if response has success: false (even with 200 status)
1336
- if (typeof responseData === "object" &&
1337
- responseData !== null &&
1338
- "success" in responseData &&
1339
- responseData.success === false) {
1340
- const errorData = responseData;
1341
- const errorMessage = errorData.error?.message || errorData.message || "API request failed";
1342
- const errorCode = errorData.error?.code || "api_error";
1343
- console.error("[ConsentService] API returned success: false:", {
1344
- errorMessage,
1345
- errorCode,
1346
- details: errorData.error?.details,
1347
- });
1348
- return {
1349
- success: false,
1350
- error: errorMessage,
1351
- error_code: errorCode,
1352
- };
1353
- }
1354
2616
  // Check for validation error specifically
1355
2617
  if (response.status === 400) {
1356
- const errorMessage = responseData
1357
- ?.error?.message ||
1358
- responseData?.message ||
1359
- "Validation failed";
1360
- if (errorMessage.includes("format") ||
2618
+ const errorData = responseData;
2619
+ const errorMessage = errorData.error?.message || errorData.message || "Validation failed";
2620
+ const errorCode = errorData.error_code || errorData.error?.code;
2621
+ // Check if error_code is explicitly set to "validation_error" OR error message suggests validation error
2622
+ if (errorCode === "validation_error" ||
2623
+ errorMessage.includes("format") ||
1361
2624
  errorMessage.includes("schema") ||
1362
- errorMessage.includes("invalid")) {
2625
+ errorMessage.includes("invalid") ||
2626
+ errorMessage.includes("Validation")) {
1363
2627
  return {
1364
2628
  success: false,
1365
2629
  error: errorMessage,