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

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.
@@ -9,14 +9,14 @@
9
9
  import { ConsentConfigService } from "./consent-config.service";
10
10
  import { TemplateRenderer } from "./consent-templates/template-renderer";
11
11
  import { DEFAULT_AGENTSHIELD_URL, DEFAULT_SESSION_CACHE_TTL, KEY_PAIR_TTL_SECONDS, CONSENT_PROVIDER_TYPES, } from "../constants";
12
- import { STORAGE_KEYS } from "../constants/storage-keys";
12
+ import { STORAGE_KEYS, isPendingDelegationId, } from "../constants/storage-keys";
13
13
  import { loadDay0Config, getDelegationFieldName } from "../utils/day0-config";
14
14
  import { buildProviderAuthorizeUrl } from "../runtime/oidc/authorize-url.js";
15
15
  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,44 @@ 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
+ //
4049
+ // SECURITY — the authoritative pre-consent guard. This method is the single
4050
+ // choke point through which an authorization type reaches BOTH destinations
4051
+ // the enforcement gate can read: the Durable Object record
4052
+ // (`storeDelegationViaDO` below) and the KV sidecar. A `pending_` delegation
4053
+ // id means the OAuth callback parked a placeholder whose token is still the
4054
+ // raw IdP access token — the real delegation is only minted once the user
4055
+ // approves on the clickwrap. Recording a type for it would let the gate
4056
+ // treat a pre-consent token as a satisfying delegation and run a protected
4057
+ // tool before the user ever consented.
4058
+ //
4059
+ // Guarding HERE rather than at each caller is deliberate: `oauth-handler.ts`
4060
+ // reaches this via `storeDelegationForOAuth` with a hardcoded "oauth2" and
4061
+ // the pending tokenData, so a per-call-site guard would have to be repeated
4062
+ // at every present and future writer. Dropping the type to undefined makes
4063
+ // every downstream consumer fail safe, because absent already means
4064
+ // "reject" at the gate.
4065
+ const isPendingDelegation = isPendingDelegationId(delegationId);
4066
+ const normalizedAuthorizationType = isPendingDelegation
4067
+ ? undefined
4068
+ : normalizeAuthType(authorizationType);
4069
+ if (isPendingDelegation && authorizationType) {
4070
+ logger.warn("[ConsentService] Refusing to record an authorization type for a pending (pre-consent) delegation", {
4071
+ delegationId,
4072
+ requestedAuthorizationType: authorizationType,
4073
+ note: "The real delegation is minted after clickwrap approval; typing this token would authorize it pre-consent.",
4074
+ });
4075
+ }
3931
4076
  // Resolve userDid if not provided
3932
4077
  let userDid = providedUserDid;
3933
4078
  if (!userDid && delegationStorage) {
@@ -3943,7 +4088,14 @@ export class ConsentService {
3943
4088
  // PRIMARY: Store to Durable Object (strongly consistent)
3944
4089
  // This eliminates eventual consistency issues with KV
3945
4090
  const origin = serverOrigin || this.env.MCP_SERVER_URL || "https://localhost";
3946
- const storedViaDO = await this.storeDelegationViaDO({ sessionId, delegationToken: token, delegationId, userDid, agentDid }, origin);
4091
+ const storedViaDO = await this.storeDelegationViaDO({
4092
+ sessionId,
4093
+ delegationToken: token,
4094
+ delegationId,
4095
+ userDid,
4096
+ agentDid,
4097
+ authorizationType: normalizedAuthorizationType,
4098
+ }, origin);
3947
4099
  // ALSO store to KV for backward compatibility and cross-session persistence
3948
4100
  if (delegationStorage) {
3949
4101
  try {
@@ -3954,6 +4106,36 @@ export class ConsentService {
3954
4106
  await delegationStorage.put(userAgentKey, token, {
3955
4107
  expirationTtl: ttl,
3956
4108
  });
4109
+ // Record the auth type alongside it (separate key — the value above
4110
+ // is a bare token that several readers consume as the JWT itself).
4111
+ // Without this, a delegation recovered via the KV-legacy path in
4112
+ // agent.ts carries no type and can never satisfy a typed tool, so a
4113
+ // DO miss becomes a permanent re-challenge loop rather than a
4114
+ // one-time re-auth.
4115
+ //
4116
+ // INVARIANT: a sidecar present ⇒ it describes the token currently
4117
+ // under the key above. So an untyped write must CLEAR any existing
4118
+ // sidecar rather than leave it: the token was just replaced, and a
4119
+ // leftover type from a previous mint would be inherited by the new
4120
+ // (weaker or unknown) token on the next KV read. Revocation already
4121
+ // deletes the sidecar for exactly this reason; a remint must too.
4122
+ const authTypeKey = STORAGE_KEYS.delegationAuthType(userDid, agentDid);
4123
+ if (normalizedAuthorizationType) {
4124
+ await delegationStorage.put(authTypeKey, normalizedAuthorizationType, { expirationTtl: ttl });
4125
+ }
4126
+ else {
4127
+ // Clearing a stale sidecar is cleanup, not the primary write: it
4128
+ // must never abort the session-key write that follows. Swallow its
4129
+ // failure rather than letting it escape to the enclosing catch.
4130
+ // Failing to clear is safe in the conservative direction only for
4131
+ // the token it described, so it is logged rather than ignored.
4132
+ try {
4133
+ await delegationStorage.delete?.(authTypeKey);
4134
+ }
4135
+ catch (clearError) {
4136
+ logger.warn("[ConsentService] Could not clear stale delegation auth-type sidecar", clearError);
4137
+ }
4138
+ }
3957
4139
  logger.debug("[ConsentService] Token also stored to KV (user+agent key)");
3958
4140
  }
3959
4141
  // Store session key (for session data preservation)
@@ -3965,6 +4147,10 @@ export class ConsentService {
3965
4147
  agentDid,
3966
4148
  delegationToken: token,
3967
4149
  delegationId,
4150
+ // Task B2: mirror the auth type into the KV session record too, so
4151
+ // the two store paths (DO + KV) stay consistent. The DO record is
4152
+ // the one the enforcement gate reads; this is for parity/debugging.
4153
+ authorizationType: normalizedAuthorizationType,
3968
4154
  cachedAt: Date.now(),
3969
4155
  }), { expirationTtl: ttl });
3970
4156
  logger.debug("[ConsentService] Token also stored to KV (session key)");
@@ -4110,7 +4296,14 @@ export class ConsentService {
4110
4296
  // tool handler runs server-side through the MCP tools/call proxy, so
4111
4297
  // CSRF is not applicable.
4112
4298
  const isInlineMode = body.inline_mode === true;
4113
- if (!isInlineMode) {
4299
+ // SECURITY (A4, spec §3.3): the ONLY sanctioned CSRF relaxation is the
4300
+ // dev/test escape hatch — BOTH MCPI_TEST_CONSENT and a development env —
4301
+ // applied here at the call site (mirroring consent-anchor-bridge's
4302
+ // `isTestConsentMode` gate on `validateCsrfToken`), never inside the
4303
+ // validator/minter, which are strictly fail-closed. Production, staging,
4304
+ // and unset always enforce CSRF.
4305
+ const skipCsrf = isInlineMode || this.isDevConsentTestMode();
4306
+ if (!skipCsrf) {
4114
4307
  // Validate CSRF token
4115
4308
  if (!csrf_token || typeof csrf_token !== "string") {
4116
4309
  logger.warn("[ConsentService] Missing or invalid CSRF token");
@@ -4304,7 +4497,12 @@ export class ConsentService {
4304
4497
  }
4305
4498
  // Store delegation token (DO + KV)
4306
4499
  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);
4500
+ await this.storeDelegationToken(session_id, agent_did, delegationResult.delegation_token, delegationResult.delegation_id, identityResult.userDid, serverUrl,
4501
+ // Task B2: the inline path is the credential (password) auth flow —
4502
+ // it authenticated against the provider and minted with
4503
+ // provider_type=PASSWORD (see approvalBody above), so the delegation's
4504
+ // authorization type is "password".
4505
+ CONSENT_PROVIDER_TYPES.PASSWORD);
4308
4506
  logger.info("[ConsentService] ✅ Inline credential auth + delegation complete", {
4309
4507
  delegationId: delegationResult.delegation_id?.substring(0, 20) + "...",
4310
4508
  });
@@ -4387,17 +4585,49 @@ export class ConsentService {
4387
4585
  // Provider's internal ID (e.g., customer ID 696395) for business reference
4388
4586
  clickwrapUrl.searchParams.set("credential_provider_user_id", authResult.userId);
4389
4587
  }
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);
4588
+ // SECURITY (A2, spec §3.1): carry the authenticated identity to the
4589
+ // clickwrap as a server-signed, grant-bound `identity_assertion` instead
4590
+ // of the bare, caller-forgeable `user_did` query param that used to live
4591
+ // here. The assertion binds {sid, userDid, provider, agentDid, tool,
4592
+ // scopesHash, projectId}; handleApproval re-verifies it before minting, so
4593
+ // the delegation's subject can no longer be forged and the KV-race the old
4594
+ // param dodged is still dodged (the identity travels in the signed token).
4595
+ //
4596
+ // Delivery: the token goes in the URL *fragment* (`#identity_assertion=…`),
4597
+ // never the query string, so it stays out of access logs, `Referer`, and
4598
+ // browser history. The consent page's client script reads the fragment and
4599
+ // posts it back (see the @kya-os/consent component). The consent render
4600
+ // Response also sets `Referrer-Policy: no-referrer` as defence in depth.
4601
+ if (this.isDevConsentTestMode()) {
4602
+ // Dev/test escape hatch (BOTH MCPI_TEST_CONSENT and a development env):
4603
+ // no signed assertion is minted, so local/miniflare runs work without
4604
+ // OAUTH_ENCRYPTION_SECRET. Mirrors consent-anchor-bridge's call-site
4605
+ // relaxation. Never reached on production/staging/unset deployments.
4606
+ clickwrapUrl.searchParams.set("user_did", identityResult.userDid);
4607
+ }
4608
+ else {
4609
+ // Fail-closed: mintIdentityAssertion throws if OAUTH_ENCRYPTION_SECRET
4610
+ // is unset, surfacing as a 500 rather than a silent unsigned fallback.
4611
+ const identityAssertion = await this.mintIdentityAssertion({
4612
+ sid: session_id,
4613
+ userDid: identityResult.userDid,
4614
+ provider: provider,
4615
+ agentDid: agent_did,
4616
+ tool: tool,
4617
+ scopes,
4618
+ projectId: project_id,
4619
+ });
4620
+ clickwrapUrl.hash = `identity_assertion=${identityAssertion}`;
4621
+ }
4396
4622
  logger.debug("[ConsentService] ✅ Credential auth complete, redirecting to clickwrap", {
4397
4623
  sessionId: session_id.substring(0, 20) + "...",
4398
4624
  userDid: identityResult.userDid.substring(0, 30) + "...",
4399
4625
  provider: provider,
4400
- clickwrapUrl: clickwrapUrl.toString().substring(0, 100) + "...",
4626
+ // Never log clickwrapUrl.toString()/.href here: the URL's fragment
4627
+ // carries the signed identity_assertion (see clickwrapUrl.hash
4628
+ // above), and query params carry other session identifiers. Log
4629
+ // only the origin + pathname.
4630
+ clickwrapUrl: clickwrapUrl.origin + clickwrapUrl.pathname,
4401
4631
  });
4402
4632
  // Return success with redirect to clickwrap page
4403
4633
  return new Response(JSON.stringify({
@@ -4701,9 +4931,15 @@ export class ConsentService {
4701
4931
  async validateCredentialCsrfToken(token, sessionId) {
4702
4932
  const secret = this.env.OAUTH_ENCRYPTION_SECRET;
4703
4933
  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;
4934
+ // SECURITY (A4, spec §3.3, defect #4): fail CLOSED. The old code returned
4935
+ // `true` here ("graceful degradation"), silently disabling CSRF on a
4936
+ // state-changing endpoint whenever the secret was unset — the exact
4937
+ // fail-open this PR removes. Matches consent-anchor-bridge's
4938
+ // `validateCsrfToken`. The single allowed relaxation is the dev/test
4939
+ // escape hatch applied at the CALL SITE (see `handleCredentialApproval`),
4940
+ // never here in the validator.
4941
+ logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET for CSRF validation — rejecting (fail-closed)");
4942
+ return false;
4707
4943
  }
4708
4944
  try {
4709
4945
  const dotIndex = token.indexOf(".");
@@ -4747,10 +4983,19 @@ export class ConsentService {
4747
4983
  async storeCredentialCsrfToken(sessionId) {
4748
4984
  const secret = this.env.OAUTH_ENCRYPTION_SECRET;
4749
4985
  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);
4986
+ // SECURITY (A4, spec §3.3, defect #4): do NOT mint a random,
4987
+ // never-validating token when the secret is unset. The old fallback
4988
+ // handed the form a token that `validateCredentialCsrfToken` (now
4989
+ // fail-closed) can never accept — pure theatre that paired with the old
4990
+ // validate fail-OPEN to silently disable CSRF. Fail closed instead: only
4991
+ // the dev/test escape hatch may render a form without a real secret, in
4992
+ // which case the call-site skips CSRF entirely so a placeholder is safe.
4993
+ if (this.isDevConsentTestMode()) {
4994
+ logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET; issuing a dev/test placeholder CSRF token (CSRF is skipped at the call site in dev mode)");
4995
+ const tokenBytes = crypto.getRandomValues(new Uint8Array(32));
4996
+ return this.base64UrlEncode(tokenBytes);
4997
+ }
4998
+ throw new Error("[ConsentService] OAUTH_ENCRYPTION_SECRET is unset; refusing to mint a CSRF token (fail-closed)");
4754
4999
  }
4755
5000
  // 10 minute expiration (matches previous KV TTL)
4756
5001
  const exp = Math.floor(Date.now() / 1000) + 600;
@@ -4807,6 +5052,202 @@ export class ConsentService {
4807
5052
  }
4808
5053
  return result === 0;
4809
5054
  }
5055
+ /**
5056
+ * Whether local/miniflare testing may relax consent security checks.
5057
+ *
5058
+ * Mirrors `consent-anchor-bridge.ts`'s `isTestConsentMode` verbatim: requires
5059
+ * BOTH `MCPI_TEST_CONSENT === "true"` AND an explicit `development`
5060
+ * environment. The flag alone must NEVER relax a check on a
5061
+ * production/staging/unset deployment.
5062
+ *
5063
+ * NOT used by `mintIdentityAssertion`/`verifyIdentityAssertion` — those are
5064
+ * strictly fail-closed with no dev relaxation of any kind (a review found
5065
+ * that signing/verifying under an attacker-known empty key when this flag
5066
+ * is active was a latent total-forgery bypass if the two dev flags ever
5067
+ * co-occurred in production). If a dev bypass for the identity-assertion
5068
+ * requirement is ever needed, it belongs at the CALL SITE (mirroring how
5069
+ * the anchor bridge bypasses CSRF via this flag at the handler, before
5070
+ * `validateCsrfToken` is even invoked — never inside the crypto itself).
5071
+ * Public so the OAuth callback path (runtime/oauth-handler.ts) can gate its
5072
+ * own redirect mint on the identical dev/test relaxation used by the
5073
+ * credential redirect above, rather than re-deriving the two-flag check.
5074
+ */
5075
+ isDevConsentTestMode() {
5076
+ if (this.env.MCPI_TEST_CONSENT !== "true") {
5077
+ return false;
5078
+ }
5079
+ const environment = (this.env.ENVIRONMENT ??
5080
+ this.env.MCPI_ENV ??
5081
+ "").toLowerCase();
5082
+ return environment === "development";
5083
+ }
5084
+ /**
5085
+ * SHA-256 hex digest of the given scopes, sorted ascending and
5086
+ * JSON-array-encoded. Used as the identity assertion's `scopesHash` so the
5087
+ * signed grant binds to the scope *set* regardless of the order scopes
5088
+ * arrive in the request body. Mirrors the digest-to-hex shape already used
5089
+ * elsewhere in this codebase (e.g. `DelegationService.hashToken`).
5090
+ *
5091
+ * `JSON.stringify` (not a `\n`-joined string) is required for injectivity:
5092
+ * a plain join collides distinct scope sets that share the same joined
5093
+ * bytes — e.g. `["a\nb"]` and `["a", "b"]` both join to `"a\nb"`. JSON
5094
+ * array encoding preserves each element's boundary (quoting + `,`
5095
+ * separators), so distinct scope sets can never hash identically.
5096
+ */
5097
+ async hashScopes(scopes) {
5098
+ const canonical = JSON.stringify([...scopes].sort());
5099
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
5100
+ return Array.from(new Uint8Array(digest))
5101
+ .map((b) => b.toString(16).padStart(2, "0"))
5102
+ .join("");
5103
+ }
5104
+ /**
5105
+ * Compute HMAC-SHA256 for identity-assertion generation/validation.
5106
+ *
5107
+ * Structurally identical to `computeCsrfHmac`, but `computeCsrfHmac`
5108
+ * hardcodes the `csrf:` key prefix internally so it cannot be reused for a
5109
+ * different domain — this mirrors its shape with an `identity:` prefix
5110
+ * instead, so a CSRF token and an identity assertion are signed under
5111
+ * disjoint keys and can never validate as one another.
5112
+ */
5113
+ async computeIdentityHmac(secret, data) {
5114
+ const encoder = new TextEncoder();
5115
+ const keyMaterial = encoder.encode(`identity:${secret}`);
5116
+ const key = await crypto.subtle.importKey("raw", keyMaterial, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
5117
+ return crypto.subtle.sign("HMAC", key, encoder.encode(data));
5118
+ }
5119
+ /**
5120
+ * Mint a grant-bound, domain-separated HMAC identity assertion.
5121
+ *
5122
+ * Carries the `userDid` resolved by a completed authentication step
5123
+ * (credential or OAuth) across the consent redirect, replacing the
5124
+ * caller-forgeable `user_did` body field (see
5125
+ * docs/superpowers/specs/2026-07-29-consent-approve-auth-gate-design.md
5126
+ * §3.1). Format mirrors `storeCredentialCsrfToken`:
5127
+ * `${hmacB64url}.${payloadB64url}`.
5128
+ *
5129
+ * Binds the grant, not just the identity: `agentDid`, `tool`, `scopesHash`
5130
+ * (SHA-256 of the sorted scope set), and `projectId` are signed in and must
5131
+ * be re-checked against the request body at mint time by the caller — a
5132
+ * leaked assertion must not be replayable to mint an arbitrary grant under
5133
+ * the victim's `userDid` within the 10-minute window.
5134
+ *
5135
+ * FAIL-CLOSED, ALWAYS: throws if `OAUTH_ENCRYPTION_SECRET` is unset or
5136
+ * empty. There is deliberately NO dev/test relaxation inside this helper —
5137
+ * signing under a predictable key (e.g. an empty-string fallback) would be
5138
+ * an attacker-known key and a latent total-forgery bypass if a dev flag
5139
+ * were ever misconfigured in production. A mint must never silently
5140
+ * produce an unsigned/unverifiable token — unlike
5141
+ * `storeCredentialCsrfToken`'s legacy "graceful degradation," which this
5142
+ * assertion deliberately does not inherit. Any future dev/test bypass
5143
+ * belongs at the call site (see `isDevConsentTestMode`'s docstring).
5144
+ *
5145
+ * Public so both authenticated redirect sites can mint under this service's
5146
+ * `identity:`-keyed secret: the credential redirect (in-class) and the OAuth
5147
+ * callback redirect (runtime/oauth-handler.ts, a separate module).
5148
+ */
5149
+ async mintIdentityAssertion(params) {
5150
+ const secret = this.env.OAUTH_ENCRYPTION_SECRET;
5151
+ if (!secret) {
5152
+ throw new Error("[ConsentService] OAUTH_ENCRYPTION_SECRET is unset; refusing to mint an identity assertion (fail-closed)");
5153
+ }
5154
+ const exp = Math.floor(Date.now() / 1000) + 600;
5155
+ const scopesHash = await this.hashScopes(params.scopes);
5156
+ const payload = JSON.stringify({
5157
+ v: 1,
5158
+ sid: params.sid,
5159
+ userDid: params.userDid,
5160
+ provider: params.provider,
5161
+ agentDid: params.agentDid,
5162
+ tool: params.tool,
5163
+ scopesHash,
5164
+ projectId: params.projectId,
5165
+ exp,
5166
+ });
5167
+ const payloadB64 = this.base64UrlEncodeString(payload);
5168
+ const hmac = await this.computeIdentityHmac(secret, payloadB64);
5169
+ const hmacB64 = this.base64UrlEncode(new Uint8Array(hmac));
5170
+ return `${hmacB64}.${payloadB64}`;
5171
+ }
5172
+ /**
5173
+ * Verify a grant-bound identity assertion minted by `mintIdentityAssertion`.
5174
+ *
5175
+ * Returns the payload's `userDid` iff: the HMAC matches (constant-time),
5176
+ * the assertion has not expired, and every bound field (`sid`, `agentDid`,
5177
+ * `tool`, `projectId`, and `scopesHash` recomputed from `expected.scopes`)
5178
+ * equals the caller-supplied `expected` values. Any mismatch, tamper, or
5179
+ * expiry returns `null` — never throws, so callers can treat `null`
5180
+ * uniformly as "no verified identity."
5181
+ *
5182
+ * FAIL-CLOSED, ALWAYS: returns `null` if `OAUTH_ENCRYPTION_SECRET` is unset
5183
+ * or empty. There is deliberately NO dev/test relaxation inside this
5184
+ * helper — mirrors `consent-anchor-bridge.ts`'s `validateCsrfToken`, NOT
5185
+ * `validateCredentialCsrfToken`'s legacy "return true to allow flow to
5186
+ * continue" behavior (the fail-open this PR closes). Any future dev/test
5187
+ * bypass belongs at the call site (see `isDevConsentTestMode`'s
5188
+ * docstring), never as a predictable/empty signing key inside this helper.
5189
+ */
5190
+ async verifyIdentityAssertion(token, expected) {
5191
+ const secret = this.env.OAUTH_ENCRYPTION_SECRET;
5192
+ if (!secret) {
5193
+ logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET for identity-assertion verification; rejecting (fail-closed)");
5194
+ return null;
5195
+ }
5196
+ try {
5197
+ const dotIndex = token.indexOf(".");
5198
+ if (dotIndex === -1) {
5199
+ logger.warn("[ConsentService] Identity assertion missing separator");
5200
+ return null;
5201
+ }
5202
+ const hmacB64 = token.substring(0, dotIndex);
5203
+ const payloadB64 = token.substring(dotIndex + 1);
5204
+ const payloadJson = this.base64UrlDecodeString(payloadB64);
5205
+ const payload = JSON.parse(payloadJson);
5206
+ if (payload.sid !== expected.sid) {
5207
+ logger.warn("[ConsentService] Identity assertion sid mismatch");
5208
+ return null;
5209
+ }
5210
+ if (payload.agentDid !== expected.agentDid) {
5211
+ logger.warn("[ConsentService] Identity assertion agentDid mismatch");
5212
+ return null;
5213
+ }
5214
+ if (payload.tool !== expected.tool) {
5215
+ logger.warn("[ConsentService] Identity assertion tool mismatch");
5216
+ return null;
5217
+ }
5218
+ if (payload.projectId !== expected.projectId) {
5219
+ logger.warn("[ConsentService] Identity assertion projectId mismatch");
5220
+ return null;
5221
+ }
5222
+ const expectedScopesHash = await this.hashScopes(expected.scopes);
5223
+ if (payload.scopesHash !== expectedScopesHash) {
5224
+ logger.warn("[ConsentService] Identity assertion scopes mismatch");
5225
+ return null;
5226
+ }
5227
+ if (typeof payload.exp !== "number" ||
5228
+ payload.exp < Math.floor(Date.now() / 1000)) {
5229
+ logger.warn("[ConsentService] Identity assertion expired");
5230
+ return null;
5231
+ }
5232
+ // Recompute HMAC and constant-time compare. Kept last so tampering with
5233
+ // any bound field above is already caught by the field checks; this is
5234
+ // the authoritative integrity check.
5235
+ const expectedHmac = await this.computeIdentityHmac(secret, payloadB64);
5236
+ const expectedB64 = this.base64UrlEncode(new Uint8Array(expectedHmac));
5237
+ if (!this.constantTimeCompare(hmacB64, expectedB64)) {
5238
+ logger.warn("[ConsentService] Identity assertion HMAC mismatch");
5239
+ return null;
5240
+ }
5241
+ if (typeof payload.userDid !== "string" || !payload.userDid) {
5242
+ return null;
5243
+ }
5244
+ return payload.userDid;
5245
+ }
5246
+ catch (error) {
5247
+ logger.error("[ConsentService] Identity assertion verification error:", error);
5248
+ return null;
5249
+ }
5250
+ }
4810
5251
  /**
4811
5252
  * Build full DelegationRecord format request (future format)
4812
5253
  */