@kya-os/mcp-i-cloudflare 1.5.10-canary.9 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -0
- package/dist/__tests__/e2e/test-config.d.ts +37 -0
- package/dist/__tests__/e2e/test-config.d.ts.map +1 -0
- package/dist/__tests__/e2e/test-config.js +62 -0
- package/dist/__tests__/e2e/test-config.js.map +1 -0
- package/dist/adapter.d.ts +44 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +712 -112
- package/dist/adapter.js.map +1 -1
- package/dist/agent.d.ts +103 -25
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +617 -40
- package/dist/agent.js.map +1 -1
- package/dist/app.d.ts +0 -8
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +277 -119
- package/dist/app.js.map +1 -1
- package/dist/cache/kv-oauth-config-cache.d.ts +47 -0
- package/dist/cache/kv-oauth-config-cache.d.ts.map +1 -0
- package/dist/cache/kv-oauth-config-cache.js +82 -0
- package/dist/cache/kv-oauth-config-cache.js.map +1 -0
- package/dist/cache/kv-tool-protection-cache.d.ts +26 -1
- package/dist/cache/kv-tool-protection-cache.d.ts.map +1 -1
- package/dist/cache/kv-tool-protection-cache.js +19 -11
- package/dist/cache/kv-tool-protection-cache.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +39 -14
- package/dist/config.js.map +1 -1
- package/dist/helpers/env-mapper.d.ts +60 -1
- package/dist/helpers/env-mapper.d.ts.map +1 -1
- package/dist/helpers/env-mapper.js +136 -6
- package/dist/helpers/env-mapper.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -3
- package/dist/index.js.map +1 -1
- package/dist/runtime/audit-logger.d.ts +96 -0
- package/dist/runtime/audit-logger.d.ts.map +1 -0
- package/dist/runtime/audit-logger.js +276 -0
- package/dist/runtime/audit-logger.js.map +1 -0
- package/dist/runtime/oauth-handler.d.ts +5 -0
- package/dist/runtime/oauth-handler.d.ts.map +1 -1
- package/dist/runtime/oauth-handler.js +287 -35
- package/dist/runtime/oauth-handler.js.map +1 -1
- package/dist/runtime.d.ts +12 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +34 -4
- package/dist/runtime.js.map +1 -1
- package/dist/server.d.ts +7 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +120 -29
- package/dist/server.js.map +1 -1
- package/dist/services/admin.service.d.ts +1 -3
- package/dist/services/admin.service.d.ts.map +1 -1
- package/dist/services/admin.service.js +175 -146
- package/dist/services/admin.service.js.map +1 -1
- package/dist/services/consent-audit.service.d.ts +91 -0
- package/dist/services/consent-audit.service.d.ts.map +1 -0
- package/dist/services/consent-audit.service.js +243 -0
- package/dist/services/consent-audit.service.js.map +1 -0
- package/dist/services/consent-config.service.d.ts +2 -2
- package/dist/services/consent-config.service.d.ts.map +1 -1
- package/dist/services/consent-config.service.js +55 -28
- package/dist/services/consent-config.service.js.map +1 -1
- package/dist/services/consent-page-renderer.d.ts +14 -0
- package/dist/services/consent-page-renderer.d.ts.map +1 -1
- package/dist/services/consent-page-renderer.js +54 -27
- package/dist/services/consent-page-renderer.js.map +1 -1
- package/dist/services/consent.service.d.ts +93 -8
- package/dist/services/consent.service.d.ts.map +1 -1
- package/dist/services/consent.service.js +1817 -553
- package/dist/services/consent.service.js.map +1 -1
- package/dist/services/delegation.service.d.ts.map +1 -1
- package/dist/services/delegation.service.js +67 -29
- package/dist/services/delegation.service.js.map +1 -1
- package/dist/services/idp-token-storage.d.ts +68 -0
- package/dist/services/idp-token-storage.d.ts.map +1 -0
- package/dist/services/idp-token-storage.js +157 -0
- package/dist/services/idp-token-storage.js.map +1 -0
- package/dist/services/oauth-service.d.ts +66 -0
- package/dist/services/oauth-service.d.ts.map +1 -0
- package/dist/services/oauth-service.js +223 -0
- package/dist/services/oauth-service.js.map +1 -0
- package/dist/services/proof.service.d.ts +8 -6
- package/dist/services/proof.service.d.ts.map +1 -1
- package/dist/services/proof.service.js +131 -75
- package/dist/services/proof.service.js.map +1 -1
- package/dist/services/tool-context-builder.d.ts +55 -0
- package/dist/services/tool-context-builder.d.ts.map +1 -0
- package/dist/services/tool-context-builder.js +124 -0
- package/dist/services/tool-context-builder.js.map +1 -0
- package/dist/types/tool-context.d.ts +35 -0
- package/dist/types/tool-context.d.ts.map +1 -0
- package/dist/types/tool-context.js +13 -0
- package/dist/types/tool-context.js.map +1 -0
- package/dist/types.d.ts +31 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/oauth-service-registry.d.ts +65 -0
- package/dist/utils/oauth-service-registry.d.ts.map +1 -0
- package/dist/utils/oauth-service-registry.js +125 -0
- package/dist/utils/oauth-service-registry.js.map +1 -0
- 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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
//
|
|
185
|
-
|
|
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
|
-
//
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
316
|
-
|
|
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:
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
//
|
|
536
|
-
const
|
|
537
|
-
//
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
557
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
//
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1786
|
+
body["scopes"] = stringValue.includes(",")
|
|
1787
|
+
? stringValue.split(",")
|
|
1788
|
+
: [stringValue];
|
|
617
1789
|
}
|
|
618
1790
|
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
|
|
629
|
-
throw new Error("FormData empty for multipart/form-data");
|
|
1816
|
+
body[fieldName] = stringValue;
|
|
630
1817
|
}
|
|
631
1818
|
}
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
1830
|
+
let hasValidFields = false;
|
|
1831
|
+
while ((match = fieldRegex.exec(text)) !== null) {
|
|
649
1832
|
const fieldName = match[1];
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
677
|
-
|
|
1869
|
+
else {
|
|
1870
|
+
textBody[fieldName] = fieldValue;
|
|
678
1871
|
}
|
|
1872
|
+
hasValidFields = true;
|
|
679
1873
|
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
700
|
-
scopes =
|
|
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
|
|
717
|
-
//
|
|
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
|
-
|
|
722
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
|
2018
|
+
error: "Invalid request",
|
|
827
2019
|
error_code: "validation_error",
|
|
828
|
-
details:
|
|
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
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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:
|
|
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
|
-
|
|
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:
|
|
909
|
-
error_code: "
|
|
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
|
-
|
|
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 (
|
|
2192
|
+
if (request.session_id) {
|
|
944
2193
|
try {
|
|
945
|
-
|
|
946
|
-
|
|
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.
|
|
950
|
-
// Continue without userDid - delegation will
|
|
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
|
-
//
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
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
|
-
//
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
|
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
|
|
2517
|
+
scopes,
|
|
1262
2518
|
expires_in_days: expiresInDays,
|
|
1263
2519
|
};
|
|
1264
2520
|
// Include user_identifier if we have userDid (matches AgentShield schema)
|
|
1265
|
-
//
|
|
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
|
-
|
|
1271
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
1295
|
-
|
|
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
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
2602
|
+
Authorization: `Bearer ${apiKey}`,
|
|
1315
2603
|
"Content-Type": "application/json",
|
|
1316
|
-
"X-Request-ID":
|
|
2604
|
+
"X-Request-ID": crypto.randomUUID(),
|
|
1317
2605
|
},
|
|
1318
|
-
body: JSON.stringify(
|
|
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
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
if (
|
|
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,
|