@kya-os/mcp-i-cloudflare 1.13.2 → 1.13.3

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.
@@ -16,7 +16,7 @@ import { resolveIdpScopes } from "../runtime/oidc/idp-scopes.js";
16
16
  import { validateConsentApprovalRequest, } from "@kya-os/contracts/consent";
17
17
  import { AGENTSHIELD_ENDPOINTS, createDelegationAPIResponseSchema, createDelegationResponseSchema, } from "@kya-os/contracts/agentshield-api";
18
18
  import { createUnsignedVCJWT, completeVCJWT, parseVCJWT, generateDidKeyFromBase64, createDelegationVerifier, createDidKeyResolver, logger, base64urlEncodeFromBytes, base64urlDecodeToBytes, bytesToBase64, wrapDelegationAsVC, } from "@kya-os/mcp";
19
- import { fetchRemoteConfig, } from "@kya-os/mcp-i-runtime"; // product-layer symbols stay on OLD (C2 #2918)
19
+ import { fetchRemoteConfig, normalizeAuthType, } from "@kya-os/mcp-i-runtime"; // product-layer symbols stay on OLD (C2 #2918)
20
20
  import { WebCryptoProvider } from "../providers/crypto";
21
21
  import { ConsentAuditService } from "./consent-audit.service";
22
22
  import { CloudflareProofGenerator } from "../proof-generator";
@@ -146,7 +146,7 @@ export class ConsentService {
146
146
  * @returns true if DO storage succeeded (strongly consistent), false if fell back to KV only
147
147
  */
148
148
  async storeDelegationForOAuth(params, serverOrigin) {
149
- return this.storeDelegationToken(params.sessionId, params.agentDid, params.delegationToken, params.delegationId, params.userDid, serverOrigin);
149
+ return this.storeDelegationToken(params.sessionId, params.agentDid, params.delegationToken, params.delegationId, params.userDid, serverOrigin, params.authorizationType);
150
150
  }
151
151
  /**
152
152
  * Initialize audit service - fetches config from remote API
@@ -2130,6 +2130,10 @@ export class ConsentService {
2130
2130
  headers: {
2131
2131
  "Content-Type": "text/html; charset=utf-8",
2132
2132
  "Cache-Control": "no-cache, no-store, must-revalidate",
2133
+ // SECURITY (A2, spec §3.1): the identity assertion is delivered to
2134
+ // this page in the URL fragment; `no-referrer` guarantees the token
2135
+ // (and any query state) never leaks to a third party via `Referer`.
2136
+ "Referrer-Policy": "no-referrer",
2133
2137
  },
2134
2138
  });
2135
2139
  }
@@ -2140,6 +2144,10 @@ export class ConsentService {
2140
2144
  headers: {
2141
2145
  "Content-Type": "text/html; charset=utf-8",
2142
2146
  "Cache-Control": "no-cache, no-store, must-revalidate",
2147
+ // SECURITY (A2, spec §3.1): the identity assertion is delivered to
2148
+ // this page in the URL fragment; `no-referrer` guarantees the token
2149
+ // (and any query state) never leaks to a third party via `Referer`.
2150
+ "Referrer-Policy": "no-referrer",
2143
2151
  },
2144
2152
  });
2145
2153
  }
@@ -3241,6 +3249,23 @@ export class ConsentService {
3241
3249
  const normalized = String(raw).trim().toLowerCase();
3242
3250
  return normalized !== "false" && normalized !== "0";
3243
3251
  }
3252
+ /**
3253
+ * 403 returned by `handleApproval` when a credential/OAuth clickwrap mint is
3254
+ * attempted without a valid, grant-bound identity assertion (A3, spec §3.2).
3255
+ * A distinct `error_code` from the plain `identity_required` gate so callers
3256
+ * and tests can tell "no server-signed proof of authentication" apart from
3257
+ * "no identity resolved at all".
3258
+ */
3259
+ identityAssertionRequiredResponse() {
3260
+ return new Response(JSON.stringify({
3261
+ success: false,
3262
+ error: "A valid identity assertion is required to approve this request",
3263
+ error_code: "identity_assertion_required",
3264
+ }), {
3265
+ status: 403,
3266
+ headers: { "Content-Type": "application/json" },
3267
+ });
3268
+ }
3244
3269
  /**
3245
3270
  * Handle consent approval
3246
3271
  *
@@ -3266,6 +3291,16 @@ export class ConsentService {
3266
3291
  // - 'none': Consent-only mode (clickwrap) - user agrees without authentication
3267
3292
  const bodyObj = body;
3268
3293
  const providerType = bodyObj?.provider_type;
3294
+ // SECURITY (A3, spec §3.1/§3.2, defect #2): the identity a delegation is
3295
+ // minted under is authoritative ONLY from a trusted source resolved below
3296
+ // — a server-signed identity assertion (credential/OAuth clickwrap) or the
3297
+ // server-generated ephemeral / session-stored DID (pure consent-only).
3298
+ // A caller-supplied `user_did` body field is NEVER an identity source.
3299
+ // This holds the trusted value; it overwrites `bodyObj.user_did`
3300
+ // unconditionally before validation so no downstream consumer can read a
3301
+ // forged one. `undefined` for the plain authenticated (non-clickwrap)
3302
+ // shapes, which fall back to server-side session resolution.
3303
+ let trustedUserDid;
3269
3304
  // CRED-003: Check for credential provider submission
3270
3305
  // Credential submissions include `provider_type: 'credential'` or 'password' and are handled separately
3271
3306
  // IMPORTANT: AgentShield tool protection uses 'password' for credential-based auth validation,
@@ -3304,18 +3339,36 @@ export class ConsentService {
3304
3339
  provider_type: CONSENT_PROVIDER_TYPES.PASSWORD,
3305
3340
  };
3306
3341
  }
3307
- // CRITICAL FIX: Read userDid from URL params passed through redirect
3308
- // This bypasses KV eventual consistency issues - we don't need to read
3309
- // from KV because userDid was passed directly in the redirect URL
3310
- const userDidFromRedirect = bodyObj.user_did;
3311
- if (userDidFromRedirect) {
3312
- logger.debug("[ConsentService] ✅ Using userDid from redirect (bypassing KV):", {
3313
- sessionId: sessionId?.substring(0, 20) + "...",
3314
- userDid: userDidFromRedirect.substring(0, 30) + "...",
3315
- source: "redirect-param",
3316
- });
3317
- // Ensure userDid is in bodyObj for createDelegation and storeDelegationToken
3318
- bodyObj.user_did = userDidFromRedirect;
3342
+ // SECURITY (A3, spec §3.2): the userDid for a post-credential
3343
+ // clickwrap mint comes ONLY from the server-signed identity assertion
3344
+ // minted by handleCredentialApproval after it authenticated against
3345
+ // the provider — bound to this sid AND grant (agent/tool/scopes/
3346
+ // project). The bare `user_did` redirect param that used to be trusted
3347
+ // here is gone: it was caller-forgeable (subject/issuer forgery). A
3348
+ // missing/invalid/expired/mismatched assertion → 403, no mint.
3349
+ if (this.isDevConsentTestMode()) {
3350
+ // Dev/test escape hatch (BOTH MCPI_TEST_CONSENT and a development
3351
+ // env), mirroring consent-anchor-bridge's call-site CSRF relaxation:
3352
+ // no signed assertion is required, so local/miniflare runs work
3353
+ // without OAUTH_ENCRYPTION_SECRET. Never on prod/staging/unset.
3354
+ trustedUserDid = bodyObj.user_did;
3355
+ }
3356
+ else {
3357
+ const assertion = bodyObj.identity_assertion;
3358
+ const verifiedUserDid = typeof assertion === "string"
3359
+ ? await this.verifyIdentityAssertion(assertion, {
3360
+ sid: sessionId,
3361
+ agentDid: bodyObj.agent_did ?? "",
3362
+ tool: bodyObj.tool ?? "",
3363
+ scopes: this.parseScopes(bodyObj.scopes),
3364
+ projectId: bodyObj.project_id ?? "",
3365
+ })
3366
+ : null;
3367
+ if (!verifiedUserDid) {
3368
+ logger.warn("[ConsentService] Post-credential clickwrap has no valid identity assertion — refusing to mint");
3369
+ return this.identityAssertionRequiredResponse();
3370
+ }
3371
+ trustedUserDid = verifiedUserDid;
3319
3372
  }
3320
3373
  // Read user info from redirect URL params
3321
3374
  // Field mapping (clear naming):
@@ -3341,7 +3394,7 @@ export class ConsentService {
3341
3394
  sessionId: sessionId?.substring(0, 20) + "...",
3342
3395
  providerType: CONSENT_PROVIDER_TYPES.PASSWORD,
3343
3396
  provider: credentialProvider,
3344
- hasUserDid: !!userDidFromRedirect,
3397
+ hasUserDid: !!trustedUserDid,
3345
3398
  hasCredentialUserEmail: !!credentialUserEmailFromRedirect,
3346
3399
  hasProviderUserId: !!credentialProviderUserIdFromRedirect,
3347
3400
  note: "Delegation will have authorization.type='password' to match tool protection",
@@ -3370,12 +3423,46 @@ export class ConsentService {
3370
3423
  provider: oauthProvider || bodyObj.provider,
3371
3424
  note: "Delegation will have authorization.type='oauth' to match tool protection",
3372
3425
  });
3426
+ // SECURITY (A3, spec §3.2): as with the credential clickwrap, the
3427
+ // userDid for a post-OAuth clickwrap mint comes ONLY from the
3428
+ // server-signed identity assertion minted by the OAuth callback
3429
+ // (oauth-handler.ts) after it linked the OAuth identity to the user
3430
+ // DID — bound to this sid AND grant. No valid assertion → 403, no
3431
+ // mint. The bare `user_did` redirect param is no longer trusted here.
3432
+ if (this.isDevConsentTestMode()) {
3433
+ trustedUserDid = bodyObj.user_did;
3434
+ }
3435
+ else {
3436
+ const assertion = bodyObj.identity_assertion;
3437
+ const verifiedUserDid = typeof assertion === "string"
3438
+ ? await this.verifyIdentityAssertion(assertion, {
3439
+ sid: sessionId,
3440
+ agentDid: bodyObj.agent_did ?? "",
3441
+ tool: bodyObj.tool ?? "",
3442
+ scopes: this.parseScopes(bodyObj.scopes),
3443
+ projectId: bodyObj.project_id ?? "",
3444
+ })
3445
+ : null;
3446
+ if (!verifiedUserDid) {
3447
+ logger.warn("[ConsentService] Post-OAuth clickwrap has no valid identity assertion — refusing to mint");
3448
+ return this.identityAssertionRequiredResponse();
3449
+ }
3450
+ trustedUserDid = verifiedUserDid;
3451
+ }
3373
3452
  }
3374
3453
  // If neither credential nor OAuth, this is pure consent-only
3375
3454
  if (!(credentialProviderType === CONSENT_PROVIDER_TYPES.PASSWORD ||
3376
3455
  oauthProviderType === "oauth")) {
3377
3456
  // Pure consent-only mode - no prior authentication
3378
3457
  logger.debug("[ConsentService] Consent-only mode detected (pure clickwrap)");
3458
+ // SECURITY (A3, spec §3.2, review #5): a pure consent-only mint is
3459
+ // intentionally anonymous/ephemeral — it never had a victim identity
3460
+ // to attribute. Any caller-supplied `user_did` is forgeable and must
3461
+ // NEVER survive. Discard it up-front, before the ephemeral/session DID
3462
+ // is (maybe) resolved below, so no sub-path — including the
3463
+ // DELEGATION_STORAGE-unbound `else` fallback that used to leave the
3464
+ // body value intact — can carry a forged DID into the mint.
3465
+ trustedUserDid = undefined;
3379
3466
  if (sessionId && this.env.DELEGATION_STORAGE) {
3380
3467
  // Check if session already has userDid (from credential auth or OAuth)
3381
3468
  let existingUserDid;
@@ -3393,7 +3480,9 @@ export class ConsentService {
3393
3480
  sessionId: sessionId.substring(0, 20) + "...",
3394
3481
  userDid: existingUserDid.substring(0, 30) + "...",
3395
3482
  });
3396
- bodyObj.user_did = existingUserDid;
3483
+ // Server-side session identity (set by a prior auth step), not a
3484
+ // caller-supplied body value — safe to trust.
3485
+ trustedUserDid = existingUserDid;
3397
3486
  }
3398
3487
  else {
3399
3488
  // Generate ephemeral userDid for pure consent-only
@@ -3404,8 +3493,9 @@ export class ConsentService {
3404
3493
  // This ensures storeDelegationToken can find it and use PRIORITY 1 key
3405
3494
  await this.updateSessionWithIdentity(sessionId, ephemeralUserDid, null // No OAuth identity for consent-only
3406
3495
  );
3407
- // Also inject the user_did into the request body so createDelegation receives it
3408
- bodyObj.user_did = ephemeralUserDid;
3496
+ // Server-generated ephemeral DID (not a caller-supplied value)
3497
+ // — safe to trust as the anonymous consent-only subject.
3498
+ trustedUserDid = ephemeralUserDid;
3409
3499
  logger.debug("[ConsentService] ✅ Ephemeral userDid stored in session for consent-only flow:", {
3410
3500
  sessionId: sessionId.substring(0, 20) + "...",
3411
3501
  userDid: ephemeralUserDid.substring(0, 30) + "...",
@@ -3422,6 +3512,16 @@ export class ConsentService {
3422
3512
  }
3423
3513
  }
3424
3514
  }
3515
+ // SECURITY (A3, spec §3.2, defect #2 :4419/:4477): overwrite the forgeable
3516
+ // `user_did` body field with the trusted value resolved above — a signed
3517
+ // assertion, or the server-generated ephemeral / session DID — for EVERY
3518
+ // shape, unconditionally. For the plain authenticated (non-clickwrap)
3519
+ // shapes `trustedUserDid` is undefined, which wipes any caller-supplied
3520
+ // `user_did` so it can never be an identity source; those shapes then fall
3521
+ // back to server-side session resolution below. This is the single choke
3522
+ // point that guarantees no downstream consumer (validation,
3523
+ // createDelegation, storeDelegationToken) ever reads a caller-forged DID.
3524
+ bodyObj.user_did = trustedUserDid;
3425
3525
  // Convert null oauth_identity to undefined for proper schema validation
3426
3526
  // Zod's .nullish() should handle null, but converting to undefined is more explicit
3427
3527
  // and avoids potential edge cases with FormData parsing
@@ -3553,11 +3653,21 @@ export class ConsentService {
3553
3653
  delegationId: delegationResult.delegation_id?.substring(0, 20) + "...",
3554
3654
  });
3555
3655
  // Store delegation token to DO (strongly consistent) and KV (backward compat)
3556
- // Pass user_did directly if available (from redirect params) to bypass KV consistency issues
3656
+ // SECURITY (A3, defect #2 :4477): pass the trusted `resolvedUserDid`
3657
+ // (assertion / ephemeral / session-resolved), NOT the raw body
3658
+ // `approvalRequest.user_did`, so the stored delegation key is never scoped
3659
+ // to a caller-forged DID. `resolvedUserDid` is a superset of the body
3660
+ // value (which is already neutralised above) plus server-side resolution.
3557
3661
  const serverOrigin = new URL(request.url).origin;
3558
- await this.storeDelegationToken(approvalRequest.session_id, approvalRequest.agent_did, delegationResult.delegation_token, delegationResult.delegation_id, approvalRequest.user_did, // Pass userDid directly to bypass KV read
3559
- serverOrigin // Pass origin for DO URL construction
3560
- );
3662
+ await this.storeDelegationToken(approvalRequest.session_id, approvalRequest.agent_did, delegationResult.delegation_token, delegationResult.delegation_id, resolvedUserDid, // trusted identity only (see above)
3663
+ serverOrigin, // Pass origin for DO URL construction
3664
+ // Task B2: the EFFECTIVE provider_type is the delegation's auth type.
3665
+ // On this ('none') branch it is one of 'none' | 'password' | 'oauth'
3666
+ // (the post-credential / post-OAuth clickwrap branches rewrote it to
3667
+ // match the tool's authorization.type; 'credential'/'password' form
3668
+ // submissions were routed to handleCredentialApproval earlier), so it
3669
+ // maps cleanly through normalizeAuthType — no 'credential' mis-fold here.
3670
+ effectiveProviderType);
3561
3671
  // ✅ After successful delegation creation - log audit events
3562
3672
  if (auditService && delegationResult.success) {
3563
3673
  try {
@@ -3925,9 +4035,17 @@ export class ConsentService {
3925
4035
  * @param delegationId - Delegation ID
3926
4036
  * @param providedUserDid - Optional userDid passed directly (bypasses KV read for consistency)
3927
4037
  * @param serverOrigin - Server origin for DO URL construction
4038
+ * @param authorizationType - The delegation's authorization type as recorded
4039
+ * at mint (raw provider_type / authorization.type). Normalized here before
4040
+ * persisting so the call-time enforcement gate (Task B2/B3) reads a
4041
+ * consistent value ("password" | "oauth2" | "none" | …). Undefined → the
4042
+ * field is omitted and the gate fails safe (never satisfies a typed tool).
3928
4043
  */
3929
- async storeDelegationToken(sessionId, agentDid, token, delegationId, providedUserDid, serverOrigin) {
4044
+ async storeDelegationToken(sessionId, agentDid, token, delegationId, providedUserDid, serverOrigin, authorizationType) {
3930
4045
  const delegationStorage = this.env.DELEGATION_STORAGE;
4046
+ // Task B2: normalize once (folds deprecated aliases: oauth→oauth2) so every
4047
+ // stored record and every read compares in the same vocabulary.
4048
+ const normalizedAuthorizationType = normalizeAuthType(authorizationType);
3931
4049
  // Resolve userDid if not provided
3932
4050
  let userDid = providedUserDid;
3933
4051
  if (!userDid && delegationStorage) {
@@ -3943,7 +4061,14 @@ export class ConsentService {
3943
4061
  // PRIMARY: Store to Durable Object (strongly consistent)
3944
4062
  // This eliminates eventual consistency issues with KV
3945
4063
  const origin = serverOrigin || this.env.MCP_SERVER_URL || "https://localhost";
3946
- const storedViaDO = await this.storeDelegationViaDO({ sessionId, delegationToken: token, delegationId, userDid, agentDid }, origin);
4064
+ const storedViaDO = await this.storeDelegationViaDO({
4065
+ sessionId,
4066
+ delegationToken: token,
4067
+ delegationId,
4068
+ userDid,
4069
+ agentDid,
4070
+ authorizationType: normalizedAuthorizationType,
4071
+ }, origin);
3947
4072
  // ALSO store to KV for backward compatibility and cross-session persistence
3948
4073
  if (delegationStorage) {
3949
4074
  try {
@@ -3965,6 +4090,10 @@ export class ConsentService {
3965
4090
  agentDid,
3966
4091
  delegationToken: token,
3967
4092
  delegationId,
4093
+ // Task B2: mirror the auth type into the KV session record too, so
4094
+ // the two store paths (DO + KV) stay consistent. The DO record is
4095
+ // the one the enforcement gate reads; this is for parity/debugging.
4096
+ authorizationType: normalizedAuthorizationType,
3968
4097
  cachedAt: Date.now(),
3969
4098
  }), { expirationTtl: ttl });
3970
4099
  logger.debug("[ConsentService] Token also stored to KV (session key)");
@@ -4110,7 +4239,14 @@ export class ConsentService {
4110
4239
  // tool handler runs server-side through the MCP tools/call proxy, so
4111
4240
  // CSRF is not applicable.
4112
4241
  const isInlineMode = body.inline_mode === true;
4113
- if (!isInlineMode) {
4242
+ // SECURITY (A4, spec §3.3): the ONLY sanctioned CSRF relaxation is the
4243
+ // dev/test escape hatch — BOTH MCPI_TEST_CONSENT and a development env —
4244
+ // applied here at the call site (mirroring consent-anchor-bridge's
4245
+ // `isTestConsentMode` gate on `validateCsrfToken`), never inside the
4246
+ // validator/minter, which are strictly fail-closed. Production, staging,
4247
+ // and unset always enforce CSRF.
4248
+ const skipCsrf = isInlineMode || this.isDevConsentTestMode();
4249
+ if (!skipCsrf) {
4114
4250
  // Validate CSRF token
4115
4251
  if (!csrf_token || typeof csrf_token !== "string") {
4116
4252
  logger.warn("[ConsentService] Missing or invalid CSRF token");
@@ -4304,7 +4440,12 @@ export class ConsentService {
4304
4440
  }
4305
4441
  // Store delegation token (DO + KV)
4306
4442
  const serverUrl = this.env.MCP_SERVER_URL || new URL(request.url).origin;
4307
- await this.storeDelegationToken(session_id, agent_did, delegationResult.delegation_token, delegationResult.delegation_id, identityResult.userDid, serverUrl);
4443
+ await this.storeDelegationToken(session_id, agent_did, delegationResult.delegation_token, delegationResult.delegation_id, identityResult.userDid, serverUrl,
4444
+ // Task B2: the inline path is the credential (password) auth flow —
4445
+ // it authenticated against the provider and minted with
4446
+ // provider_type=PASSWORD (see approvalBody above), so the delegation's
4447
+ // authorization type is "password".
4448
+ CONSENT_PROVIDER_TYPES.PASSWORD);
4308
4449
  logger.info("[ConsentService] ✅ Inline credential auth + delegation complete", {
4309
4450
  delegationId: delegationResult.delegation_id?.substring(0, 20) + "...",
4310
4451
  });
@@ -4387,17 +4528,49 @@ export class ConsentService {
4387
4528
  // Provider's internal ID (e.g., customer ID 696395) for business reference
4388
4529
  clickwrapUrl.searchParams.set("credential_provider_user_id", authResult.userId);
4389
4530
  }
4390
- // CRITICAL FIX: Pass userDid through redirect to avoid KV eventual consistency issues
4391
- // Without this, the clickwrap approval might read stale session data from KV
4392
- // that doesn't yet have the userDid from updateSessionWithIdentity above.
4393
- // By passing userDid in the URL, we ensure the delegation is stored with the
4394
- // correct user+agent scoped key regardless of KV propagation timing.
4395
- clickwrapUrl.searchParams.set("user_did", identityResult.userDid);
4531
+ // SECURITY (A2, spec §3.1): carry the authenticated identity to the
4532
+ // clickwrap as a server-signed, grant-bound `identity_assertion` instead
4533
+ // of the bare, caller-forgeable `user_did` query param that used to live
4534
+ // here. The assertion binds {sid, userDid, provider, agentDid, tool,
4535
+ // scopesHash, projectId}; handleApproval re-verifies it before minting, so
4536
+ // the delegation's subject can no longer be forged and the KV-race the old
4537
+ // param dodged is still dodged (the identity travels in the signed token).
4538
+ //
4539
+ // Delivery: the token goes in the URL *fragment* (`#identity_assertion=…`),
4540
+ // never the query string, so it stays out of access logs, `Referer`, and
4541
+ // browser history. The consent page's client script reads the fragment and
4542
+ // posts it back (see the @kya-os/consent component). The consent render
4543
+ // Response also sets `Referrer-Policy: no-referrer` as defence in depth.
4544
+ if (this.isDevConsentTestMode()) {
4545
+ // Dev/test escape hatch (BOTH MCPI_TEST_CONSENT and a development env):
4546
+ // no signed assertion is minted, so local/miniflare runs work without
4547
+ // OAUTH_ENCRYPTION_SECRET. Mirrors consent-anchor-bridge's call-site
4548
+ // relaxation. Never reached on production/staging/unset deployments.
4549
+ clickwrapUrl.searchParams.set("user_did", identityResult.userDid);
4550
+ }
4551
+ else {
4552
+ // Fail-closed: mintIdentityAssertion throws if OAUTH_ENCRYPTION_SECRET
4553
+ // is unset, surfacing as a 500 rather than a silent unsigned fallback.
4554
+ const identityAssertion = await this.mintIdentityAssertion({
4555
+ sid: session_id,
4556
+ userDid: identityResult.userDid,
4557
+ provider: provider,
4558
+ agentDid: agent_did,
4559
+ tool: tool,
4560
+ scopes,
4561
+ projectId: project_id,
4562
+ });
4563
+ clickwrapUrl.hash = `identity_assertion=${identityAssertion}`;
4564
+ }
4396
4565
  logger.debug("[ConsentService] ✅ Credential auth complete, redirecting to clickwrap", {
4397
4566
  sessionId: session_id.substring(0, 20) + "...",
4398
4567
  userDid: identityResult.userDid.substring(0, 30) + "...",
4399
4568
  provider: provider,
4400
- clickwrapUrl: clickwrapUrl.toString().substring(0, 100) + "...",
4569
+ // Never log clickwrapUrl.toString()/.href here: the URL's fragment
4570
+ // carries the signed identity_assertion (see clickwrapUrl.hash
4571
+ // above), and query params carry other session identifiers. Log
4572
+ // only the origin + pathname.
4573
+ clickwrapUrl: clickwrapUrl.origin + clickwrapUrl.pathname,
4401
4574
  });
4402
4575
  // Return success with redirect to clickwrap page
4403
4576
  return new Response(JSON.stringify({
@@ -4701,9 +4874,15 @@ export class ConsentService {
4701
4874
  async validateCredentialCsrfToken(token, sessionId) {
4702
4875
  const secret = this.env.OAUTH_ENCRYPTION_SECRET;
4703
4876
  if (!secret) {
4704
- logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET for CSRF validation, skipping");
4705
- // Return true to allow flow to continue (graceful degradation)
4706
- return true;
4877
+ // SECURITY (A4, spec §3.3, defect #4): fail CLOSED. The old code returned
4878
+ // `true` here ("graceful degradation"), silently disabling CSRF on a
4879
+ // state-changing endpoint whenever the secret was unset — the exact
4880
+ // fail-open this PR removes. Matches consent-anchor-bridge's
4881
+ // `validateCsrfToken`. The single allowed relaxation is the dev/test
4882
+ // escape hatch applied at the CALL SITE (see `handleCredentialApproval`),
4883
+ // never here in the validator.
4884
+ logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET for CSRF validation — rejecting (fail-closed)");
4885
+ return false;
4707
4886
  }
4708
4887
  try {
4709
4888
  const dotIndex = token.indexOf(".");
@@ -4747,10 +4926,19 @@ export class ConsentService {
4747
4926
  async storeCredentialCsrfToken(sessionId) {
4748
4927
  const secret = this.env.OAUTH_ENCRYPTION_SECRET;
4749
4928
  if (!secret) {
4750
- logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET for CSRF generation");
4751
- // Fallback: random token (won't validate but allows graceful degradation)
4752
- const tokenBytes = crypto.getRandomValues(new Uint8Array(32));
4753
- return this.base64UrlEncode(tokenBytes);
4929
+ // SECURITY (A4, spec §3.3, defect #4): do NOT mint a random,
4930
+ // never-validating token when the secret is unset. The old fallback
4931
+ // handed the form a token that `validateCredentialCsrfToken` (now
4932
+ // fail-closed) can never accept — pure theatre that paired with the old
4933
+ // validate fail-OPEN to silently disable CSRF. Fail closed instead: only
4934
+ // the dev/test escape hatch may render a form without a real secret, in
4935
+ // which case the call-site skips CSRF entirely so a placeholder is safe.
4936
+ if (this.isDevConsentTestMode()) {
4937
+ logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET; issuing a dev/test placeholder CSRF token (CSRF is skipped at the call site in dev mode)");
4938
+ const tokenBytes = crypto.getRandomValues(new Uint8Array(32));
4939
+ return this.base64UrlEncode(tokenBytes);
4940
+ }
4941
+ throw new Error("[ConsentService] OAUTH_ENCRYPTION_SECRET is unset; refusing to mint a CSRF token (fail-closed)");
4754
4942
  }
4755
4943
  // 10 minute expiration (matches previous KV TTL)
4756
4944
  const exp = Math.floor(Date.now() / 1000) + 600;
@@ -4807,6 +4995,202 @@ export class ConsentService {
4807
4995
  }
4808
4996
  return result === 0;
4809
4997
  }
4998
+ /**
4999
+ * Whether local/miniflare testing may relax consent security checks.
5000
+ *
5001
+ * Mirrors `consent-anchor-bridge.ts`'s `isTestConsentMode` verbatim: requires
5002
+ * BOTH `MCPI_TEST_CONSENT === "true"` AND an explicit `development`
5003
+ * environment. The flag alone must NEVER relax a check on a
5004
+ * production/staging/unset deployment.
5005
+ *
5006
+ * NOT used by `mintIdentityAssertion`/`verifyIdentityAssertion` — those are
5007
+ * strictly fail-closed with no dev relaxation of any kind (a review found
5008
+ * that signing/verifying under an attacker-known empty key when this flag
5009
+ * is active was a latent total-forgery bypass if the two dev flags ever
5010
+ * co-occurred in production). If a dev bypass for the identity-assertion
5011
+ * requirement is ever needed, it belongs at the CALL SITE (mirroring how
5012
+ * the anchor bridge bypasses CSRF via this flag at the handler, before
5013
+ * `validateCsrfToken` is even invoked — never inside the crypto itself).
5014
+ * Public so the OAuth callback path (runtime/oauth-handler.ts) can gate its
5015
+ * own redirect mint on the identical dev/test relaxation used by the
5016
+ * credential redirect above, rather than re-deriving the two-flag check.
5017
+ */
5018
+ isDevConsentTestMode() {
5019
+ if (this.env.MCPI_TEST_CONSENT !== "true") {
5020
+ return false;
5021
+ }
5022
+ const environment = (this.env.ENVIRONMENT ??
5023
+ this.env.MCPI_ENV ??
5024
+ "").toLowerCase();
5025
+ return environment === "development";
5026
+ }
5027
+ /**
5028
+ * SHA-256 hex digest of the given scopes, sorted ascending and
5029
+ * JSON-array-encoded. Used as the identity assertion's `scopesHash` so the
5030
+ * signed grant binds to the scope *set* regardless of the order scopes
5031
+ * arrive in the request body. Mirrors the digest-to-hex shape already used
5032
+ * elsewhere in this codebase (e.g. `DelegationService.hashToken`).
5033
+ *
5034
+ * `JSON.stringify` (not a `\n`-joined string) is required for injectivity:
5035
+ * a plain join collides distinct scope sets that share the same joined
5036
+ * bytes — e.g. `["a\nb"]` and `["a", "b"]` both join to `"a\nb"`. JSON
5037
+ * array encoding preserves each element's boundary (quoting + `,`
5038
+ * separators), so distinct scope sets can never hash identically.
5039
+ */
5040
+ async hashScopes(scopes) {
5041
+ const canonical = JSON.stringify([...scopes].sort());
5042
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
5043
+ return Array.from(new Uint8Array(digest))
5044
+ .map((b) => b.toString(16).padStart(2, "0"))
5045
+ .join("");
5046
+ }
5047
+ /**
5048
+ * Compute HMAC-SHA256 for identity-assertion generation/validation.
5049
+ *
5050
+ * Structurally identical to `computeCsrfHmac`, but `computeCsrfHmac`
5051
+ * hardcodes the `csrf:` key prefix internally so it cannot be reused for a
5052
+ * different domain — this mirrors its shape with an `identity:` prefix
5053
+ * instead, so a CSRF token and an identity assertion are signed under
5054
+ * disjoint keys and can never validate as one another.
5055
+ */
5056
+ async computeIdentityHmac(secret, data) {
5057
+ const encoder = new TextEncoder();
5058
+ const keyMaterial = encoder.encode(`identity:${secret}`);
5059
+ const key = await crypto.subtle.importKey("raw", keyMaterial, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
5060
+ return crypto.subtle.sign("HMAC", key, encoder.encode(data));
5061
+ }
5062
+ /**
5063
+ * Mint a grant-bound, domain-separated HMAC identity assertion.
5064
+ *
5065
+ * Carries the `userDid` resolved by a completed authentication step
5066
+ * (credential or OAuth) across the consent redirect, replacing the
5067
+ * caller-forgeable `user_did` body field (see
5068
+ * docs/superpowers/specs/2026-07-29-consent-approve-auth-gate-design.md
5069
+ * §3.1). Format mirrors `storeCredentialCsrfToken`:
5070
+ * `${hmacB64url}.${payloadB64url}`.
5071
+ *
5072
+ * Binds the grant, not just the identity: `agentDid`, `tool`, `scopesHash`
5073
+ * (SHA-256 of the sorted scope set), and `projectId` are signed in and must
5074
+ * be re-checked against the request body at mint time by the caller — a
5075
+ * leaked assertion must not be replayable to mint an arbitrary grant under
5076
+ * the victim's `userDid` within the 10-minute window.
5077
+ *
5078
+ * FAIL-CLOSED, ALWAYS: throws if `OAUTH_ENCRYPTION_SECRET` is unset or
5079
+ * empty. There is deliberately NO dev/test relaxation inside this helper —
5080
+ * signing under a predictable key (e.g. an empty-string fallback) would be
5081
+ * an attacker-known key and a latent total-forgery bypass if a dev flag
5082
+ * were ever misconfigured in production. A mint must never silently
5083
+ * produce an unsigned/unverifiable token — unlike
5084
+ * `storeCredentialCsrfToken`'s legacy "graceful degradation," which this
5085
+ * assertion deliberately does not inherit. Any future dev/test bypass
5086
+ * belongs at the call site (see `isDevConsentTestMode`'s docstring).
5087
+ *
5088
+ * Public so both authenticated redirect sites can mint under this service's
5089
+ * `identity:`-keyed secret: the credential redirect (in-class) and the OAuth
5090
+ * callback redirect (runtime/oauth-handler.ts, a separate module).
5091
+ */
5092
+ async mintIdentityAssertion(params) {
5093
+ const secret = this.env.OAUTH_ENCRYPTION_SECRET;
5094
+ if (!secret) {
5095
+ throw new Error("[ConsentService] OAUTH_ENCRYPTION_SECRET is unset; refusing to mint an identity assertion (fail-closed)");
5096
+ }
5097
+ const exp = Math.floor(Date.now() / 1000) + 600;
5098
+ const scopesHash = await this.hashScopes(params.scopes);
5099
+ const payload = JSON.stringify({
5100
+ v: 1,
5101
+ sid: params.sid,
5102
+ userDid: params.userDid,
5103
+ provider: params.provider,
5104
+ agentDid: params.agentDid,
5105
+ tool: params.tool,
5106
+ scopesHash,
5107
+ projectId: params.projectId,
5108
+ exp,
5109
+ });
5110
+ const payloadB64 = this.base64UrlEncodeString(payload);
5111
+ const hmac = await this.computeIdentityHmac(secret, payloadB64);
5112
+ const hmacB64 = this.base64UrlEncode(new Uint8Array(hmac));
5113
+ return `${hmacB64}.${payloadB64}`;
5114
+ }
5115
+ /**
5116
+ * Verify a grant-bound identity assertion minted by `mintIdentityAssertion`.
5117
+ *
5118
+ * Returns the payload's `userDid` iff: the HMAC matches (constant-time),
5119
+ * the assertion has not expired, and every bound field (`sid`, `agentDid`,
5120
+ * `tool`, `projectId`, and `scopesHash` recomputed from `expected.scopes`)
5121
+ * equals the caller-supplied `expected` values. Any mismatch, tamper, or
5122
+ * expiry returns `null` — never throws, so callers can treat `null`
5123
+ * uniformly as "no verified identity."
5124
+ *
5125
+ * FAIL-CLOSED, ALWAYS: returns `null` if `OAUTH_ENCRYPTION_SECRET` is unset
5126
+ * or empty. There is deliberately NO dev/test relaxation inside this
5127
+ * helper — mirrors `consent-anchor-bridge.ts`'s `validateCsrfToken`, NOT
5128
+ * `validateCredentialCsrfToken`'s legacy "return true to allow flow to
5129
+ * continue" behavior (the fail-open this PR closes). Any future dev/test
5130
+ * bypass belongs at the call site (see `isDevConsentTestMode`'s
5131
+ * docstring), never as a predictable/empty signing key inside this helper.
5132
+ */
5133
+ async verifyIdentityAssertion(token, expected) {
5134
+ const secret = this.env.OAUTH_ENCRYPTION_SECRET;
5135
+ if (!secret) {
5136
+ logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET for identity-assertion verification; rejecting (fail-closed)");
5137
+ return null;
5138
+ }
5139
+ try {
5140
+ const dotIndex = token.indexOf(".");
5141
+ if (dotIndex === -1) {
5142
+ logger.warn("[ConsentService] Identity assertion missing separator");
5143
+ return null;
5144
+ }
5145
+ const hmacB64 = token.substring(0, dotIndex);
5146
+ const payloadB64 = token.substring(dotIndex + 1);
5147
+ const payloadJson = this.base64UrlDecodeString(payloadB64);
5148
+ const payload = JSON.parse(payloadJson);
5149
+ if (payload.sid !== expected.sid) {
5150
+ logger.warn("[ConsentService] Identity assertion sid mismatch");
5151
+ return null;
5152
+ }
5153
+ if (payload.agentDid !== expected.agentDid) {
5154
+ logger.warn("[ConsentService] Identity assertion agentDid mismatch");
5155
+ return null;
5156
+ }
5157
+ if (payload.tool !== expected.tool) {
5158
+ logger.warn("[ConsentService] Identity assertion tool mismatch");
5159
+ return null;
5160
+ }
5161
+ if (payload.projectId !== expected.projectId) {
5162
+ logger.warn("[ConsentService] Identity assertion projectId mismatch");
5163
+ return null;
5164
+ }
5165
+ const expectedScopesHash = await this.hashScopes(expected.scopes);
5166
+ if (payload.scopesHash !== expectedScopesHash) {
5167
+ logger.warn("[ConsentService] Identity assertion scopes mismatch");
5168
+ return null;
5169
+ }
5170
+ if (typeof payload.exp !== "number" ||
5171
+ payload.exp < Math.floor(Date.now() / 1000)) {
5172
+ logger.warn("[ConsentService] Identity assertion expired");
5173
+ return null;
5174
+ }
5175
+ // Recompute HMAC and constant-time compare. Kept last so tampering with
5176
+ // any bound field above is already caught by the field checks; this is
5177
+ // the authoritative integrity check.
5178
+ const expectedHmac = await this.computeIdentityHmac(secret, payloadB64);
5179
+ const expectedB64 = this.base64UrlEncode(new Uint8Array(expectedHmac));
5180
+ if (!this.constantTimeCompare(hmacB64, expectedB64)) {
5181
+ logger.warn("[ConsentService] Identity assertion HMAC mismatch");
5182
+ return null;
5183
+ }
5184
+ if (typeof payload.userDid !== "string" || !payload.userDid) {
5185
+ return null;
5186
+ }
5187
+ return payload.userDid;
5188
+ }
5189
+ catch (error) {
5190
+ logger.error("[ConsentService] Identity assertion verification error:", error);
5191
+ return null;
5192
+ }
5193
+ }
4810
5194
  /**
4811
5195
  * Build full DelegationRecord format request (future format)
4812
5196
  */