@kya-os/mcp-i-cloudflare 1.13.0 → 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.
Files changed (37) hide show
  1. package/dist/agent.d.ts +7 -0
  2. package/dist/agent.d.ts.map +1 -1
  3. package/dist/agent.js +43 -7
  4. package/dist/agent.js.map +1 -1
  5. package/dist/helpers/env-mapper.d.ts.map +1 -1
  6. package/dist/helpers/env-mapper.js +6 -0
  7. package/dist/helpers/env-mapper.js.map +1 -1
  8. package/dist/runtime/oauth-handler.d.ts.map +1 -1
  9. package/dist/runtime/oauth-handler.js +71 -5
  10. package/dist/runtime/oauth-handler.js.map +1 -1
  11. package/dist/runtime/oidc/idp-scopes.d.ts +26 -0
  12. package/dist/runtime/oidc/idp-scopes.d.ts.map +1 -0
  13. package/dist/runtime/oidc/idp-scopes.js +24 -0
  14. package/dist/runtime/oidc/idp-scopes.js.map +1 -0
  15. package/dist/services/batch-queue.d.ts +103 -0
  16. package/dist/services/batch-queue.d.ts.map +1 -0
  17. package/dist/services/batch-queue.js +180 -0
  18. package/dist/services/batch-queue.js.map +1 -0
  19. package/dist/services/consent.service.d.ts +124 -0
  20. package/dist/services/consent.service.d.ts.map +1 -1
  21. package/dist/services/consent.service.js +542 -101
  22. package/dist/services/consent.service.js.map +1 -1
  23. package/dist/services/kya-os-events.service.d.ts +131 -0
  24. package/dist/services/kya-os-events.service.d.ts.map +1 -0
  25. package/dist/services/kya-os-events.service.js +135 -0
  26. package/dist/services/kya-os-events.service.js.map +1 -0
  27. package/dist/services/proof-batch-queue.d.ts +15 -67
  28. package/dist/services/proof-batch-queue.d.ts.map +1 -1
  29. package/dist/services/proof-batch-queue.js +12 -147
  30. package/dist/services/proof-batch-queue.js.map +1 -1
  31. package/dist/services/proof.service.d.ts +7 -0
  32. package/dist/services/proof.service.d.ts.map +1 -1
  33. package/dist/services/proof.service.js +35 -0
  34. package/dist/services/proof.service.js.map +1 -1
  35. package/dist/types.d.ts +2 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/package.json +4 -3
@@ -12,10 +12,11 @@ import { DEFAULT_AGENTSHIELD_URL, DEFAULT_SESSION_CACHE_TTL, KEY_PAIR_TTL_SECOND
12
12
  import { STORAGE_KEYS } from "../constants/storage-keys";
13
13
  import { loadDay0Config, getDelegationFieldName } from "../utils/day0-config";
14
14
  import { buildProviderAuthorizeUrl } from "../runtime/oidc/authorize-url.js";
15
+ import { resolveIdpScopes } from "../runtime/oidc/idp-scopes.js";
15
16
  import { validateConsentApprovalRequest, } from "@kya-os/contracts/consent";
16
17
  import { AGENTSHIELD_ENDPOINTS, createDelegationAPIResponseSchema, createDelegationResponseSchema, } from "@kya-os/contracts/agentshield-api";
17
18
  import { createUnsignedVCJWT, completeVCJWT, parseVCJWT, generateDidKeyFromBase64, createDelegationVerifier, createDidKeyResolver, logger, base64urlEncodeFromBytes, base64urlDecodeToBytes, bytesToBase64, wrapDelegationAsVC, } from "@kya-os/mcp";
18
- 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)
19
20
  import { WebCryptoProvider } from "../providers/crypto";
20
21
  import { ConsentAuditService } from "./consent-audit.service";
21
22
  import { CloudflareProofGenerator } from "../proof-generator";
@@ -145,7 +146,7 @@ export class ConsentService {
145
146
  * @returns true if DO storage succeeded (strongly consistent), false if fell back to KV only
146
147
  */
147
148
  async storeDelegationForOAuth(params, serverOrigin) {
148
- 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);
149
150
  }
150
151
  /**
151
152
  * Initialize audit service - fetches config from remote API
@@ -1154,13 +1155,19 @@ export class ConsentService {
1154
1155
  !providerConfig.proxyMode) {
1155
1156
  // Use providerConfig.clientId from AgentShield dashboard config
1156
1157
  const oauthClientId = providerConfig.clientId || projectId;
1158
+ // The `scopes` argument holds MCP-I delegation scopes (e.g. "greet:execute"),
1159
+ // which the identity provider has never heard of. Only the provider's own
1160
+ // scopes belong in its authorization URL; delegation scopes already travel
1161
+ // separately in `stateData.scopes` above, for the callback to mint the
1162
+ // delegation.
1163
+ const idpScopes = resolveIdpScopes(providerConfig);
1157
1164
  logger.debug("[ConsentService] Using direct OAuth mode (PKCE)", {
1158
1165
  provider: provider || "unknown",
1159
1166
  authorizationUrl: providerConfig.authorizationUrl,
1160
1167
  supportsPKCE: providerConfig.supportsPKCE,
1161
1168
  clientId: oauthClientId.substring(0, 8) + "...",
1162
1169
  });
1163
- return this.buildDirectOAuthUrl(providerConfig, oauthClientId, `${serverUrl}/oauth/callback`, scopes, stateParam, codeChallenge);
1170
+ return this.buildDirectOAuthUrl(providerConfig, oauthClientId, `${serverUrl}/oauth/callback`, idpScopes, stateParam, codeChallenge);
1164
1171
  }
1165
1172
  // Phase 3: Validate custom parameters don't conflict with reserved parameters
1166
1173
  const RESERVED_PARAMS = [
@@ -1195,7 +1202,9 @@ export class ConsentService {
1195
1202
  oauthUrl.searchParams.set("response_type", "code");
1196
1203
  oauthUrl.searchParams.set("client_id", projectId); // Use projectId as client_id
1197
1204
  oauthUrl.searchParams.set("redirect_uri", `${serverUrl}/oauth/callback`);
1198
- oauthUrl.searchParams.set("scope", scopes.join(" "));
1205
+ // Same separation as the direct-PKCE branch above: `scopes` is the MCP-I
1206
+ // delegation scopes, not what belongs in the IdP-facing `scope` param.
1207
+ oauthUrl.searchParams.set("scope", resolveIdpScopes(providerConfig).join(" "));
1199
1208
  oauthUrl.searchParams.set("state", stateParam);
1200
1209
  // ✅ Pass provider to AgentShield bouncer so it selects the correct auth method
1201
1210
  // This is critical when multiple providers are configured for a project
@@ -2121,6 +2130,10 @@ export class ConsentService {
2121
2130
  headers: {
2122
2131
  "Content-Type": "text/html; charset=utf-8",
2123
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",
2124
2137
  },
2125
2138
  });
2126
2139
  }
@@ -2131,6 +2144,10 @@ export class ConsentService {
2131
2144
  headers: {
2132
2145
  "Content-Type": "text/html; charset=utf-8",
2133
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",
2134
2151
  },
2135
2152
  });
2136
2153
  }
@@ -3219,6 +3236,36 @@ export class ConsentService {
3219
3236
  throw new Error(`Failed to parse request body: ${error instanceof Error ? error.message : "Unknown error"}`);
3220
3237
  }
3221
3238
  }
3239
+ /**
3240
+ * Interpret the `CONSENT_REQUIRE_IDENTITY` env override that gates the
3241
+ * identity-required refusal in `handleApproval`. Defaults to enabled - only
3242
+ * an explicit `"false"`/`"0"` disables it, mirroring the escape-hatch
3243
+ * convention used for `MCPI_HTTP_CHALLENGE` in config.ts.
3244
+ */
3245
+ isIdentityRequired() {
3246
+ const raw = this.env.CONSENT_REQUIRE_IDENTITY;
3247
+ if (raw === undefined || raw === null)
3248
+ return true;
3249
+ const normalized = String(raw).trim().toLowerCase();
3250
+ return normalized !== "false" && normalized !== "0";
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
+ }
3222
3269
  /**
3223
3270
  * Handle consent approval
3224
3271
  *
@@ -3244,6 +3291,16 @@ export class ConsentService {
3244
3291
  // - 'none': Consent-only mode (clickwrap) - user agrees without authentication
3245
3292
  const bodyObj = body;
3246
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;
3247
3304
  // CRED-003: Check for credential provider submission
3248
3305
  // Credential submissions include `provider_type: 'credential'` or 'password' and are handled separately
3249
3306
  // IMPORTANT: AgentShield tool protection uses 'password' for credential-based auth validation,
@@ -3282,18 +3339,36 @@ export class ConsentService {
3282
3339
  provider_type: CONSENT_PROVIDER_TYPES.PASSWORD,
3283
3340
  };
3284
3341
  }
3285
- // CRITICAL FIX: Read userDid from URL params passed through redirect
3286
- // This bypasses KV eventual consistency issues - we don't need to read
3287
- // from KV because userDid was passed directly in the redirect URL
3288
- const userDidFromRedirect = bodyObj.user_did;
3289
- if (userDidFromRedirect) {
3290
- logger.debug("[ConsentService] ✅ Using userDid from redirect (bypassing KV):", {
3291
- sessionId: sessionId?.substring(0, 20) + "...",
3292
- userDid: userDidFromRedirect.substring(0, 30) + "...",
3293
- source: "redirect-param",
3294
- });
3295
- // Ensure userDid is in bodyObj for createDelegation and storeDelegationToken
3296
- 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;
3297
3372
  }
3298
3373
  // Read user info from redirect URL params
3299
3374
  // Field mapping (clear naming):
@@ -3319,7 +3394,7 @@ export class ConsentService {
3319
3394
  sessionId: sessionId?.substring(0, 20) + "...",
3320
3395
  providerType: CONSENT_PROVIDER_TYPES.PASSWORD,
3321
3396
  provider: credentialProvider,
3322
- hasUserDid: !!userDidFromRedirect,
3397
+ hasUserDid: !!trustedUserDid,
3323
3398
  hasCredentialUserEmail: !!credentialUserEmailFromRedirect,
3324
3399
  hasProviderUserId: !!credentialProviderUserIdFromRedirect,
3325
3400
  note: "Delegation will have authorization.type='password' to match tool protection",
@@ -3348,12 +3423,46 @@ export class ConsentService {
3348
3423
  provider: oauthProvider || bodyObj.provider,
3349
3424
  note: "Delegation will have authorization.type='oauth' to match tool protection",
3350
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
+ }
3351
3452
  }
3352
3453
  // If neither credential nor OAuth, this is pure consent-only
3353
3454
  if (!(credentialProviderType === CONSENT_PROVIDER_TYPES.PASSWORD ||
3354
3455
  oauthProviderType === "oauth")) {
3355
3456
  // Pure consent-only mode - no prior authentication
3356
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;
3357
3466
  if (sessionId && this.env.DELEGATION_STORAGE) {
3358
3467
  // Check if session already has userDid (from credential auth or OAuth)
3359
3468
  let existingUserDid;
@@ -3371,7 +3480,9 @@ export class ConsentService {
3371
3480
  sessionId: sessionId.substring(0, 20) + "...",
3372
3481
  userDid: existingUserDid.substring(0, 30) + "...",
3373
3482
  });
3374
- 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;
3375
3486
  }
3376
3487
  else {
3377
3488
  // Generate ephemeral userDid for pure consent-only
@@ -3382,8 +3493,9 @@ export class ConsentService {
3382
3493
  // This ensures storeDelegationToken can find it and use PRIORITY 1 key
3383
3494
  await this.updateSessionWithIdentity(sessionId, ephemeralUserDid, null // No OAuth identity for consent-only
3384
3495
  );
3385
- // Also inject the user_did into the request body so createDelegation receives it
3386
- 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;
3387
3499
  logger.debug("[ConsentService] ✅ Ephemeral userDid stored in session for consent-only flow:", {
3388
3500
  sessionId: sessionId.substring(0, 20) + "...",
3389
3501
  userDid: ephemeralUserDid.substring(0, 30) + "...",
@@ -3400,6 +3512,16 @@ export class ConsentService {
3400
3512
  }
3401
3513
  }
3402
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;
3403
3525
  // Convert null oauth_identity to undefined for proper schema validation
3404
3526
  // Zod's .nullish() should handle null, but converting to undefined is more explicit
3405
3527
  // and avoids potential edge cases with FormData parsing
@@ -3453,11 +3575,30 @@ export class ConsentService {
3453
3575
  // ✅ Lazy initialization with projectId
3454
3576
  const auditService = await this.getAuditService(projectId);
3455
3577
  // Check if user needs credentials before delegation
3456
- // Skip credential requirement for consent-only mode (provider_type: 'none')
3457
- const isConsentOnlyMode = providerType === CONSENT_PROVIDER_TYPES.NONE;
3458
- const needsCredentials = !isConsentOnlyMode &&
3459
- !approvalRequest.user_did &&
3460
- !approvalRequest.oauth_identity;
3578
+ // Skip credential requirement for consent-only mode (provider_type: 'none').
3579
+ // Read the EFFECTIVE provider_type off bodyObj, not the `providerType`
3580
+ // captured above: the post-OAuth/post-credential clickwrap branches above
3581
+ // rewrite bodyObj.provider_type to 'oauth'/'password' while `providerType`
3582
+ // stays the original raw 'none'. Gating on the stale value would treat
3583
+ // those already-authenticated clickwrap submissions as consent-only and
3584
+ // skip the identity check entirely.
3585
+ const effectiveProviderType = bodyObj.provider_type;
3586
+ const isConsentOnlyMode = effectiveProviderType === CONSENT_PROVIDER_TYPES.NONE;
3587
+ // Resolve identity once, ahead of the gate below, using the same priority
3588
+ // createDelegation used to apply internally (request.user_did first, then
3589
+ // session lookup). The resolved value is threaded into createDelegation so
3590
+ // it is never resolved a second time.
3591
+ let resolvedUserDid = approvalRequest.user_did;
3592
+ if (!resolvedUserDid && approvalRequest.session_id) {
3593
+ try {
3594
+ resolvedUserDid =
3595
+ (await this.getUserDidForSession(approvalRequest.session_id, approvalRequest.oauth_identity || undefined)) ?? undefined;
3596
+ }
3597
+ catch (error) {
3598
+ logger.debug("[ConsentService] Failed to resolve userDid ahead of identity gate:", error);
3599
+ }
3600
+ }
3601
+ const needsCredentials = !isConsentOnlyMode && !resolvedUserDid;
3461
3602
  if (needsCredentials && auditService) {
3462
3603
  await auditService
3463
3604
  .logCredentialRequired({
@@ -3475,12 +3616,25 @@ export class ConsentService {
3475
3616
  error: err instanceof Error ? err.message : String(err),
3476
3617
  });
3477
3618
  });
3478
- // Note: We don't redirect here - the consent flow continues
3479
- // The credential_required event is just for audit tracking
3619
+ }
3620
+ // Fail closed: an authenticated-provider approval with no resolvable user
3621
+ // identity must not mint a delegation. Consent-only mode is exempt - it
3622
+ // legitimately has no user identity. Gated on CONSENT_REQUIRE_IDENTITY so
3623
+ // a bad interaction can be switched off in production without a redeploy;
3624
+ // defaults to enabled.
3625
+ if (needsCredentials && this.isIdentityRequired()) {
3626
+ return new Response(JSON.stringify({
3627
+ success: false,
3628
+ error: "User identity is required to approve this request",
3629
+ error_code: "identity_required",
3630
+ }), {
3631
+ status: 403,
3632
+ headers: { "Content-Type": "application/json" },
3633
+ });
3480
3634
  }
3481
3635
  // Create delegation via AgentShield API
3482
3636
  logger.debug("[ConsentService] Creating delegation...");
3483
- const delegationResult = await this.createDelegation(approvalRequest, resolveExpirationDays(consentConfig.expirationDays));
3637
+ const delegationResult = await this.createDelegation(approvalRequest, resolveExpirationDays(consentConfig.expirationDays), resolvedUserDid ?? null);
3484
3638
  if (!delegationResult.success) {
3485
3639
  logger.error("[ConsentService] Delegation creation failed:", {
3486
3640
  error: delegationResult.error,
@@ -3499,27 +3653,27 @@ export class ConsentService {
3499
3653
  delegationId: delegationResult.delegation_id?.substring(0, 20) + "...",
3500
3654
  });
3501
3655
  // Store delegation token to DO (strongly consistent) and KV (backward compat)
3502
- // 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.
3503
3661
  const serverOrigin = new URL(request.url).origin;
3504
- 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
3505
- serverOrigin // Pass origin for DO URL construction
3506
- );
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);
3507
3671
  // ✅ After successful delegation creation - log audit events
3508
3672
  if (auditService && delegationResult.success) {
3509
3673
  try {
3510
- // Get userDid (resolved via OAuth identity resolution)
3511
- // getUserDidForSession can work without DELEGATION_STORAGE (uses in-memory UserDidManager)
3512
- let userDid;
3513
- if (approvalRequest.session_id) {
3514
- try {
3515
- userDid =
3516
- (await this.getUserDidForSession(approvalRequest.session_id, approvalRequest.oauth_identity || undefined)) ?? undefined; // Phase 5: Convert null to undefined
3517
- }
3518
- catch (error) {
3519
- logger.warn("[ConsentService] Failed to get userDid for lifecycle recording:", error);
3520
- // Continue without userDid - audit events can still be logged
3521
- }
3522
- }
3674
+ // Reuse the identity resolved ahead of the gate above - do not
3675
+ // read KV a second time for the same session.
3676
+ const userDid = resolvedUserDid;
3523
3677
  await auditService.logConsentApproval({
3524
3678
  sessionId: approvalRequest.session_id,
3525
3679
  userDid,
@@ -3583,7 +3737,7 @@ export class ConsentService {
3583
3737
  * @param request - Approval request
3584
3738
  * @returns Delegation creation result
3585
3739
  */
3586
- async createDelegation(request, expirationDaysOverride) {
3740
+ async createDelegation(request, expirationDaysOverride, resolvedUserDid) {
3587
3741
  const agentShieldUrl = this.env.AGENTSHIELD_API_URL || DEFAULT_AGENTSHIELD_URL;
3588
3742
  const apiKey = this.env.AGENTSHIELD_API_KEY;
3589
3743
  if (!apiKey) {
@@ -3598,46 +3752,59 @@ export class ConsentService {
3598
3752
  // Load Day0 configuration to determine field name and API capabilities
3599
3753
  await loadDay0Config(this.env.DELEGATION_STORAGE);
3600
3754
  const fieldName = await getDelegationFieldName(this.env.DELEGATION_STORAGE);
3601
- // Get userDID - FIRST check if passed in request, THEN fallback to session storage
3602
- // request.user_did takes priority to avoid KV eventual consistency issues
3603
- // This fixes the bug where credential auth resolves userDid but it's not found in storage
3604
- let userDid = request.user_did;
3605
- // Only fetch from storage if not already provided in request
3606
- if (!userDid && request.session_id) {
3607
- try {
3608
- logger.debug("[ConsentService] Getting User DID for session:", {
3609
- sessionId: request.session_id.substring(0, 20) + "...",
3610
- hasOAuthIdentity: !!request.oauth_identity,
3611
- oauthProvider: request.oauth_identity?.provider,
3612
- hasStorage: !!this.env.DELEGATION_STORAGE,
3613
- });
3614
- // Pass OAuth identity if available in approval request (can be null/undefined)
3615
- // getUserDidForSession can work without DELEGATION_STORAGE (uses in-memory UserDidManager)
3616
- // Phase 5: Returns null if no identity found (session stays anonymous)
3617
- userDid =
3618
- (await this.getUserDidForSession(request.session_id, request.oauth_identity || undefined // Explicitly handle null as undefined
3619
- )) ?? undefined;
3620
- logger.debug("[ConsentService] User DID retrieved from storage:", {
3621
- userDid: userDid?.substring(0, 20) + "...",
3622
- hasUserDid: !!userDid,
3623
- });
3624
- }
3625
- catch (error) {
3626
- logger.debug("[ConsentService] Failed to get/generate userDid:", error);
3627
- // Continue without userDid - delegation will work without user_identifier
3628
- // This is valid for non-OAuth scenarios, but we should log this as a warning
3629
- logger.warn("[ConsentService] Delegation will be created without user_identifier - this may affect user tracking");
3630
- }
3631
- }
3632
- else if (userDid) {
3633
- // userDid was provided in request (e.g., from credential auth flow)
3634
- logger.debug("[ConsentService] Using provided user_did from request:", {
3635
- userDid: userDid.substring(0, 20) + "...",
3636
- source: "request.user_did",
3755
+ // Get userDID. If the caller (handleApproval) already resolved it, use that
3756
+ // directly and skip the lookup below entirely - it was already attempted
3757
+ // with the same priority (request.user_did first, then session storage),
3758
+ // and repeating it would read KV a second time for no benefit.
3759
+ let userDid;
3760
+ if (resolvedUserDid !== undefined) {
3761
+ userDid = resolvedUserDid ?? undefined;
3762
+ logger.debug("[ConsentService] Using pre-resolved userDid:", {
3763
+ hasUserDid: !!userDid,
3637
3764
  });
3638
3765
  }
3639
3766
  else {
3640
- logger.debug("[ConsentService] No session_id provided - skipping User DID generation");
3767
+ // FIRST check if passed in request, THEN fallback to session storage
3768
+ // request.user_did takes priority to avoid KV eventual consistency issues
3769
+ // This fixes the bug where credential auth resolves userDid but it's not found in storage
3770
+ userDid = request.user_did;
3771
+ // Only fetch from storage if not already provided in request
3772
+ if (!userDid && request.session_id) {
3773
+ try {
3774
+ logger.debug("[ConsentService] Getting User DID for session:", {
3775
+ sessionId: request.session_id.substring(0, 20) + "...",
3776
+ hasOAuthIdentity: !!request.oauth_identity,
3777
+ oauthProvider: request.oauth_identity?.provider,
3778
+ hasStorage: !!this.env.DELEGATION_STORAGE,
3779
+ });
3780
+ // Pass OAuth identity if available in approval request (can be null/undefined)
3781
+ // getUserDidForSession can work without DELEGATION_STORAGE (uses in-memory UserDidManager)
3782
+ // Phase 5: Returns null if no identity found (session stays anonymous)
3783
+ userDid =
3784
+ (await this.getUserDidForSession(request.session_id, request.oauth_identity || undefined // Explicitly handle null as undefined
3785
+ )) ?? undefined;
3786
+ logger.debug("[ConsentService] User DID retrieved from storage:", {
3787
+ userDid: userDid?.substring(0, 20) + "...",
3788
+ hasUserDid: !!userDid,
3789
+ });
3790
+ }
3791
+ catch (error) {
3792
+ logger.debug("[ConsentService] Failed to get/generate userDid:", error);
3793
+ // Continue without userDid - delegation will work without user_identifier
3794
+ // This is valid for non-OAuth scenarios, but we should log this as a warning
3795
+ logger.warn("[ConsentService] Delegation will be created without user_identifier - this may affect user tracking");
3796
+ }
3797
+ }
3798
+ else if (userDid) {
3799
+ // userDid was provided in request (e.g., from credential auth flow)
3800
+ logger.debug("[ConsentService] Using provided user_did from request:", {
3801
+ userDid: userDid.substring(0, 20) + "...",
3802
+ source: "request.user_did",
3803
+ });
3804
+ }
3805
+ else {
3806
+ logger.debug("[ConsentService] No session_id provided - skipping User DID generation");
3807
+ }
3641
3808
  }
3642
3809
  const expiresInDays = expirationDaysOverride ?? DEFAULT_EXPIRATION_DAYS;
3643
3810
  // Phase 2 VC-Only: Issue Delegation VC if we have a session and userDid
@@ -3868,9 +4035,17 @@ export class ConsentService {
3868
4035
  * @param delegationId - Delegation ID
3869
4036
  * @param providedUserDid - Optional userDid passed directly (bypasses KV read for consistency)
3870
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).
3871
4043
  */
3872
- async storeDelegationToken(sessionId, agentDid, token, delegationId, providedUserDid, serverOrigin) {
4044
+ async storeDelegationToken(sessionId, agentDid, token, delegationId, providedUserDid, serverOrigin, authorizationType) {
3873
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);
3874
4049
  // Resolve userDid if not provided
3875
4050
  let userDid = providedUserDid;
3876
4051
  if (!userDid && delegationStorage) {
@@ -3886,7 +4061,14 @@ export class ConsentService {
3886
4061
  // PRIMARY: Store to Durable Object (strongly consistent)
3887
4062
  // This eliminates eventual consistency issues with KV
3888
4063
  const origin = serverOrigin || this.env.MCP_SERVER_URL || "https://localhost";
3889
- 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);
3890
4072
  // ALSO store to KV for backward compatibility and cross-session persistence
3891
4073
  if (delegationStorage) {
3892
4074
  try {
@@ -3908,6 +4090,10 @@ export class ConsentService {
3908
4090
  agentDid,
3909
4091
  delegationToken: token,
3910
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,
3911
4097
  cachedAt: Date.now(),
3912
4098
  }), { expirationTtl: ttl });
3913
4099
  logger.debug("[ConsentService] Token also stored to KV (session key)");
@@ -4053,7 +4239,14 @@ export class ConsentService {
4053
4239
  // tool handler runs server-side through the MCP tools/call proxy, so
4054
4240
  // CSRF is not applicable.
4055
4241
  const isInlineMode = body.inline_mode === true;
4056
- 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) {
4057
4250
  // Validate CSRF token
4058
4251
  if (!csrf_token || typeof csrf_token !== "string") {
4059
4252
  logger.warn("[ConsentService] Missing or invalid CSRF token");
@@ -4247,7 +4440,12 @@ export class ConsentService {
4247
4440
  }
4248
4441
  // Store delegation token (DO + KV)
4249
4442
  const serverUrl = this.env.MCP_SERVER_URL || new URL(request.url).origin;
4250
- 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);
4251
4449
  logger.info("[ConsentService] ✅ Inline credential auth + delegation complete", {
4252
4450
  delegationId: delegationResult.delegation_id?.substring(0, 20) + "...",
4253
4451
  });
@@ -4330,17 +4528,49 @@ export class ConsentService {
4330
4528
  // Provider's internal ID (e.g., customer ID 696395) for business reference
4331
4529
  clickwrapUrl.searchParams.set("credential_provider_user_id", authResult.userId);
4332
4530
  }
4333
- // CRITICAL FIX: Pass userDid through redirect to avoid KV eventual consistency issues
4334
- // Without this, the clickwrap approval might read stale session data from KV
4335
- // that doesn't yet have the userDid from updateSessionWithIdentity above.
4336
- // By passing userDid in the URL, we ensure the delegation is stored with the
4337
- // correct user+agent scoped key regardless of KV propagation timing.
4338
- 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
+ }
4339
4565
  logger.debug("[ConsentService] ✅ Credential auth complete, redirecting to clickwrap", {
4340
4566
  sessionId: session_id.substring(0, 20) + "...",
4341
4567
  userDid: identityResult.userDid.substring(0, 30) + "...",
4342
4568
  provider: provider,
4343
- 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,
4344
4574
  });
4345
4575
  // Return success with redirect to clickwrap page
4346
4576
  return new Response(JSON.stringify({
@@ -4644,9 +4874,15 @@ export class ConsentService {
4644
4874
  async validateCredentialCsrfToken(token, sessionId) {
4645
4875
  const secret = this.env.OAUTH_ENCRYPTION_SECRET;
4646
4876
  if (!secret) {
4647
- logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET for CSRF validation, skipping");
4648
- // Return true to allow flow to continue (graceful degradation)
4649
- 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;
4650
4886
  }
4651
4887
  try {
4652
4888
  const dotIndex = token.indexOf(".");
@@ -4690,10 +4926,19 @@ export class ConsentService {
4690
4926
  async storeCredentialCsrfToken(sessionId) {
4691
4927
  const secret = this.env.OAUTH_ENCRYPTION_SECRET;
4692
4928
  if (!secret) {
4693
- logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET for CSRF generation");
4694
- // Fallback: random token (won't validate but allows graceful degradation)
4695
- const tokenBytes = crypto.getRandomValues(new Uint8Array(32));
4696
- 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)");
4697
4942
  }
4698
4943
  // 10 minute expiration (matches previous KV TTL)
4699
4944
  const exp = Math.floor(Date.now() / 1000) + 600;
@@ -4750,6 +4995,202 @@ export class ConsentService {
4750
4995
  }
4751
4996
  return result === 0;
4752
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
+ }
4753
5194
  /**
4754
5195
  * Build full DelegationRecord format request (future format)
4755
5196
  */