@kya-os/mcp-i-cloudflare 1.15.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +37 -0
  2. package/dist/agent.d.ts.map +1 -1
  3. package/dist/agent.js +81 -51
  4. package/dist/agent.js.map +1 -1
  5. package/dist/app.d.ts.map +1 -1
  6. package/dist/app.js +9 -12
  7. package/dist/app.js.map +1 -1
  8. package/dist/audit/deployment-binding.d.ts +12 -12
  9. package/dist/config.d.ts +28 -1
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +16 -1
  12. package/dist/config.js.map +1 -1
  13. package/dist/constants/storage-keys.d.ts +9 -0
  14. package/dist/constants/storage-keys.d.ts.map +1 -1
  15. package/dist/constants/storage-keys.js +9 -0
  16. package/dist/constants/storage-keys.js.map +1 -1
  17. package/dist/constants.d.ts +6 -0
  18. package/dist/constants.d.ts.map +1 -1
  19. package/dist/constants.js +6 -0
  20. package/dist/constants.js.map +1 -1
  21. package/dist/delegation-http/bearer-retry.d.ts +20 -4
  22. package/dist/delegation-http/bearer-retry.d.ts.map +1 -1
  23. package/dist/delegation-http/bearer-retry.js +56 -10
  24. package/dist/delegation-http/bearer-retry.js.map +1 -1
  25. package/dist/delegation-http/consent-anchor-bridge.d.ts.map +1 -1
  26. package/dist/delegation-http/consent-anchor-bridge.js +72 -13
  27. package/dist/delegation-http/consent-anchor-bridge.js.map +1 -1
  28. package/dist/delegation-http/native-oauth-state.d.ts +16 -0
  29. package/dist/delegation-http/native-oauth-state.d.ts.map +1 -1
  30. package/dist/delegation-http/native-oauth-state.js +24 -9
  31. package/dist/delegation-http/native-oauth-state.js.map +1 -1
  32. package/dist/delegation-http/native-oauth.d.ts.map +1 -1
  33. package/dist/delegation-http/native-oauth.js +26 -7
  34. package/dist/delegation-http/native-oauth.js.map +1 -1
  35. package/dist/delegation-http/oauth-recovery.d.ts +3 -0
  36. package/dist/delegation-http/oauth-recovery.d.ts.map +1 -0
  37. package/dist/delegation-http/oauth-recovery.js +39 -0
  38. package/dist/delegation-http/oauth-recovery.js.map +1 -0
  39. package/dist/delegation-http/remote-issuer.d.ts +15 -0
  40. package/dist/delegation-http/remote-issuer.d.ts.map +1 -1
  41. package/dist/delegation-http/remote-issuer.js +27 -8
  42. package/dist/delegation-http/remote-issuer.js.map +1 -1
  43. package/dist/helpers/env-mapper.d.ts.map +1 -1
  44. package/dist/helpers/env-mapper.js +28 -6
  45. package/dist/helpers/env-mapper.js.map +1 -1
  46. package/dist/index.d.ts +6 -3
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +9 -1
  49. package/dist/index.js.map +1 -1
  50. package/dist/proof-generator.d.ts +8 -7
  51. package/dist/proof-generator.d.ts.map +1 -1
  52. package/dist/proof-generator.js +32 -34
  53. package/dist/proof-generator.js.map +1 -1
  54. package/dist/runtime/oauth-handler.d.ts.map +1 -1
  55. package/dist/runtime/oauth-handler.js +43 -31
  56. package/dist/runtime/oauth-handler.js.map +1 -1
  57. package/dist/runtime/tool-activity.d.ts +31 -4
  58. package/dist/runtime/tool-activity.d.ts.map +1 -1
  59. package/dist/runtime/tool-activity.js +48 -0
  60. package/dist/runtime/tool-activity.js.map +1 -1
  61. package/dist/runtime.d.ts +31 -0
  62. package/dist/runtime.d.ts.map +1 -1
  63. package/dist/runtime.js +65 -0
  64. package/dist/runtime.js.map +1 -1
  65. package/dist/security/admission-refusal.d.ts +32 -0
  66. package/dist/security/admission-refusal.d.ts.map +1 -0
  67. package/dist/security/admission-refusal.js +97 -0
  68. package/dist/security/admission-refusal.js.map +1 -0
  69. package/dist/security/card-proof-support.d.ts +66 -0
  70. package/dist/security/card-proof-support.d.ts.map +1 -0
  71. package/dist/security/card-proof-support.js +121 -0
  72. package/dist/security/card-proof-support.js.map +1 -0
  73. package/dist/security/mcp-admission.d.ts +28 -2
  74. package/dist/security/mcp-admission.d.ts.map +1 -1
  75. package/dist/security/mcp-admission.js +129 -29
  76. package/dist/security/mcp-admission.js.map +1 -1
  77. package/dist/security/mcp-holder-admission.d.ts +12 -1
  78. package/dist/security/mcp-holder-admission.d.ts.map +1 -1
  79. package/dist/security/mcp-holder-admission.js +46 -8
  80. package/dist/security/mcp-holder-admission.js.map +1 -1
  81. package/dist/security/presented-delegation.d.ts +10 -0
  82. package/dist/security/presented-delegation.d.ts.map +1 -1
  83. package/dist/security/presented-delegation.js +23 -3
  84. package/dist/security/presented-delegation.js.map +1 -1
  85. package/dist/services/admin.service.d.ts.map +1 -1
  86. package/dist/services/admin.service.js +24 -9
  87. package/dist/services/admin.service.js.map +1 -1
  88. package/dist/services/approval-route.d.ts +48 -0
  89. package/dist/services/approval-route.d.ts.map +1 -0
  90. package/dist/services/approval-route.js +87 -0
  91. package/dist/services/approval-route.js.map +1 -0
  92. package/dist/services/consent-config.service.d.ts.map +1 -1
  93. package/dist/services/consent-config.service.js +20 -5
  94. package/dist/services/consent-config.service.js.map +1 -1
  95. package/dist/services/consent.service.d.ts +113 -5
  96. package/dist/services/consent.service.d.ts.map +1 -1
  97. package/dist/services/consent.service.js +746 -904
  98. package/dist/services/consent.service.js.map +1 -1
  99. package/dist/services/credential-auth.handler.d.ts +2 -0
  100. package/dist/services/credential-auth.handler.d.ts.map +1 -1
  101. package/dist/services/credential-auth.handler.js +19 -3
  102. package/dist/services/credential-auth.handler.js.map +1 -1
  103. package/dist/services/delegation-trust.d.ts +97 -0
  104. package/dist/services/delegation-trust.d.ts.map +1 -0
  105. package/dist/services/delegation-trust.js +378 -0
  106. package/dist/services/delegation-trust.js.map +1 -0
  107. package/dist/services/delegation.service.d.ts +56 -14
  108. package/dist/services/delegation.service.d.ts.map +1 -1
  109. package/dist/services/delegation.service.js +160 -68
  110. package/dist/services/delegation.service.js.map +1 -1
  111. package/dist/services/multipart-parser.d.ts +73 -0
  112. package/dist/services/multipart-parser.d.ts.map +1 -0
  113. package/dist/services/multipart-parser.js +116 -0
  114. package/dist/services/multipart-parser.js.map +1 -0
  115. package/dist/services/proof.service.d.ts +38 -0
  116. package/dist/services/proof.service.d.ts.map +1 -1
  117. package/dist/services/proof.service.js +41 -2
  118. package/dist/services/proof.service.js.map +1 -1
  119. package/dist/types.d.ts +17 -0
  120. package/dist/types.d.ts.map +1 -1
  121. package/dist/utils/checkpoint-project-id.d.ts +31 -0
  122. package/dist/utils/checkpoint-project-id.d.ts.map +1 -0
  123. package/dist/utils/checkpoint-project-id.js +35 -0
  124. package/dist/utils/checkpoint-project-id.js.map +1 -0
  125. package/dist/utils/oauth-profile.d.ts +7 -0
  126. package/dist/utils/oauth-profile.d.ts.map +1 -0
  127. package/dist/utils/oauth-profile.js +24 -0
  128. package/dist/utils/oauth-profile.js.map +1 -0
  129. package/package.json +7 -6
@@ -8,9 +8,10 @@
8
8
  */
9
9
  import { ConsentConfigService } from "./consent-config.service";
10
10
  import { TemplateRenderer } from "./consent-templates/template-renderer";
11
- import { DEFAULT_CHECKPOINT_URL, DEFAULT_SESSION_CACHE_TTL, KEY_PAIR_TTL_SECONDS, CONSENT_PROVIDER_TYPES, } from "../constants";
11
+ import { CHECKPOINT_CONFIG_TIMEOUT_MS, DEFAULT_CHECKPOINT_URL, DEFAULT_SESSION_CACHE_TTL, KEY_PAIR_TTL_SECONDS, CONSENT_PROVIDER_TYPES, } from "../constants";
12
12
  import { STORAGE_KEYS, isPendingDelegationId } from "../constants/storage-keys";
13
13
  import { loadDay0Config, getDelegationFieldName } from "../utils/day0-config";
14
+ import { normalizeOAuthSubject, oauthProfileString, } from "../utils/oauth-profile";
14
15
  import { buildProviderAuthorizeUrl } from "../runtime/oidc/authorize-url.js";
15
16
  import { resolveIdpScopes } from "../runtime/oidc/idp-scopes.js";
16
17
  import { validateConsentApprovalRequest, } from "@kya-os/contracts/consent";
@@ -22,13 +23,17 @@ import { ConsentAuditService } from "./consent-audit.service";
22
23
  import { CloudflareProofGenerator } from "../proof-generator";
23
24
  import { ProofService } from "./proof.service";
24
25
  import { withMandateConstraints } from "./delegation-mandate";
26
+ import { isPostCredentialMarker, isPostOAuthMarker, resolveApprovalRoute, stripServerOwnedFields, } from "./approval-route";
25
27
  import { createCredentialAuthHandler } from "./credential-auth.handler";
28
+ import { parseMultipartFields, reconstructMalformedMultipart, isMalformedSingleEntry, extractFieldNameFromKey, } from "./multipart-parser";
26
29
  import { IdpTokenStorage } from "./idp-token-storage";
27
30
  import { OAuthSecurityService } from "./oauth-security.service";
28
31
  import { didKeyFragment } from "@kya-os/mcp";
29
32
  import { CONSENT_BUNDLE, CONSENT_BUNDLE_SIZE, CONSENT_BUNDLE_PATH, CONSENT_BUNDLE_HASH, resolveExpirationDays, DEFAULT_EXPIRATION_DAYS, resolveCapabilitiesForScopes, } from "@kya-os/consent";
30
33
  import { calculateDOInstanceId, parseDORoutingStrategy, parseDOShardCount, } from "../utils/do-routing";
31
34
  import { defaultProviders } from "@kya-os/provider-registry";
35
+ import { z } from "zod";
36
+ import { isValidCheckpointProjectId } from "../utils/checkpoint-project-id";
32
37
  const BUILTIN_OAUTH_PROVIDER_IDS = new Set(defaultProviders
33
38
  .filter((p) => p.authType === "oauth2")
34
39
  .map((p) => p.id.toLowerCase()));
@@ -59,6 +64,76 @@ const MANDATE_ERROR = {
59
64
  error: "Delegation limits could not be resolved",
60
65
  error_code: "mandate_error",
61
66
  };
67
+ /**
68
+ * How long a credential sign-in's display fields wait for the clickwrap.
69
+ * Matches the identity assertion's lifetime (`mintIdentityAssertion`), since
70
+ * the clickwrap cannot mint after that anyway.
71
+ */
72
+ const CREDENTIAL_CLICKWRAP_USER_TTL_SECONDS = 600;
73
+ const OAUTH_REQUIREMENT_ERROR = {
74
+ error: "Sign-in requirement could not be determined",
75
+ error_code: "oauth_requirement_unavailable",
76
+ };
77
+ /**
78
+ * Thrown by `isOAuthRequired` when Checkpoint is in use (CHECKPOINT_API_KEY is
79
+ * set) but its answer about the project's sign-in provider cannot be read. The
80
+ * consent page refuses rather than render an approvable consent-only screen.
81
+ *
82
+ * `reason` is fixed text written here, plus at most an error's `name` or an
83
+ * HTTP status (never an upstream error message), so it is safe to log.
84
+ */
85
+ export class OAuthRequirementUnavailableError extends Error {
86
+ projectId;
87
+ reason;
88
+ constructor(projectId, reason) {
89
+ super(`Could not determine whether project "${projectId}" requires sign-in: ${reason}`);
90
+ this.projectId = projectId;
91
+ this.reason = reason;
92
+ this.name = "OAuthRequirementUnavailableError";
93
+ }
94
+ }
95
+ /**
96
+ * A project's default sign-in provider, as Checkpoint's merged config reports
97
+ * it (`oauth: { provider, type: "oauth2" | "credential" } | null`, see
98
+ * Checkpoint `apps/web/lib/services/kyaos-config.service.ts`). `type` stays a
99
+ * plain string: every configured provider, whatever its type, is a sign-in
100
+ * step, so an unknown future type must not make the answer unreadable.
101
+ */
102
+ const defaultProviderSchema = z
103
+ .object({ provider: z.string().min(1), type: z.string().optional() })
104
+ .nullable();
105
+ /** Checkpoint's response: `{ success: true, data: { config: { oauth } } }`. */
106
+ const checkpointConfigResponseSchema = z.object({
107
+ success: z.literal(true),
108
+ data: z.object({ config: z.object({ oauth: defaultProviderSchema }) }),
109
+ });
110
+ /** Root-level shapes the fallback read before it followed Checkpoint's envelope. */
111
+ const legacyRootOAuthSchema = z.object({ oauth: defaultProviderSchema });
112
+ const legacyRootOAuthProviderSchema = z.object({
113
+ oauth_provider: z.string().min(1).nullable(),
114
+ });
115
+ /**
116
+ * Read the default provider out of a Checkpoint config response body.
117
+ * `ok: false` means the body has none of the known shapes, so the answer
118
+ * cannot be read (a missing `oauth` key included: Checkpoint sends `null`
119
+ * when a project has no provider).
120
+ */
121
+ function parseProjectDefaultProvider(body) {
122
+ const current = checkpointConfigResponseSchema.safeParse(body);
123
+ if (current.success) {
124
+ return { ok: true, defaultProvider: current.data.data.config.oauth };
125
+ }
126
+ const legacyOAuth = legacyRootOAuthSchema.safeParse(body);
127
+ if (legacyOAuth.success) {
128
+ return { ok: true, defaultProvider: legacyOAuth.data.oauth };
129
+ }
130
+ const legacyOAuthProvider = legacyRootOAuthProviderSchema.safeParse(body);
131
+ if (legacyOAuthProvider.success) {
132
+ const provider = legacyOAuthProvider.data.oauth_provider;
133
+ return { ok: true, defaultProvider: provider ? { provider } : null };
134
+ }
135
+ return { ok: false };
136
+ }
62
137
  export class ConsentService {
63
138
  configService;
64
139
  renderer;
@@ -659,6 +734,51 @@ export class ConsentService {
659
734
  generateDidKeyFromPublicKey(publicKeyBase64) {
660
735
  return generateDidKeyFromBase64(publicKeyBase64);
661
736
  }
737
+ /**
738
+ * The session's own throwaway DID, or undefined.
739
+ *
740
+ * A session record also holds a signed-in user's DID once the OAuth callback
741
+ * or the credential handler has run (`updateSessionWithIdentity`), and that
742
+ * write keeps the earlier throwaway `keyPair`. So the DID counts as
743
+ * throwaway only when it is the did:key of the key pair stored with it.
744
+ */
745
+ sessionThrowawayDid(session) {
746
+ const userDid = session?.userDid;
747
+ const publicKey = session?.keyPair?.publicKey;
748
+ if (typeof userDid !== "string" || typeof publicKey !== "string") {
749
+ return undefined;
750
+ }
751
+ try {
752
+ return this.generateDidKeyFromPublicKey(publicKey) === userDid
753
+ ? userDid
754
+ : undefined;
755
+ }
756
+ catch {
757
+ return undefined;
758
+ }
759
+ }
760
+ /**
761
+ * A fresh throwaway did:key and its key pair. Nothing is stored.
762
+ *
763
+ * @returns The key pair (its `did` is the throwaway DID), or null if key
764
+ * generation fails
765
+ */
766
+ async createThrowawayIdentity() {
767
+ try {
768
+ const rawKeyPair = await new WebCryptoProvider().generateKeyPair();
769
+ const did = this.generateDidKeyFromPublicKey(rawKeyPair.publicKey);
770
+ return {
771
+ did,
772
+ publicKey: rawKeyPair.publicKey,
773
+ privateKey: rawKeyPair.privateKey,
774
+ keyId: `${did}#${didKeyFragment(did)}`,
775
+ };
776
+ }
777
+ catch (error) {
778
+ logger.error("[ConsentService] Failed to generate a throwaway identity:", error);
779
+ return null;
780
+ }
781
+ }
662
782
  /**
663
783
  * Generate an ephemeral userDid for consent-only flows
664
784
  *
@@ -671,7 +791,10 @@ export class ConsentService {
671
791
  * and used for delegation storage during the current session.
672
792
  *
673
793
  * @param sessionId - Session ID to associate the ephemeral userDid with
674
- * @returns Ephemeral userDid (did:key format) or null if generation fails
794
+ * @returns Ephemeral userDid (did:key format), or null if generation fails,
795
+ * the session record can't be read before the write, or the session
796
+ * already holds a signed-in user's DID (which is never returned or
797
+ * overwritten here)
675
798
  */
676
799
  async generateEphemeralUserDidForSession(sessionId) {
677
800
  // Check if session already has a userDid and key pair
@@ -679,13 +802,18 @@ export class ConsentService {
679
802
  try {
680
803
  const sessionKey = STORAGE_KEYS.session(sessionId);
681
804
  const existingSession = (await this.env.DELEGATION_STORAGE.get(sessionKey, "json"));
682
- if (existingSession?.userDid) {
683
- logger.debug("[ConsentService] Session already has userDid, reusing:", {
805
+ const throwawayDid = this.sessionThrowawayDid(existingSession);
806
+ if (throwawayDid) {
807
+ logger.debug("[ConsentService] Session already has a throwaway userDid, reusing:", {
684
808
  sessionId: sessionId.substring(0, 20) + "...",
685
- userDid: existingSession.userDid.substring(0, 20) + "...",
686
- hasKeyPair: !!existingSession.keyPair,
809
+ userDid: throwawayDid.substring(0, 20) + "...",
687
810
  });
688
- return existingSession.userDid;
811
+ return throwawayDid;
812
+ }
813
+ // SECURITY: a signed-in user's DID is neither returned (consent-only
814
+ // must not mint under it) nor overwritten.
815
+ if (existingSession?.userDid) {
816
+ return null;
689
817
  }
690
818
  }
691
819
  catch (error) {
@@ -695,24 +823,34 @@ export class ConsentService {
695
823
  // Generate new ephemeral keypair - THIS TIME WE STORE IT!
696
824
  logger.debug("[ConsentService] Generating ephemeral key pair for consent-only VC signing");
697
825
  try {
698
- const crypto = new WebCryptoProvider();
699
- const rawKeyPair = await crypto.generateKeyPair();
700
- // Generate did:key from public key
701
- const ephemeralUserDid = this.generateDidKeyFromPublicKey(rawKeyPair.publicKey);
702
- const keyId = `${ephemeralUserDid}#${didKeyFragment(ephemeralUserDid)}`;
703
- // Create full UserKeyPair for VC signing
704
- const keyPair = {
705
- did: ephemeralUserDid,
706
- publicKey: rawKeyPair.publicKey,
707
- privateKey: rawKeyPair.privateKey,
708
- keyId,
709
- };
826
+ // Full UserKeyPair for VC signing
827
+ const keyPair = await this.createThrowawayIdentity();
828
+ if (!keyPair) {
829
+ return null;
830
+ }
831
+ const ephemeralUserDid = keyPair.did;
710
832
  // Store key pair in session for VC signing
711
833
  // This enables consent-only users to sign their delegation VCs!
712
834
  if (this.env.DELEGATION_STORAGE) {
835
+ const sessionKey = STORAGE_KEYS.session(sessionId);
836
+ let existingSession;
837
+ try {
838
+ existingSession = (await this.env.DELEGATION_STORAGE.get(sessionKey, "json"));
839
+ }
840
+ catch (readError) {
841
+ // The check above may have been skipped by a failed read too, so
842
+ // without this record there is no proof the session holds no
843
+ // signed-in DID. Write nothing.
844
+ logger.warn("[ConsentService] Failed to re-read session before storing ephemeral key pair:", readError);
845
+ return null;
846
+ }
847
+ // SECURITY: re-check before writing, so a signed-in DID is never
848
+ // overwritten even when the first read failed.
849
+ if (existingSession?.userDid &&
850
+ !this.sessionThrowawayDid(existingSession)) {
851
+ return null;
852
+ }
713
853
  try {
714
- const sessionKey = STORAGE_KEYS.session(sessionId);
715
- const existingSession = (await this.env.DELEGATION_STORAGE.get(sessionKey, "json"));
716
854
  await this.env.DELEGATION_STORAGE.put(sessionKey, JSON.stringify({
717
855
  ...existingSession,
718
856
  userDid: ephemeralUserDid,
@@ -929,17 +1067,45 @@ export class ConsentService {
929
1067
  /**
930
1068
  * Check if OAuth is required for delegation creation
931
1069
  *
932
- * Determines if OAuth flow should be used instead of direct delegation creation.
933
- * OAuth is required if:
934
- * - Project has OAuth provider configured in Checkpoint
1070
+ * Determines if a sign-in step must come before consent.
1071
+ * Sign-in is required if:
1072
+ * - Project has a default sign-in provider configured in Checkpoint
935
1073
  * - No OAuth identity is present in the request
936
1074
  *
1075
+ * A "credential" default provider (a merchant's own sign-in form) counts as
1076
+ * required too. `true` never forces an OAuth redirect by itself: the consent
1077
+ * page renders the credential form instead whenever the provider has a
1078
+ * credential config.
1079
+ *
937
1080
  * @param projectId - Project ID to check
938
1081
  * @param oauthIdentity - Optional OAuth identity from cookie
939
1082
  * @param toolOAuthProvider - Optional tool-specific OAuth provider (takes precedence)
940
- * @returns True if OAuth redirect is required
1083
+ * @returns True if a sign-in step is required
1084
+ * @throws OAuthRequirementUnavailableError when CHECKPOINT_API_KEY is set
1085
+ * and Checkpoint's config cannot be read (request failed or timed out,
1086
+ * non-OK status, unparseable body, or no `oauth` field), whatever
1087
+ * `toolProtection` says
1088
+ *
1089
+ * @example
1090
+ * // Checkpoint answers { success: true, data: { config: { oauth: { provider: "github", type: "oauth2" } } } }
1091
+ * await consentService.isOAuthRequired("proj123", undefined, undefined, {
1092
+ * requiresDelegation: true,
1093
+ * requiredScopes: ["cart:write"],
1094
+ * }); // true
941
1095
  */
942
1096
  async isOAuthRequired(projectId, oauthIdentity, toolOAuthProvider, toolProtection) {
1097
+ const { required } = await this.resolveSignInRequirement(projectId, oauthIdentity, toolOAuthProvider, toolProtection);
1098
+ return required;
1099
+ }
1100
+ /**
1101
+ * `isOAuthRequired`, plus the project's default provider when Checkpoint's
1102
+ * config is what made sign-in required. The consent page names that
1103
+ * provider so the OAuth callback signs it into the identity assertion,
1104
+ * without asking Checkpoint a second time.
1105
+ *
1106
+ * @throws OAuthRequirementUnavailableError as `isOAuthRequired` does
1107
+ */
1108
+ async resolveSignInRequirement(projectId, oauthIdentity, toolOAuthProvider, toolProtection) {
943
1109
  // Priority 0: Check for consent-only mode (authorization.type === 'none')
944
1110
  // Consent-only means delegation is required but NO OAuth provider is needed
945
1111
  if (toolProtection?.authorization?.type === "none") {
@@ -947,15 +1113,15 @@ export class ConsentService {
947
1113
  projectId,
948
1114
  requiresDelegation: toolProtection.requiresDelegation,
949
1115
  });
950
- return false;
1116
+ return { required: false };
951
1117
  }
952
1118
  // If OAuth identity is already present, OAuth is not required
953
1119
  if (oauthIdentity && oauthIdentity.provider && oauthIdentity.subject) {
954
- return false;
1120
+ return { required: false };
955
1121
  }
956
1122
  // If tool specifically requires an OAuth provider, it's required immediately
957
1123
  if (toolOAuthProvider) {
958
- return true;
1124
+ return { required: true };
959
1125
  }
960
1126
  // Phase 2: If toolOAuthProvider is undefined but tool requires delegation,
961
1127
  // use ProviderResolver to check if a provider can be resolved
@@ -972,25 +1138,23 @@ export class ConsentService {
972
1138
  toolRequiresDelegation: toolProtection.requiresDelegation,
973
1139
  hasToolOAuthProvider: !!toolOAuthProvider,
974
1140
  });
975
- return true;
1141
+ return { required: true };
976
1142
  }
977
1143
  }
978
1144
  catch (error) {
979
1145
  // Check if this is ConsentOnlyModeError - not a real error
980
1146
  if (error instanceof Error && error.name === "ConsentOnlyModeError") {
981
1147
  logger.debug("[ConsentService] Tool is consent-only mode (ConsentOnlyModeError), OAuth NOT required", { projectId });
982
- return false;
1148
+ return { required: false };
983
1149
  }
984
- // Check for CredentialAuthModeError - credential auth does NOT require OAuth
985
- // Use multiple detection methods to handle bundled code where class names may be mangled
986
- const isCredentialAuthError = error instanceof Error &&
987
- (error.name === "CredentialAuthModeError" ||
988
- error.constructor?.name === "CredentialAuthModeError" ||
989
- ("provider" in error &&
990
- typeof error.provider === "string"));
1150
+ // Check for CredentialAuthModeError - credential auth does NOT require OAuth.
1151
+ // Match the name the class sets explicitly (it survives bundling), never
1152
+ // a duck-typed `provider` property: any other resolver error must go on
1153
+ // to the Checkpoint check below, which fails closed
1154
+ const isCredentialAuthError = error instanceof Error && error.name === "CredentialAuthModeError";
991
1155
  if (isCredentialAuthError) {
992
1156
  logger.debug("[ConsentService] Tool uses credential auth (CredentialAuthModeError), OAuth NOT required", { projectId });
993
- return false;
1157
+ return { required: false };
994
1158
  }
995
1159
  // ProviderResolver failed - log but continue to fallback check
996
1160
  logger.warn("[ConsentService] ProviderResolver failed to resolve provider, falling back to project config check:", {
@@ -1000,37 +1164,92 @@ export class ConsentService {
1000
1164
  }
1001
1165
  }
1002
1166
  }
1003
- // Check project config to see if OAuth is configured
1167
+ // Fallback: ask Checkpoint for the project's default sign-in provider
1168
+ const apiKey = this.env.CHECKPOINT_API_KEY;
1169
+ if (!apiKey) {
1170
+ // CHECKPOINT_API_KEY is what turns the Checkpoint integration on: without
1171
+ // it there is no tool protection service or provider resolver, and
1172
+ // createDelegation refuses with api_key_missing. A missing key means
1173
+ // Checkpoint is not in use, so there is no sign-in provider to require.
1174
+ return { required: false };
1175
+ }
1004
1176
  try {
1005
- const checkpointUrl = this.env.CHECKPOINT_API_URL || DEFAULT_CHECKPOINT_URL;
1006
- const apiKey = this.env.CHECKPOINT_API_KEY;
1007
- if (!apiKey) {
1008
- // No API key - can't check, assume OAuth not required
1009
- return false;
1010
- }
1011
- // Fetch project config to check for OAuth provider
1012
- const configUrl = `${checkpointUrl}/api/v1/bouncer/projects/${projectId}/config`;
1013
- const response = await fetch(configUrl, {
1177
+ const defaultProvider = await this.fetchProjectDefaultProvider(projectId, apiKey);
1178
+ // Any default provider is a sign-in step, "credential" included
1179
+ return {
1180
+ required: defaultProvider !== null,
1181
+ defaultProvider: defaultProvider?.provider,
1182
+ };
1183
+ }
1184
+ catch (error) {
1185
+ const unavailable = error instanceof OAuthRequirementUnavailableError
1186
+ ? error
1187
+ : new OAuthRequirementUnavailableError(projectId, "reading the Checkpoint config failed unexpectedly");
1188
+ logger.warn("[ConsentService] Failed to check OAuth requirement:", {
1189
+ projectId,
1190
+ reason: unavailable.reason,
1191
+ });
1192
+ // Fail closed whatever `toolProtection` says: the consent page mints a
1193
+ // delegation for the URL's scopes, not the tool's, so an unprotected or
1194
+ // unknown tool name must not turn an unknown answer into an approvable
1195
+ // consent-only screen
1196
+ throw unavailable;
1197
+ }
1198
+ }
1199
+ /**
1200
+ * Fetch the project's default sign-in provider from Checkpoint's config
1201
+ * (`GET /api/v1/bouncer/projects/{projectId}/config`, `data.config.oauth`).
1202
+ *
1203
+ * @returns The provider, or null when Checkpoint says the project has none
1204
+ * @throws OAuthRequirementUnavailableError when the answer cannot be read
1205
+ */
1206
+ async fetchProjectDefaultProvider(projectId, apiKey) {
1207
+ // The id comes from the consent URL's query string: one that cannot be a
1208
+ // Checkpoint project never reaches Checkpoint
1209
+ if (!isValidCheckpointProjectId(projectId)) {
1210
+ throw new OAuthRequirementUnavailableError(projectId, "the project id is not a valid Checkpoint project id");
1211
+ }
1212
+ const checkpointUrl = this.env.CHECKPOINT_API_URL || DEFAULT_CHECKPOINT_URL;
1213
+ const configUrl = `${checkpointUrl}/api/v1/bouncer/projects/${encodeURIComponent(projectId)}/config`;
1214
+ let response;
1215
+ try {
1216
+ response = await fetch(configUrl, {
1014
1217
  headers: {
1015
1218
  Authorization: `Bearer ${apiKey}`,
1016
1219
  "Content-Type": "application/json",
1017
1220
  },
1221
+ signal: AbortSignal.timeout(CHECKPOINT_CONFIG_TIMEOUT_MS),
1018
1222
  });
1019
- if (response.ok) {
1020
- const config = await response.json();
1021
- // Check if OAuth provider is configured
1022
- // OAuth is required if project has OAuth provider but no identity is present
1023
- const hasOAuthProvider = config?.oauth?.provider || config?.oauth_provider;
1024
- return !!hasOAuthProvider;
1025
- }
1026
- // If API call fails, default to not requiring OAuth (backward compatibility)
1027
- return false;
1028
1223
  }
1029
1224
  catch (error) {
1030
- logger.warn("[ConsentService] Failed to check OAuth requirement:", error);
1031
- // On error, default to not requiring OAuth (backward compatibility)
1032
- return false;
1225
+ // Only the error's name (TypeError, TimeoutError, ...): its message can
1226
+ // carry the URL or other request detail
1227
+ const errorName = error instanceof Error ? error.name : "unknown";
1228
+ throw new OAuthRequirementUnavailableError(projectId, `the Checkpoint config request failed (${errorName})`);
1229
+ }
1230
+ if (!response.ok) {
1231
+ // Release the connection: an unread subrequest body holds it until GC
1232
+ await response.body?.cancel().catch(() => { });
1233
+ throw new OAuthRequirementUnavailableError(projectId, `Checkpoint answered ${response.status}`);
1234
+ }
1235
+ let body;
1236
+ try {
1237
+ body = await response.json();
1238
+ }
1239
+ catch {
1240
+ throw new OAuthRequirementUnavailableError(projectId, "the Checkpoint config response is not JSON");
1033
1241
  }
1242
+ const parsed = parseProjectDefaultProvider(body);
1243
+ if (!parsed.ok) {
1244
+ throw new OAuthRequirementUnavailableError(projectId, "the Checkpoint config response has no oauth field");
1245
+ }
1246
+ return parsed.defaultProvider;
1247
+ }
1248
+ oauthRequirementErrorResponse() {
1249
+ return new Response(JSON.stringify({ success: false, ...OAUTH_REQUIREMENT_ERROR }), {
1250
+ status: 500,
1251
+ headers: { "Content-Type": "application/json" },
1252
+ });
1034
1253
  }
1035
1254
  /**
1036
1255
  * Build OAuth authorization URL
@@ -1683,9 +1902,23 @@ export class ConsentService {
1683
1902
  // Validate it's valid JSON before passing to client
1684
1903
  try {
1685
1904
  const parsed = JSON.parse(decodeURIComponent(cookieValue));
1686
- // Basic validation - ensure it has required fields
1687
- if (parsed && parsed.provider && parsed.subject) {
1688
- oauthIdentity = parsed;
1905
+ if (parsed &&
1906
+ typeof parsed === "object" &&
1907
+ !Array.isArray(parsed)) {
1908
+ const claims = parsed;
1909
+ const provider = oauthProfileString(claims.provider);
1910
+ const subject = normalizeOAuthSubject(claims.subject);
1911
+ // Older GitHub callbacks stored numeric IDs in this display
1912
+ // cookie. Normalize only scalar subjects before rendering;
1913
+ // this cookie does not establish a verified delegation.
1914
+ if (provider && subject) {
1915
+ oauthIdentity = {
1916
+ provider,
1917
+ subject,
1918
+ email: oauthProfileString(claims.email),
1919
+ name: oauthProfileString(claims.name),
1920
+ };
1921
+ }
1689
1922
  }
1690
1923
  }
1691
1924
  catch (parseError) {
@@ -1709,7 +1942,19 @@ export class ConsentService {
1709
1942
  protection = await toolProtectionService.checkToolProtection(tool, agentDidObj.did);
1710
1943
  toolOAuthProvider = protection?.oauthProvider;
1711
1944
  }
1712
- const oauthRequired = await this.isOAuthRequired(projectId, oauthIdentity, toolOAuthProvider, protection);
1945
+ let oauthRequired;
1946
+ let defaultProvider;
1947
+ try {
1948
+ ({ required: oauthRequired, defaultProvider } =
1949
+ await this.resolveSignInRequirement(projectId, oauthIdentity, toolOAuthProvider, protection));
1950
+ }
1951
+ catch (error) {
1952
+ // An unknown sign-in requirement must never render an approvable page
1953
+ if (error instanceof OAuthRequirementUnavailableError) {
1954
+ return this.oauthRequirementErrorResponse();
1955
+ }
1956
+ throw error;
1957
+ }
1713
1958
  // Phase 2: Resolve provider for tool
1714
1959
  // Priority: 1) URL query param (from buildConsentUrl), 2) providerResolver, 3) toolOAuthProvider
1715
1960
  let provider;
@@ -1784,6 +2029,20 @@ export class ConsentService {
1784
2029
  tool,
1785
2030
  });
1786
2031
  }
2032
+ // PRIORITY 4: the project's default provider, when Checkpoint's config
2033
+ // is what made sign-in required and nothing above named one. Checkpoint
2034
+ // would pick the same provider. Naming it lets the OAuth callback sign it
2035
+ // into the identity assertion, which /consent/approve requires for an
2036
+ // OAuth approval. A credential default provider gets the credential
2037
+ // form below.
2038
+ if (!provider && oauthRequired && defaultProvider) {
2039
+ provider = defaultProvider;
2040
+ logger.debug("[ConsentService] Using the project's default provider:", {
2041
+ provider,
2042
+ projectId,
2043
+ tool,
2044
+ });
2045
+ }
1787
2046
  let resolvedOAuthUrl;
1788
2047
  let isOAuthRequired = false;
1789
2048
  // ✅ CONFIG-BASED CREDENTIAL DETECTION (replaces string matching)
@@ -2230,54 +2489,62 @@ export class ConsentService {
2230
2489
  */
2231
2490
  async parseRequestBody(request) {
2232
2491
  const contentType = request.headers.get("content-type") || "";
2233
- // Helper function to parse form fields into body object
2234
- const parseFormFields = (entries) => {
2235
- const body = {};
2236
- for (const [key, value] of entries) {
2237
- // Handle special fields that need parsing
2238
- if (key === "scopes" || key === "scopes[]") {
2239
- // Scopes come as JSON string
2240
- try {
2241
- body["scopes"] = JSON.parse(value);
2242
- }
2243
- catch {
2244
- // If not JSON, treat as single scope or array
2245
- body["scopes"] = value.includes(",") ? value.split(",") : [value];
2246
- }
2492
+ // Coerce a single form field into the body object, applying the
2493
+ // special-case parsing for scopes / oauth_identity / termsAccepted /
2494
+ // customFields. Single source of truth shared by every form/multipart
2495
+ // parsing path below.
2496
+ // Returns true when a value was actually stored on `body` (false for the
2497
+ // one case that can silently skip: invalid `customFields` JSON), so
2498
+ // callers can tell "field present but dropped" apart from "field set".
2499
+ const coerceFieldInto = (body, key, value) => {
2500
+ // Handle special fields that need parsing
2501
+ if (key === "scopes" || key === "scopes[]") {
2502
+ // Scopes come as JSON string
2503
+ try {
2504
+ body["scopes"] = JSON.parse(value);
2247
2505
  }
2248
- else if (key === "oauth_identity" || key === "oauth_identity_json") {
2249
- // OAuth identity comes as JSON string
2250
- try {
2251
- const parsed = JSON.parse(value);
2252
- body["oauth_identity"] = parsed || null;
2253
- }
2254
- catch {
2255
- // Invalid JSON, set to null
2256
- body["oauth_identity"] = null;
2257
- }
2506
+ catch {
2507
+ // If not JSON, treat as single scope or array
2508
+ body["scopes"] = value.includes(",") ? value.split(",") : [value];
2258
2509
  }
2259
- else if (key === "termsAccepted") {
2260
- // Convert checkbox values to boolean
2261
- body[key] =
2262
- value === "on" ||
2263
- value === "true" ||
2264
- value === "1" ||
2265
- value === "yes";
2510
+ }
2511
+ else if (key === "oauth_identity" || key === "oauth_identity_json") {
2512
+ // OAuth identity comes as JSON string
2513
+ try {
2514
+ const parsed = JSON.parse(value);
2515
+ body["oauth_identity"] = parsed || null;
2266
2516
  }
2267
- else if (key === "customFields") {
2268
- // Custom fields come as JSON string
2269
- try {
2270
- body[key] = JSON.parse(value);
2271
- }
2272
- catch {
2273
- // If not JSON, skip
2274
- }
2517
+ catch {
2518
+ // Invalid JSON, set to null
2519
+ body["oauth_identity"] = null;
2275
2520
  }
2276
- else {
2277
- // Regular string fields
2278
- body[key] = value;
2521
+ }
2522
+ else if (key === "termsAccepted") {
2523
+ // Convert checkbox values to boolean
2524
+ body[key] =
2525
+ value === "on" ||
2526
+ value === "true" ||
2527
+ value === "1" ||
2528
+ value === "yes";
2529
+ }
2530
+ else if (key === "customFields") {
2531
+ // Custom fields come as JSON string
2532
+ try {
2533
+ body[key] = JSON.parse(value);
2534
+ }
2535
+ catch {
2536
+ // If not JSON, skip
2537
+ return false;
2279
2538
  }
2280
2539
  }
2540
+ else {
2541
+ // Regular string fields
2542
+ body[key] = value;
2543
+ }
2544
+ return true;
2545
+ };
2546
+ // Apply schema-required defaults (termsAccepted, scopes) to a body.
2547
+ const applyFormDefaults = (body) => {
2281
2548
  // Default termsAccepted to true if not provided (common for checkboxes)
2282
2549
  if (!("termsAccepted" in body)) {
2283
2550
  body.termsAccepted = true;
@@ -2288,6 +2555,35 @@ export class ConsentService {
2288
2555
  }
2289
2556
  return body;
2290
2557
  };
2558
+ // Helper function to parse form fields into body object
2559
+ const parseFormFields = (entries) => {
2560
+ const body = {};
2561
+ for (const [key, value] of entries) {
2562
+ coerceFieldInto(body, key, value);
2563
+ }
2564
+ return applyFormDefaults(body);
2565
+ };
2566
+ // Parse a raw multipart/form-data body string using the dedicated
2567
+ // boundary-detection parser, then coerce fields via the shared
2568
+ // parseFormFields helper. Returns null when no fields could be extracted,
2569
+ // preserving the original "only return when hasValidFields" semantics.
2570
+ const parseMultipartText = (text) => {
2571
+ const fields = parseMultipartFields(text);
2572
+ if (fields.length === 0) {
2573
+ return null;
2574
+ }
2575
+ const body = {};
2576
+ let hasValidFields = false;
2577
+ for (const f of fields) {
2578
+ if (coerceFieldInto(body, f.name, f.value)) {
2579
+ hasValidFields = true;
2580
+ }
2581
+ }
2582
+ if (!hasValidFields) {
2583
+ return null;
2584
+ }
2585
+ return applyFormDefaults(body);
2586
+ };
2291
2587
  // Parse URL-encoded form data (application/x-www-form-urlencoded)
2292
2588
  // When Content-Type is URL-encoded but FormData object is passed, try FormData parsing first
2293
2589
  if (contentType.includes("application/x-www-form-urlencoded")) {
@@ -2300,69 +2596,12 @@ export class ConsentService {
2300
2596
  // Check if FormData parsing returned malformed data (entire multipart body as single entry)
2301
2597
  // Type assertion: FormData.entries() exists at runtime in Cloudflare Workers
2302
2598
  const entriesArray = Array.from(formData.entries());
2303
- if (entriesArray.length === 1 &&
2304
- typeof entriesArray[0][0] === "string" &&
2305
- entriesArray[0][0].includes("Content-Disposition") &&
2306
- typeof entriesArray[0][1] === "string" &&
2307
- entriesArray[0][1].toString().includes("Content-Disposition")) {
2308
- // Manually parse multipart format from the value string
2309
- // The key contains the first boundary and Content-Disposition header start
2310
- // The value contains the rest of the first field and all subsequent fields
2311
- const keyPart = entriesArray[0][0];
2312
- const valuePart = entriesArray[0][1];
2313
- // Fix: If key ends with "name" and value starts with '"fieldname"', combine them
2314
- let multipartBody;
2315
- if (keyPart.trim().endsWith("name") &&
2316
- valuePart.trim().startsWith('"')) {
2317
- // Key ends with "name", value starts with '"fieldname"', combine with = between them
2318
- multipartBody = keyPart + "=" + valuePart;
2319
- }
2320
- else {
2321
- multipartBody = keyPart + valuePart;
2322
- }
2323
- // Match: Content-Disposition header with field name, then capture value until next boundary or end
2324
- const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
2325
- let match;
2326
- while ((match = fieldRegex.exec(multipartBody)) !== null) {
2327
- const fieldName = match[1];
2328
- const fieldValue = match[2].trim(); // Remove trailing newlines
2329
- if (fieldName === "scopes" || fieldName === "scopes[]") {
2330
- try {
2331
- body["scopes"] = JSON.parse(fieldValue);
2332
- }
2333
- catch {
2334
- body["scopes"] = fieldValue.includes(",")
2335
- ? fieldValue.split(",")
2336
- : [fieldValue];
2337
- }
2338
- }
2339
- else if (fieldName === "oauth_identity" ||
2340
- fieldName === "oauth_identity_json") {
2341
- try {
2342
- body["oauth_identity"] = JSON.parse(fieldValue) || null;
2343
- }
2344
- catch {
2345
- body["oauth_identity"] = null;
2346
- }
2347
- }
2348
- else if (fieldName === "termsAccepted") {
2349
- body[fieldName] =
2350
- fieldValue === "on" ||
2351
- fieldValue === "true" ||
2352
- fieldValue === "1" ||
2353
- fieldValue === "yes";
2354
- }
2355
- else if (fieldName === "customFields") {
2356
- try {
2357
- body[fieldName] = JSON.parse(fieldValue);
2358
- }
2359
- catch {
2360
- // Skip
2361
- }
2362
- }
2363
- else {
2364
- body[fieldName] = fieldValue;
2365
- }
2599
+ if (isMalformedSingleEntry(entriesArray)) {
2600
+ // FormData leaked the entire multipart body into a single malformed
2601
+ // entry. Reconstruct a parseable body and extract the fields.
2602
+ const multipartBody = reconstructMalformedMultipart(entriesArray[0][0], entriesArray[0][1]);
2603
+ for (const field of parseMultipartFields(multipartBody)) {
2604
+ coerceFieldInto(body, field.name, field.value);
2366
2605
  hasValidFields = true;
2367
2606
  }
2368
2607
  }
@@ -2374,63 +2613,13 @@ export class ConsentService {
2374
2613
  continue;
2375
2614
  }
2376
2615
  // Extract field name from malformed keys (when FormData is passed with wrong Content-Type)
2377
- let fieldName = key;
2378
- if (key.includes("Content-Disposition") && key.includes('name="')) {
2379
- // Extract field name from Content-Disposition header
2380
- const nameMatch = key.match(/name="([^"]+)"/);
2381
- if (nameMatch && nameMatch[1]) {
2382
- fieldName = nameMatch[1];
2383
- }
2384
- else {
2385
- // Skip if we can't extract a valid field name
2386
- continue;
2387
- }
2388
- }
2389
- else if (key.includes("------") ||
2390
- key.includes("\r\n") ||
2391
- key.trim() === "") {
2616
+ const fieldName = extractFieldNameFromKey(key);
2617
+ if (fieldName === null) {
2392
2618
  // Skip boundary strings and invalid keys
2393
2619
  continue;
2394
2620
  }
2395
2621
  hasValidFields = true;
2396
- const stringValue = value.toString();
2397
- if (fieldName === "scopes" || fieldName === "scopes[]") {
2398
- try {
2399
- body["scopes"] = JSON.parse(stringValue);
2400
- }
2401
- catch {
2402
- body["scopes"] = stringValue.includes(",")
2403
- ? stringValue.split(",")
2404
- : [stringValue];
2405
- }
2406
- }
2407
- else if (fieldName === "oauth_identity" ||
2408
- fieldName === "oauth_identity_json") {
2409
- try {
2410
- body["oauth_identity"] = JSON.parse(stringValue) || null;
2411
- }
2412
- catch {
2413
- body["oauth_identity"] = null;
2414
- }
2415
- }
2416
- else if (fieldName === "termsAccepted") {
2417
- body[fieldName] =
2418
- stringValue === "on" ||
2419
- stringValue === "true" ||
2420
- stringValue === "1" ||
2421
- stringValue === "yes";
2422
- }
2423
- else if (fieldName === "customFields") {
2424
- try {
2425
- body[fieldName] = JSON.parse(stringValue);
2426
- }
2427
- catch {
2428
- // Skip
2429
- }
2430
- }
2431
- else {
2432
- body[fieldName] = stringValue;
2433
- }
2622
+ coerceFieldInto(body, fieldName, value.toString());
2434
2623
  }
2435
2624
  }
2436
2625
  if (hasValidFields && Object.keys(body).length > 0) {
@@ -2476,61 +2665,9 @@ export class ConsentService {
2476
2665
  const text = await textRequest.text();
2477
2666
  if (text && text.length > 0) {
2478
2667
  // Try to manually parse multipart format from text
2479
- const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
2480
- const body = {};
2481
- let match;
2482
- let hasValidFields = false;
2483
- while ((match = fieldRegex.exec(text)) !== null) {
2484
- const fieldName = match[1];
2485
- const fieldValue = match[2].trim();
2486
- if (fieldName === "scopes" || fieldName === "scopes[]") {
2487
- try {
2488
- body["scopes"] = JSON.parse(fieldValue);
2489
- }
2490
- catch {
2491
- body["scopes"] = fieldValue.includes(",")
2492
- ? fieldValue.split(",")
2493
- : [fieldValue];
2494
- }
2495
- }
2496
- else if (fieldName === "oauth_identity" ||
2497
- fieldName === "oauth_identity_json") {
2498
- try {
2499
- body["oauth_identity"] = JSON.parse(fieldValue) || null;
2500
- }
2501
- catch {
2502
- body["oauth_identity"] = null;
2503
- }
2504
- }
2505
- else if (fieldName === "termsAccepted") {
2506
- body[fieldName] =
2507
- fieldValue === "on" ||
2508
- fieldValue === "true" ||
2509
- fieldValue === "1" ||
2510
- fieldValue === "yes";
2511
- }
2512
- else if (fieldName === "customFields") {
2513
- try {
2514
- body[fieldName] = JSON.parse(fieldValue);
2515
- }
2516
- catch {
2517
- // Skip
2518
- }
2519
- }
2520
- else {
2521
- body[fieldName] = fieldValue;
2522
- }
2523
- hasValidFields = true;
2524
- }
2525
- if (hasValidFields && Object.keys(body).length > 0) {
2526
- if (!("termsAccepted" in body)) {
2527
- body.termsAccepted = true;
2528
- }
2529
- // Default scopes to empty array if not provided
2530
- if (!("scopes" in body)) {
2531
- body.scopes = [];
2532
- }
2533
- return body;
2668
+ const parsed = parseMultipartText(text);
2669
+ if (parsed) {
2670
+ return parsed;
2534
2671
  }
2535
2672
  }
2536
2673
  }
@@ -2559,77 +2696,25 @@ export class ConsentService {
2559
2696
  errorMessage.includes("Content-Type was not one of") ||
2560
2697
  errorCause.includes("missing boundary") ||
2561
2698
  errorCause.includes("boundary")) {
2562
- // When boundary is missing, try to parse as URL-encoded form data instead
2563
- // This handles the case where FormData was passed but Content-Type doesn't include boundary
2699
+ // When boundary is missing, parse the text itself. This handles
2700
+ // the case where FormData was passed but Content-Type doesn't
2701
+ // include boundary. A body with multipart parts is parsed only as
2702
+ // multipart: read as URL-encoded it would become one garbage
2703
+ // field plus the form defaults, and URLSearchParams never throws.
2564
2704
  try {
2565
2705
  const textRequest = request.clone();
2566
2706
  const text = await textRequest.text();
2567
- // If text parsing succeeds, try URL-encoded parsing
2568
2707
  if (text && text.length > 0) {
2569
- try {
2708
+ if (text.includes("Content-Disposition")) {
2709
+ const parsed = parseMultipartText(text);
2710
+ if (parsed) {
2711
+ return parsed;
2712
+ }
2713
+ }
2714
+ else {
2570
2715
  const params = new URLSearchParams(text);
2571
2716
  return parseFormFields(params.entries());
2572
2717
  }
2573
- catch {
2574
- // If URL-encoded parsing fails, the text might be multipart format
2575
- // Try to manually parse multipart format from text
2576
- const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
2577
- const body = {};
2578
- let match;
2579
- let hasValidFields = false;
2580
- while ((match = fieldRegex.exec(text)) !== null) {
2581
- const fieldName = match[1];
2582
- const fieldValue = match[2].trim();
2583
- if (fieldName === "scopes" || fieldName === "scopes[]") {
2584
- try {
2585
- body["scopes"] = JSON.parse(fieldValue);
2586
- }
2587
- catch {
2588
- body["scopes"] = fieldValue.includes(",")
2589
- ? fieldValue.split(",")
2590
- : [fieldValue];
2591
- }
2592
- }
2593
- else if (fieldName === "oauth_identity" ||
2594
- fieldName === "oauth_identity_json") {
2595
- try {
2596
- body["oauth_identity"] = JSON.parse(fieldValue) || null;
2597
- }
2598
- catch {
2599
- body["oauth_identity"] = null;
2600
- }
2601
- }
2602
- else if (fieldName === "termsAccepted") {
2603
- body[fieldName] =
2604
- fieldValue === "on" ||
2605
- fieldValue === "true" ||
2606
- fieldValue === "1" ||
2607
- fieldValue === "yes";
2608
- }
2609
- else if (fieldName === "customFields") {
2610
- try {
2611
- body[fieldName] = JSON.parse(fieldValue);
2612
- }
2613
- catch {
2614
- // Skip
2615
- }
2616
- }
2617
- else {
2618
- body[fieldName] = fieldValue;
2619
- }
2620
- hasValidFields = true;
2621
- }
2622
- if (hasValidFields && Object.keys(body).length > 0) {
2623
- if (!("termsAccepted" in body)) {
2624
- body.termsAccepted = true;
2625
- }
2626
- // Default scopes to empty array if not provided
2627
- if (!("scopes" in body)) {
2628
- body.scopes = [];
2629
- }
2630
- return body;
2631
- }
2632
- }
2633
2718
  }
2634
2719
  }
2635
2720
  catch {
@@ -2643,66 +2728,12 @@ export class ConsentService {
2643
2728
  // Check if FormData parsing returned malformed data (entire multipart body as single entry)
2644
2729
  // Type assertion: FormData.entries() exists at runtime in Cloudflare Workers
2645
2730
  const entriesArray = Array.from(formData.entries());
2646
- if (entriesArray.length === 1 &&
2647
- typeof entriesArray[0][0] === "string" &&
2648
- entriesArray[0][0].includes("Content-Disposition") &&
2649
- typeof entriesArray[0][1] === "string" &&
2650
- entriesArray[0][1].toString().includes("Content-Disposition")) {
2651
- // Manually parse multipart format from the value string
2652
- const keyPart = entriesArray[0][0];
2653
- const valuePart = entriesArray[0][1];
2654
- // Fix: If key ends with "name" and value starts with '"fieldname"', combine them
2655
- let multipartBody;
2656
- if (keyPart.trim().endsWith("name") &&
2657
- valuePart.trim().startsWith('"')) {
2658
- multipartBody = keyPart + "=" + valuePart;
2659
- }
2660
- else {
2661
- multipartBody = keyPart + valuePart;
2662
- }
2663
- // Match: Content-Disposition header with field name, then capture value until next boundary or end
2664
- const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
2665
- let match;
2666
- while ((match = fieldRegex.exec(multipartBody)) !== null) {
2667
- const fieldName = match[1];
2668
- const fieldValue = match[2].trim();
2669
- if (fieldName === "scopes" || fieldName === "scopes[]") {
2670
- try {
2671
- body["scopes"] = JSON.parse(fieldValue);
2672
- }
2673
- catch {
2674
- body["scopes"] = fieldValue.includes(",")
2675
- ? fieldValue.split(",")
2676
- : [fieldValue];
2677
- }
2678
- }
2679
- else if (fieldName === "oauth_identity" ||
2680
- fieldName === "oauth_identity_json") {
2681
- try {
2682
- body["oauth_identity"] = JSON.parse(fieldValue) || null;
2683
- }
2684
- catch {
2685
- body["oauth_identity"] = null;
2686
- }
2687
- }
2688
- else if (fieldName === "termsAccepted") {
2689
- body[fieldName] =
2690
- fieldValue === "on" ||
2691
- fieldValue === "true" ||
2692
- fieldValue === "1" ||
2693
- fieldValue === "yes";
2694
- }
2695
- else if (fieldName === "customFields") {
2696
- try {
2697
- body[fieldName] = JSON.parse(fieldValue);
2698
- }
2699
- catch {
2700
- // Skip
2701
- }
2702
- }
2703
- else {
2704
- body[fieldName] = fieldValue;
2705
- }
2731
+ if (isMalformedSingleEntry(entriesArray)) {
2732
+ // FormData leaked the entire multipart body into a single malformed
2733
+ // entry. Reconstruct a parseable body and extract the fields.
2734
+ const multipartBody = reconstructMalformedMultipart(entriesArray[0][0], entriesArray[0][1]);
2735
+ for (const field of parseMultipartFields(multipartBody)) {
2736
+ coerceFieldInto(body, field.name, field.value);
2706
2737
  hasValidFields = true;
2707
2738
  }
2708
2739
  }
@@ -2714,63 +2745,13 @@ export class ConsentService {
2714
2745
  continue;
2715
2746
  }
2716
2747
  // Extract field name from malformed keys (when FormData is passed with wrong Content-Type)
2717
- let fieldName = key;
2718
- if (key.includes("Content-Disposition") && key.includes('name="')) {
2719
- // Extract field name from Content-Disposition header
2720
- const nameMatch = key.match(/name="([^"]+)"/);
2721
- if (nameMatch && nameMatch[1]) {
2722
- fieldName = nameMatch[1];
2723
- }
2724
- else {
2725
- // Skip if we can't extract a valid field name
2726
- continue;
2727
- }
2728
- }
2729
- else if (key.includes("------") ||
2730
- key.includes("\r\n") ||
2731
- key.trim() === "") {
2748
+ const fieldName = extractFieldNameFromKey(key);
2749
+ if (fieldName === null) {
2732
2750
  // Skip boundary strings and invalid keys
2733
2751
  continue;
2734
2752
  }
2735
2753
  hasValidFields = true;
2736
- const stringValue = value.toString();
2737
- if (fieldName === "scopes" || fieldName === "scopes[]") {
2738
- try {
2739
- body["scopes"] = JSON.parse(stringValue);
2740
- }
2741
- catch {
2742
- body["scopes"] = stringValue.includes(",")
2743
- ? stringValue.split(",")
2744
- : [stringValue];
2745
- }
2746
- }
2747
- else if (fieldName === "oauth_identity" ||
2748
- fieldName === "oauth_identity_json") {
2749
- try {
2750
- body["oauth_identity"] = JSON.parse(stringValue) || null;
2751
- }
2752
- catch {
2753
- body["oauth_identity"] = null;
2754
- }
2755
- }
2756
- else if (fieldName === "termsAccepted") {
2757
- body[fieldName] =
2758
- stringValue === "on" ||
2759
- stringValue === "true" ||
2760
- stringValue === "1" ||
2761
- stringValue === "yes";
2762
- }
2763
- else if (fieldName === "customFields") {
2764
- try {
2765
- body[fieldName] = JSON.parse(stringValue);
2766
- }
2767
- catch {
2768
- // Skip
2769
- }
2770
- }
2771
- else {
2772
- body[fieldName] = stringValue;
2773
- }
2754
+ coerceFieldInto(body, fieldName, value.toString());
2774
2755
  }
2775
2756
  }
2776
2757
  if (hasValidFields && Object.keys(body).length > 0) {
@@ -2813,61 +2794,9 @@ export class ConsentService {
2813
2794
  // URL-encoded parsing failed, try multipart format
2814
2795
  }
2815
2796
  // Try multipart format parsing (FormData serializes to multipart)
2816
- const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
2817
- const body = {};
2818
- let match;
2819
- let hasValidFields = false;
2820
- while ((match = fieldRegex.exec(text)) !== null) {
2821
- const fieldName = match[1];
2822
- const fieldValue = match[2].trim();
2823
- if (fieldName === "scopes" || fieldName === "scopes[]") {
2824
- try {
2825
- body["scopes"] = JSON.parse(fieldValue);
2826
- }
2827
- catch {
2828
- body["scopes"] = fieldValue.includes(",")
2829
- ? fieldValue.split(",")
2830
- : [fieldValue];
2831
- }
2832
- }
2833
- else if (fieldName === "oauth_identity" ||
2834
- fieldName === "oauth_identity_json") {
2835
- try {
2836
- body["oauth_identity"] = JSON.parse(fieldValue) || null;
2837
- }
2838
- catch {
2839
- body["oauth_identity"] = null;
2840
- }
2841
- }
2842
- else if (fieldName === "termsAccepted") {
2843
- body[fieldName] =
2844
- fieldValue === "on" ||
2845
- fieldValue === "true" ||
2846
- fieldValue === "1" ||
2847
- fieldValue === "yes";
2848
- }
2849
- else if (fieldName === "customFields") {
2850
- try {
2851
- body[fieldName] = JSON.parse(fieldValue);
2852
- }
2853
- catch {
2854
- // Skip
2855
- }
2856
- }
2857
- else {
2858
- body[fieldName] = fieldValue;
2859
- }
2860
- hasValidFields = true;
2861
- }
2862
- if (hasValidFields && Object.keys(body).length > 0) {
2863
- if (!("termsAccepted" in body)) {
2864
- body.termsAccepted = true;
2865
- }
2866
- // Default scopes to empty array if not provided
2867
- if (!("scopes" in body)) {
2868
- body.scopes = [];
2869
- }
2870
- return body;
2797
+ const parsed = parseMultipartText(text);
2798
+ if (parsed) {
2799
+ return parsed;
2871
2800
  }
2872
2801
  }
2873
2802
  }
@@ -2907,61 +2836,9 @@ export class ConsentService {
2907
2836
  // URL-encoded parsing failed, try multipart format
2908
2837
  }
2909
2838
  // Try to manually parse multipart format from text
2910
- const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
2911
- const body = {};
2912
- let match;
2913
- let hasValidFields = false;
2914
- while ((match = fieldRegex.exec(text)) !== null) {
2915
- const fieldName = match[1];
2916
- const fieldValue = match[2].trim();
2917
- if (fieldName === "scopes" || fieldName === "scopes[]") {
2918
- try {
2919
- body["scopes"] = JSON.parse(fieldValue);
2920
- }
2921
- catch {
2922
- body["scopes"] = fieldValue.includes(",")
2923
- ? fieldValue.split(",")
2924
- : [fieldValue];
2925
- }
2926
- }
2927
- else if (fieldName === "oauth_identity" ||
2928
- fieldName === "oauth_identity_json") {
2929
- try {
2930
- body["oauth_identity"] = JSON.parse(fieldValue) || null;
2931
- }
2932
- catch {
2933
- body["oauth_identity"] = null;
2934
- }
2935
- }
2936
- else if (fieldName === "termsAccepted") {
2937
- body[fieldName] =
2938
- fieldValue === "on" ||
2939
- fieldValue === "true" ||
2940
- fieldValue === "1" ||
2941
- fieldValue === "yes";
2942
- }
2943
- else if (fieldName === "customFields") {
2944
- try {
2945
- body[fieldName] = JSON.parse(fieldValue);
2946
- }
2947
- catch {
2948
- // Skip
2949
- }
2950
- }
2951
- else {
2952
- body[fieldName] = fieldValue;
2953
- }
2954
- hasValidFields = true;
2955
- }
2956
- if (hasValidFields && Object.keys(body).length > 0) {
2957
- if (!("termsAccepted" in body)) {
2958
- body.termsAccepted = true;
2959
- }
2960
- // Default scopes to empty array if not provided
2961
- if (!("scopes" in body)) {
2962
- body.scopes = [];
2963
- }
2964
- return body;
2839
+ const parsed = parseMultipartText(text);
2840
+ if (parsed) {
2841
+ return parsed;
2965
2842
  }
2966
2843
  }
2967
2844
  }
@@ -2991,61 +2868,9 @@ export class ConsentService {
2991
2868
  // URL-encoded parsing failed, try multipart format
2992
2869
  }
2993
2870
  // Try multipart format parsing
2994
- const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
2995
- const body = {};
2996
- let match;
2997
- let hasValidFields = false;
2998
- while ((match = fieldRegex.exec(text)) !== null) {
2999
- const fieldName = match[1];
3000
- const fieldValue = match[2].trim();
3001
- if (fieldName === "scopes" || fieldName === "scopes[]") {
3002
- try {
3003
- body["scopes"] = JSON.parse(fieldValue);
3004
- }
3005
- catch {
3006
- body["scopes"] = fieldValue.includes(",")
3007
- ? fieldValue.split(",")
3008
- : [fieldValue];
3009
- }
3010
- }
3011
- else if (fieldName === "oauth_identity" ||
3012
- fieldName === "oauth_identity_json") {
3013
- try {
3014
- body["oauth_identity"] = JSON.parse(fieldValue) || null;
3015
- }
3016
- catch {
3017
- body["oauth_identity"] = null;
3018
- }
3019
- }
3020
- else if (fieldName === "termsAccepted") {
3021
- body[fieldName] =
3022
- fieldValue === "on" ||
3023
- fieldValue === "true" ||
3024
- fieldValue === "1" ||
3025
- fieldValue === "yes";
3026
- }
3027
- else if (fieldName === "customFields") {
3028
- try {
3029
- body[fieldName] = JSON.parse(fieldValue);
3030
- }
3031
- catch {
3032
- // Skip
3033
- }
3034
- }
3035
- else {
3036
- body[fieldName] = fieldValue;
3037
- }
3038
- hasValidFields = true;
3039
- }
3040
- if (hasValidFields && Object.keys(body).length > 0) {
3041
- if (!("termsAccepted" in body)) {
3042
- body.termsAccepted = true;
3043
- }
3044
- // Default scopes to empty array if not provided
3045
- if (!("scopes" in body)) {
3046
- body.scopes = [];
3047
- }
3048
- return body;
2871
+ const parsed = parseMultipartText(text);
2872
+ if (parsed) {
2873
+ return parsed;
3049
2874
  }
3050
2875
  }
3051
2876
  // If text parsing failed or found no valid fields, throw error to trigger fallback
@@ -3063,74 +2888,15 @@ export class ConsentService {
3063
2888
  for (const [key, value] of formData.entries()) {
3064
2889
  if (value instanceof File)
3065
2890
  continue;
3066
- // Extract field name from malformed keys
3067
- let fieldName = key;
3068
- if (key.includes("Content-Disposition") && key.includes('name="')) {
3069
- const nameMatch = key.match(/name="([^"]+)"/);
3070
- if (nameMatch && nameMatch[1]) {
3071
- fieldName = nameMatch[1];
3072
- }
3073
- else {
3074
- continue;
3075
- }
3076
- }
3077
- else if (key.includes("------") ||
3078
- key.includes("\r\n") ||
3079
- key.trim() === "" ||
3080
- key.includes("formdata-undici")) {
3081
- // Malformed key - likely from text/plain Content-Type mismatch
3082
- // Skip this entry and fall back to text parsing
2891
+ // Extract field name from malformed keys (likely from a text/plain
2892
+ // Content-Type mismatch); boundary noise is skipped so text
2893
+ // parsing can take over
2894
+ const fieldName = extractFieldNameFromKey(key);
2895
+ if (fieldName === null) {
3083
2896
  continue;
3084
2897
  }
3085
- // If we have a valid field name, mark as having valid entries
3086
- if (fieldName && fieldName !== key) {
3087
- hasValidEntries = true;
3088
- }
3089
- else if (fieldName &&
3090
- !key.includes("Content-Disposition") &&
3091
- !key.includes("------") &&
3092
- !key.includes("\r\n")) {
3093
- hasValidEntries = true;
3094
- }
3095
- // Process the field value
3096
- const stringValue = value.toString();
3097
- if (fieldName === "scopes" || fieldName === "scopes[]") {
3098
- try {
3099
- body["scopes"] = JSON.parse(stringValue);
3100
- }
3101
- catch {
3102
- body["scopes"] = stringValue.includes(",")
3103
- ? stringValue.split(",")
3104
- : [stringValue];
3105
- }
3106
- }
3107
- else if (fieldName === "oauth_identity" ||
3108
- fieldName === "oauth_identity_json") {
3109
- try {
3110
- body["oauth_identity"] = JSON.parse(stringValue) || null;
3111
- }
3112
- catch {
3113
- body["oauth_identity"] = null;
3114
- }
3115
- }
3116
- else if (fieldName === "termsAccepted") {
3117
- body[fieldName] =
3118
- stringValue === "on" ||
3119
- stringValue === "true" ||
3120
- stringValue === "1" ||
3121
- stringValue === "yes";
3122
- }
3123
- else if (fieldName === "customFields") {
3124
- try {
3125
- body[fieldName] = JSON.parse(stringValue);
3126
- }
3127
- catch {
3128
- // Skip
3129
- }
3130
- }
3131
- else {
3132
- body[fieldName] = stringValue;
3133
- }
2898
+ hasValidEntries = true;
2899
+ coerceFieldInto(body, fieldName, value.toString());
3134
2900
  }
3135
2901
  // If we didn't get any valid entries (all keys were malformed), try text parsing
3136
2902
  if (!hasValidEntries || Object.keys(body).length === 0) {
@@ -3140,61 +2906,9 @@ export class ConsentService {
3140
2906
  const text = await textRequest.text();
3141
2907
  if (text && text.length > 0) {
3142
2908
  // Try multipart format parsing from text
3143
- const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
3144
- const textBody = {};
3145
- let match;
3146
- let hasValidFields = false;
3147
- while ((match = fieldRegex.exec(text)) !== null) {
3148
- const fieldName = match[1];
3149
- const fieldValue = match[2].trim();
3150
- if (fieldName === "scopes" || fieldName === "scopes[]") {
3151
- try {
3152
- textBody["scopes"] = JSON.parse(fieldValue);
3153
- }
3154
- catch {
3155
- textBody["scopes"] = fieldValue.includes(",")
3156
- ? fieldValue.split(",")
3157
- : [fieldValue];
3158
- }
3159
- }
3160
- else if (fieldName === "oauth_identity" ||
3161
- fieldName === "oauth_identity_json") {
3162
- try {
3163
- textBody["oauth_identity"] =
3164
- JSON.parse(fieldValue) || null;
3165
- }
3166
- catch {
3167
- textBody["oauth_identity"] = null;
3168
- }
3169
- }
3170
- else if (fieldName === "termsAccepted") {
3171
- textBody[fieldName] =
3172
- fieldValue === "on" ||
3173
- fieldValue === "true" ||
3174
- fieldValue === "1" ||
3175
- fieldValue === "yes";
3176
- }
3177
- else if (fieldName === "customFields") {
3178
- try {
3179
- textBody[fieldName] = JSON.parse(fieldValue);
3180
- }
3181
- catch {
3182
- // Skip
3183
- }
3184
- }
3185
- else {
3186
- textBody[fieldName] = fieldValue;
3187
- }
3188
- hasValidFields = true;
3189
- }
3190
- if (hasValidFields && Object.keys(textBody).length > 0) {
3191
- if (!("termsAccepted" in textBody)) {
3192
- textBody.termsAccepted = true;
3193
- }
3194
- if (!("scopes" in textBody)) {
3195
- textBody.scopes = [];
3196
- }
3197
- return textBody;
2909
+ const parsed = parseMultipartText(text);
2910
+ if (parsed) {
2911
+ return parsed;
3198
2912
  }
3199
2913
  }
3200
2914
  }
@@ -3224,61 +2938,9 @@ export class ConsentService {
3224
2938
  const text = await textRequest.text();
3225
2939
  if (text && text.length > 0) {
3226
2940
  // Try multipart format parsing
3227
- const fieldRegex = /Content-Disposition:\s*form-data;\s*name="([^"]+)"\r?\n\r?\n([\s\S]*?)(?=\r?\n------|$)/g;
3228
- const body = {};
3229
- let match;
3230
- let hasValidFields = false;
3231
- while ((match = fieldRegex.exec(text)) !== null) {
3232
- const fieldName = match[1];
3233
- const fieldValue = match[2].trim();
3234
- if (fieldName === "scopes" || fieldName === "scopes[]") {
3235
- try {
3236
- body["scopes"] = JSON.parse(fieldValue);
3237
- }
3238
- catch {
3239
- body["scopes"] = fieldValue.includes(",")
3240
- ? fieldValue.split(",")
3241
- : [fieldValue];
3242
- }
3243
- }
3244
- else if (fieldName === "oauth_identity" ||
3245
- fieldName === "oauth_identity_json") {
3246
- try {
3247
- body["oauth_identity"] = JSON.parse(fieldValue) || null;
3248
- }
3249
- catch {
3250
- body["oauth_identity"] = null;
3251
- }
3252
- }
3253
- else if (fieldName === "termsAccepted") {
3254
- body[fieldName] =
3255
- fieldValue === "on" ||
3256
- fieldValue === "true" ||
3257
- fieldValue === "1" ||
3258
- fieldValue === "yes";
3259
- }
3260
- else if (fieldName === "customFields") {
3261
- try {
3262
- body[fieldName] = JSON.parse(fieldValue);
3263
- }
3264
- catch {
3265
- // Skip
3266
- }
3267
- }
3268
- else {
3269
- body[fieldName] = fieldValue;
3270
- }
3271
- hasValidFields = true;
3272
- }
3273
- if (hasValidFields && Object.keys(body).length > 0) {
3274
- if (!("termsAccepted" in body)) {
3275
- body.termsAccepted = true;
3276
- }
3277
- // Default scopes to empty array if not provided
3278
- if (!("scopes" in body)) {
3279
- body.scopes = [];
3280
- }
3281
- return body;
2941
+ const parsed = parseMultipartText(text);
2942
+ if (parsed) {
2943
+ return parsed;
3282
2944
  }
3283
2945
  }
3284
2946
  }
@@ -3322,6 +2984,113 @@ export class ConsentService {
3322
2984
  headers: { "Content-Type": "application/json" },
3323
2985
  });
3324
2986
  }
2987
+ /**
2988
+ * 400 returned by `handleApproval` when `provider_type` is missing or not
2989
+ * one of the routes in approval-route.ts. Nothing is minted.
2990
+ */
2991
+ unsupportedProviderTypeResponse() {
2992
+ return new Response(JSON.stringify({
2993
+ success: false,
2994
+ error: "Unsupported provider_type",
2995
+ error_code: "unsupported_provider_type",
2996
+ }), {
2997
+ status: 400,
2998
+ headers: { "Content-Type": "application/json" },
2999
+ });
3000
+ }
3001
+ /**
3002
+ * 503 returned by `handleApproval` when a consent-only approval has a
3003
+ * session but no throwaway DID could be made or reused (key generation or a
3004
+ * session read failed). Nothing is minted; the user can retry.
3005
+ */
3006
+ identityUnavailableResponse() {
3007
+ return new Response(JSON.stringify({
3008
+ success: false,
3009
+ error: "Could not set up an identity for this approval. Please try again.",
3010
+ error_code: "identity_unavailable",
3011
+ }), {
3012
+ status: 503,
3013
+ headers: { "Content-Type": "application/json" },
3014
+ });
3015
+ }
3016
+ /** A non-empty string, or undefined. */
3017
+ optionalString(value) {
3018
+ return typeof value === "string" && value.trim().length > 0
3019
+ ? value
3020
+ : undefined;
3021
+ }
3022
+ /**
3023
+ * The provider signed into an identity assertion. Call ONLY after
3024
+ * `verifyIdentityAssertion` accepted the same token: this decodes the
3025
+ * payload and does not check the signature again.
3026
+ */
3027
+ readAssertionProvider(verifiedToken) {
3028
+ try {
3029
+ const payloadB64 = verifiedToken.substring(verifiedToken.indexOf(".") + 1);
3030
+ const payload = JSON.parse(this.base64UrlDecodeString(payloadB64));
3031
+ return this.optionalString(payload.provider);
3032
+ }
3033
+ catch {
3034
+ return undefined;
3035
+ }
3036
+ }
3037
+ /**
3038
+ * Keep a credential sign-in's display fields server-side for the clickwrap
3039
+ * that follows. Expires with the identity assertion. Best effort: without
3040
+ * it the delegation falls back to showing the user DID.
3041
+ */
3042
+ async storeCredentialClickwrapUser(sessionId, userDid, userEmail, userId) {
3043
+ if (!this.env.DELEGATION_STORAGE || (!userEmail && !userId))
3044
+ return;
3045
+ try {
3046
+ await this.env.DELEGATION_STORAGE.put(STORAGE_KEYS.credentialClickwrapUser(sessionId), JSON.stringify({ userDid, userEmail, userId }), { expirationTtl: CREDENTIAL_CLICKWRAP_USER_TTL_SECONDS });
3047
+ }
3048
+ catch (error) {
3049
+ logger.warn("[ConsentService] Failed to store credential sign-in display fields:", error);
3050
+ }
3051
+ }
3052
+ /**
3053
+ * The display fields `storeCredentialClickwrapUser` kept for this session,
3054
+ * only when they belong to `userDid` (the asserted user).
3055
+ */
3056
+ async readCredentialClickwrapUser(sessionId, userDid) {
3057
+ if (!this.env.DELEGATION_STORAGE || !sessionId || !userDid)
3058
+ return undefined;
3059
+ try {
3060
+ const stored = (await this.env.DELEGATION_STORAGE.get(STORAGE_KEYS.credentialClickwrapUser(sessionId), "json"));
3061
+ if (!stored || stored.userDid !== userDid)
3062
+ return undefined;
3063
+ return {
3064
+ userEmail: this.optionalString(stored.userEmail),
3065
+ userId: this.optionalString(stored.userId),
3066
+ };
3067
+ }
3068
+ catch (error) {
3069
+ logger.warn("[ConsentService] Failed to read credential sign-in display fields:", error);
3070
+ return undefined;
3071
+ }
3072
+ }
3073
+ /**
3074
+ * The OAuth identity the callback recorded on this session, only when the
3075
+ * session's user is `userDid` (the asserted user). Used for display fields.
3076
+ */
3077
+ async readSessionOAuthUser(sessionId, userDid) {
3078
+ if (!this.env.DELEGATION_STORAGE || !sessionId || !userDid)
3079
+ return undefined;
3080
+ try {
3081
+ const session = (await this.env.DELEGATION_STORAGE.get(STORAGE_KEYS.session(sessionId), "json"));
3082
+ if (!session || session.userDid !== userDid)
3083
+ return undefined;
3084
+ return {
3085
+ email: this.optionalString(session.oauthIdentity?.email),
3086
+ subject: this.optionalString(session.oauthIdentity?.subject),
3087
+ };
3088
+ }
3089
+ catch (error) {
3090
+ logger.warn("[ConsentService] Failed to read the session's OAuth identity:", error);
3091
+ return undefined;
3092
+ }
3093
+ }
3325
3094
  /**
3326
3095
  * Handle consent approval
3327
3096
  *
@@ -3341,12 +3110,18 @@ export class ConsentService {
3341
3110
  bodyKeys: Object.keys(body || {}),
3342
3111
  hasOAuthIdentity: !!body?.oauth_identity,
3343
3112
  });
3344
- // Handle provider_type routing:
3113
+ // Handle provider_type routing (see approval-route.ts):
3345
3114
  // - 'credential' or 'password': User authenticated via credential form (email/password)
3346
- // - 'oauth2': User authenticated via OAuth provider (handled below)
3115
+ // - 'oauth' or 'oauth2': clickwrap after OAuth, needs a signed identity assertion
3347
3116
  // - 'none': Consent-only mode (clickwrap) - user agrees without authentication
3117
+ // SECURITY: anything else is refused. Unknown values used to fall
3118
+ // through to an unchecked path that minted under a DID looked up from
3119
+ // the caller's own `oauth_identity`.
3348
3120
  const bodyObj = body;
3349
- const providerType = bodyObj?.provider_type;
3121
+ const route = resolveApprovalRoute(bodyObj?.provider_type);
3122
+ if (!route) {
3123
+ return this.unsupportedProviderTypeResponse();
3124
+ }
3350
3125
  // SECURITY (A3, spec §3.1/§3.2, defect #2): the identity a delegation is
3351
3126
  // minted under is authoritative ONLY from a trusted source resolved below
3352
3127
  // — a server-signed identity assertion (credential/OAuth clickwrap) or the
@@ -3357,44 +3132,41 @@ export class ConsentService {
3357
3132
  // forged one. `undefined` for the plain authenticated (non-clickwrap)
3358
3133
  // shapes, which fall back to server-side session resolution.
3359
3134
  let trustedUserDid;
3135
+ // Set only when a consent-only approval in a signed-in session mints
3136
+ // under a fresh throwaway DID: the key that signs its VC, and whether the
3137
+ // session record must keep the signed-in DID.
3138
+ let consentOnlySigningKey;
3139
+ let keepSessionUserDid = false;
3360
3140
  // CRED-003: Check for credential provider submission
3361
3141
  // Credential submissions include `provider_type: 'credential'` or 'password' and are handled separately
3362
3142
  // IMPORTANT: Checkpoint tool protection uses 'password' for credential-based auth validation,
3363
3143
  // but we also accept 'credential' for backward compatibility
3364
- if (providerType === CONSENT_PROVIDER_TYPES.CREDENTIAL ||
3365
- providerType === CONSENT_PROVIDER_TYPES.PASSWORD) {
3366
- logger.debug("[ConsentService] Credential submission detected", {
3367
- provider_type: providerType,
3368
- });
3144
+ if (route === "credential") {
3145
+ logger.debug("[ConsentService] Credential submission detected");
3369
3146
  return this.handleCredentialApproval(bodyObj, request);
3370
3147
  }
3371
- // ✅ Consent-only mode (provider_type: 'none')
3372
- // This can be:
3148
+ // SECURITY: the caller never says who signed in, or how. Drop the
3149
+ // body's OAuth identity, user display fields and server-owned
3150
+ // customFields keys before anything reads them; the branches below set
3151
+ // the server's own values from the verified sign-in.
3152
+ delete bodyObj.oauth_identity;
3153
+ delete bodyObj.oauth_identity_json;
3154
+ delete bodyObj.credential_user_email;
3155
+ delete bodyObj.credential_provider_user_id;
3156
+ bodyObj.customFields = stripServerOwnedFields(bodyObj.customFields);
3157
+ const isPostOAuthClickwrap = route === "oauth" || isPostOAuthMarker(bodyObj.oauth_provider_type);
3158
+ const isPostCredentialClickwrap = route === "clickwrap" &&
3159
+ isPostCredentialMarker(bodyObj.credential_provider_type);
3160
+ // ✅ Clickwrap approval. This can be:
3373
3161
  // 1. Pure consent-only: User consents without authentication (clickwrap only)
3374
3162
  // 2. Post-credential clickwrap: User already authenticated via credentials, this is the clickwrap step
3375
3163
  // 3. Post-OAuth clickwrap: User already authenticated via OAuth, this is the clickwrap step
3376
- if (providerType === CONSENT_PROVIDER_TYPES.NONE) {
3377
- const bodyObj = body;
3164
+ if (route === "clickwrap" || route === "oauth") {
3378
3165
  const sessionId = bodyObj.session_id;
3379
- // Check for credential auth parameters passed from handleCredentialApproval
3380
- // These indicate this is the clickwrap step AFTER credential authentication
3381
- const credentialProviderType = bodyObj.credential_provider_type;
3382
- const credentialProvider = bodyObj.credential_provider;
3383
- if (credentialProviderType &&
3384
- credentialProviderType === CONSENT_PROVIDER_TYPES.PASSWORD) {
3166
+ if (isPostCredentialClickwrap) {
3385
3167
  // This is the clickwrap step after credential auth
3386
3168
  // The session already has userDid from credential auth
3387
3169
  logger.debug("[ConsentService] Consent-only mode (post-credential clickwrap)");
3388
- // Override provider_type to 'password' so delegation is created with correct authorization
3389
- // This ensures authorization.type matches tool protection expectation
3390
- bodyObj.provider_type = CONSENT_PROVIDER_TYPES.PASSWORD;
3391
- if (credentialProvider) {
3392
- bodyObj.customFields = {
3393
- ...(bodyObj.customFields || {}),
3394
- provider: credentialProvider,
3395
- provider_type: CONSENT_PROVIDER_TYPES.PASSWORD,
3396
- };
3397
- }
3398
3170
  // SECURITY (A3, spec §3.2): the userDid for a post-credential
3399
3171
  // clickwrap mint comes ONLY from the server-signed identity assertion
3400
3172
  // minted by handleCredentialApproval after it authenticated against
@@ -3402,12 +3174,14 @@ export class ConsentService {
3402
3174
  // project). The bare `user_did` redirect param that used to be trusted
3403
3175
  // here is gone: it was caller-forgeable (subject/issuer forgery). A
3404
3176
  // missing/invalid/expired/mismatched assertion → 403, no mint.
3177
+ let signedProvider;
3405
3178
  if (this.isDevConsentTestMode()) {
3406
3179
  // Dev/test escape hatch (BOTH MCPI_TEST_CONSENT and a development
3407
3180
  // env), mirroring consent-anchor-bridge's call-site CSRF relaxation:
3408
3181
  // no signed assertion is required, so local/miniflare runs work
3409
3182
  // without OAUTH_ENCRYPTION_SECRET. Never on prod/staging/unset.
3410
3183
  trustedUserDid = bodyObj.user_did;
3184
+ signedProvider = this.optionalString(bodyObj.credential_provider);
3411
3185
  }
3412
3186
  else {
3413
3187
  const assertion = bodyObj.identity_assertion;
@@ -3425,68 +3199,70 @@ export class ConsentService {
3425
3199
  return this.identityAssertionRequiredResponse();
3426
3200
  }
3427
3201
  trustedUserDid = verifiedUserDid;
3202
+ signedProvider = this.readAssertionProvider(assertion);
3428
3203
  }
3429
- // Read user info from redirect URL params
3204
+ // Override provider_type to 'password' so delegation is created with correct authorization
3205
+ // This ensures authorization.type matches tool protection expectation.
3206
+ // The provider label is the one the credential handler signed, not
3207
+ // the body's `credential_provider`.
3208
+ bodyObj.provider_type = CONSENT_PROVIDER_TYPES.PASSWORD;
3209
+ bodyObj.customFields = {
3210
+ ...bodyObj.customFields,
3211
+ ...(signedProvider ? { provider: signedProvider } : {}),
3212
+ provider_type: CONSENT_PROVIDER_TYPES.PASSWORD,
3213
+ };
3430
3214
  // Field mapping (clear naming):
3431
3215
  // credential_user_email → user_identifier (human-readable for display)
3432
3216
  // credential_provider_user_id → user_id (provider's internal ID)
3433
- const credentialUserEmailFromRedirect = bodyObj.credential_user_email;
3434
- const credentialProviderUserIdFromRedirect = bodyObj.credential_provider_user_id;
3435
- if (credentialUserEmailFromRedirect ||
3436
- credentialProviderUserIdFromRedirect) {
3437
- logger.debug("[ConsentService] ✅ Using credential user info from redirect:", {
3438
- sessionId: sessionId?.substring(0, 20) + "...",
3439
- credential_user_email: credentialUserEmailFromRedirect
3440
- ? credentialUserEmailFromRedirect.substring(0, 20) + "..."
3441
- : undefined,
3442
- credential_provider_user_id: credentialProviderUserIdFromRedirect
3443
- ? credentialProviderUserIdFromRedirect.substring(0, 20) +
3444
- "..."
3445
- : undefined,
3446
- source: "redirect-param",
3447
- });
3217
+ // Both come from the credential handler's stored sign-in, never from
3218
+ // the redirect params the page posts back.
3219
+ const signInUser = await this.readCredentialClickwrapUser(sessionId, trustedUserDid);
3220
+ if (signInUser?.userEmail) {
3221
+ bodyObj.credential_user_email = signInUser.userEmail;
3222
+ }
3223
+ if (signInUser?.userId) {
3224
+ bodyObj.credential_provider_user_id = signInUser.userId;
3448
3225
  }
3449
3226
  logger.debug("[ConsentService] ✅ Using credential auth provider_type for delegation:", {
3450
3227
  sessionId: sessionId?.substring(0, 20) + "...",
3451
3228
  providerType: CONSENT_PROVIDER_TYPES.PASSWORD,
3452
- provider: credentialProvider,
3229
+ provider: signedProvider,
3453
3230
  hasUserDid: !!trustedUserDid,
3454
- hasCredentialUserEmail: !!credentialUserEmailFromRedirect,
3455
- hasProviderUserId: !!credentialProviderUserIdFromRedirect,
3231
+ hasCredentialUserEmail: !!signInUser?.userEmail,
3232
+ hasProviderUserId: !!signInUser?.userId,
3456
3233
  note: "Delegation will have authorization.type='password' to match tool protection",
3457
3234
  });
3458
- // The userDid is passed via redirect URL - no need to read from KV
3459
3235
  }
3460
- // ✅ Check for OAuth auth parameters passed from OAuth callback
3461
- // These indicate this is the clickwrap step AFTER OAuth authentication
3462
- const oauthProviderType = bodyObj.oauth_provider_type;
3463
- const oauthProvider = bodyObj.oauth_provider;
3464
- if (oauthProviderType === "oauth") {
3236
+ // ✅ Clickwrap step AFTER OAuth authentication: provider_type 'oauth'
3237
+ // or 'oauth2', or the older 'none' plus oauth_provider_type='oauth'
3238
+ if (isPostOAuthClickwrap) {
3239
+ // The callback signs the provider along with the grant. Refuse
3240
+ // missing, malformed, or conflicting labels before recording auth
3241
+ // metadata; no caller-supplied label may change the signed provider.
3242
+ const oauthProvider = oauthProfileString(bodyObj.oauth_provider) ??
3243
+ oauthProfileString(bodyObj.provider);
3244
+ if (!oauthProvider ||
3245
+ (bodyObj.oauth_provider !== undefined &&
3246
+ bodyObj.oauth_provider !== oauthProvider) ||
3247
+ (bodyObj.provider !== undefined &&
3248
+ bodyObj.provider !== oauthProvider)) {
3249
+ return this.identityAssertionRequiredResponse();
3250
+ }
3465
3251
  // This is the clickwrap step after OAuth auth
3466
3252
  // The session already has userDid from OAuth callback
3467
3253
  logger.debug("[ConsentService] Consent-only mode (post-OAuth clickwrap)");
3468
- // Override provider_type to 'oauth' so delegation is created with correct authorization
3469
- // This ensures authorization.type matches tool protection expectation
3470
- bodyObj.provider_type = "oauth";
3471
- bodyObj.customFields = {
3472
- ...(bodyObj.customFields || {}),
3473
- provider: oauthProvider || bodyObj.provider,
3474
- provider_type: "oauth",
3475
- };
3476
- logger.debug("[ConsentService] ✅ Using OAuth auth provider_type for delegation:", {
3477
- sessionId: sessionId?.substring(0, 20) + "...",
3478
- providerType: "oauth",
3479
- provider: oauthProvider || bodyObj.provider,
3480
- note: "Delegation will have authorization.type='oauth' to match tool protection",
3481
- });
3482
3254
  // SECURITY (A3, spec §3.2): as with the credential clickwrap, the
3483
3255
  // userDid for a post-OAuth clickwrap mint comes ONLY from the
3484
3256
  // server-signed identity assertion minted by the OAuth callback
3485
3257
  // (oauth-handler.ts) after it linked the OAuth identity to the user
3486
3258
  // DID — bound to this sid AND grant. No valid assertion → 403, no
3487
3259
  // mint. The bare `user_did` redirect param is no longer trusted here.
3260
+ let signedProvider;
3488
3261
  if (this.isDevConsentTestMode()) {
3489
3262
  trustedUserDid = bodyObj.user_did;
3263
+ signedProvider =
3264
+ this.optionalString(bodyObj.oauth_provider) ??
3265
+ this.optionalString(bodyObj.provider);
3490
3266
  }
3491
3267
  else {
3492
3268
  const assertion = bodyObj.identity_assertion;
@@ -3497,6 +3273,7 @@ export class ConsentService {
3497
3273
  tool: bodyObj.tool ?? "",
3498
3274
  scopes: this.parseScopes(bodyObj.scopes),
3499
3275
  projectId: bodyObj.project_id ?? "",
3276
+ provider: oauthProvider,
3500
3277
  })
3501
3278
  : null;
3502
3279
  if (!verifiedUserDid) {
@@ -3504,11 +3281,39 @@ export class ConsentService {
3504
3281
  return this.identityAssertionRequiredResponse();
3505
3282
  }
3506
3283
  trustedUserDid = verifiedUserDid;
3284
+ signedProvider = this.readAssertionProvider(assertion);
3507
3285
  }
3286
+ // Override provider_type to 'oauth' so delegation is created with correct authorization
3287
+ // This ensures authorization.type matches tool protection expectation.
3288
+ // The provider label is the one the OAuth callback signed.
3289
+ bodyObj.provider_type = "oauth";
3290
+ bodyObj.customFields = {
3291
+ ...bodyObj.customFields,
3292
+ // Checkpoint prioritizes oauth_provider over provider. Both
3293
+ // reserved labels must describe the verified assertion.
3294
+ ...(signedProvider
3295
+ ? { provider: signedProvider, oauth_provider: signedProvider }
3296
+ : {}),
3297
+ provider_type: "oauth",
3298
+ };
3299
+ // Display fields come from the session record the OAuth callback
3300
+ // wrote for this same user, never from the page.
3301
+ const oauthUser = await this.readSessionOAuthUser(sessionId, trustedUserDid);
3302
+ if (oauthUser?.email) {
3303
+ bodyObj.credential_user_email = oauthUser.email;
3304
+ }
3305
+ if (oauthUser?.subject) {
3306
+ bodyObj.credential_provider_user_id = oauthUser.subject;
3307
+ }
3308
+ logger.debug("[ConsentService] ✅ Using OAuth auth provider_type for delegation:", {
3309
+ sessionId: sessionId?.substring(0, 20) + "...",
3310
+ providerType: "oauth",
3311
+ provider: signedProvider,
3312
+ note: "Delegation will have authorization.type='oauth' to match tool protection",
3313
+ });
3508
3314
  }
3509
3315
  // If neither credential nor OAuth, this is pure consent-only
3510
- if (!(credentialProviderType === CONSENT_PROVIDER_TYPES.PASSWORD ||
3511
- oauthProviderType === "oauth")) {
3316
+ if (!(isPostCredentialClickwrap || isPostOAuthClickwrap)) {
3512
3317
  // Pure consent-only mode - no prior authentication
3513
3318
  logger.debug("[ConsentService] Consent-only mode detected (pure clickwrap)");
3514
3319
  // SECURITY (A3, spec §3.2, review #5): a pure consent-only mint is
@@ -3520,25 +3325,36 @@ export class ConsentService {
3520
3325
  // body value intact — can carry a forged DID into the mint.
3521
3326
  trustedUserDid = undefined;
3522
3327
  if (sessionId && this.env.DELEGATION_STORAGE) {
3523
- // Check if session already has userDid (from credential auth or OAuth)
3524
- let existingUserDid;
3328
+ let existingSession = null;
3525
3329
  try {
3526
3330
  const sessionKey = STORAGE_KEYS.session(sessionId);
3527
- const existingSession = (await this.env.DELEGATION_STORAGE.get(sessionKey, "json"));
3528
- existingUserDid = existingSession?.userDid;
3331
+ existingSession = (await this.env.DELEGATION_STORAGE.get(sessionKey, "json"));
3529
3332
  }
3530
3333
  catch (error) {
3531
3334
  logger.warn("[ConsentService] Failed to read session for userDid:", error);
3532
3335
  }
3533
- if (existingUserDid) {
3534
- // Session has userDid from prior auth step - use it
3535
- logger.debug("[ConsentService] Session already has userDid, reusing:", {
3336
+ const throwawayDid = this.sessionThrowawayDid(existingSession);
3337
+ if (throwawayDid) {
3338
+ // The session's own throwaway identity from an earlier
3339
+ // consent-only step: reuse it so the agent's lookups stay on it.
3340
+ logger.debug("[ConsentService] Session already has a throwaway userDid, reusing:", {
3536
3341
  sessionId: sessionId.substring(0, 20) + "...",
3537
- userDid: existingUserDid.substring(0, 30) + "...",
3342
+ userDid: throwawayDid.substring(0, 30) + "...",
3538
3343
  });
3539
- // Server-side session identity (set by a prior auth step), not a
3540
- // caller-supplied body value — safe to trust.
3541
- trustedUserDid = existingUserDid;
3344
+ trustedUserDid = throwawayDid;
3345
+ }
3346
+ else if (existingSession?.userDid) {
3347
+ // SECURITY: the session holds a signed-in user's DID (written
3348
+ // by the OAuth callback or the credential handler). A
3349
+ // consent-only approval proves no sign-in, so it must never be
3350
+ // minted under that DID. Use a fresh throwaway identity for this
3351
+ // grant only and leave the session's identity alone.
3352
+ const fresh = await this.createThrowawayIdentity();
3353
+ if (fresh) {
3354
+ trustedUserDid = fresh.did;
3355
+ consentOnlySigningKey = fresh;
3356
+ keepSessionUserDid = true;
3357
+ }
3542
3358
  }
3543
3359
  else {
3544
3360
  // Generate ephemeral userDid for pure consent-only
@@ -3559,9 +3375,16 @@ export class ConsentService {
3559
3375
  });
3560
3376
  }
3561
3377
  else {
3562
- logger.warn("[ConsentService] ⚠️ Failed to generate ephemeral userDid - delegation will use session-scoped storage only");
3378
+ logger.warn("[ConsentService] ⚠️ Failed to generate ephemeral userDid");
3563
3379
  }
3564
3380
  }
3381
+ // SECURITY: fail closed. With no DID here, storeDelegationToken
3382
+ // resolves one from the session record, which can be a signed-in
3383
+ // user's DID, and the consent-only grant would replace their own
3384
+ // grant under it.
3385
+ if (!trustedUserDid) {
3386
+ return this.identityUnavailableResponse();
3387
+ }
3565
3388
  }
3566
3389
  else {
3567
3390
  logger.warn("[ConsentService] ⚠️ No session_id for consent-only flow - delegation will use session-scoped storage only");
@@ -3578,18 +3401,14 @@ export class ConsentService {
3578
3401
  // point that guarantees no downstream consumer (validation,
3579
3402
  // createDelegation, storeDelegationToken) ever reads a caller-forged DID.
3580
3403
  bodyObj.user_did = trustedUserDid;
3581
- // Convert null oauth_identity to undefined for proper schema validation
3582
- // Zod's .nullish() should handle null, but converting to undefined is more explicit
3583
- // and avoids potential edge cases with FormData parsing
3584
- if (body &&
3585
- typeof body === "object" &&
3586
- body !== null &&
3587
- "oauth_identity" in body) {
3588
- const bodyObj = body;
3589
- if (bodyObj.oauth_identity === null) {
3590
- bodyObj.oauth_identity = undefined;
3591
- }
3592
- }
3404
+ // SECURITY: the delegation's authorization type is the check the server
3405
+ // actually ran, never the caller's label. Checkpoint and the stored
3406
+ // auth-type sidecar both read it from here.
3407
+ bodyObj.provider_type = isPostOAuthClickwrap
3408
+ ? "oauth"
3409
+ : isPostCredentialClickwrap
3410
+ ? CONSENT_PROVIDER_TYPES.PASSWORD
3411
+ : CONSENT_PROVIDER_TYPES.NONE;
3593
3412
  const validation = validateConsentApprovalRequest(body);
3594
3413
  if (!validation.success) {
3595
3414
  logger.error("[ConsentService] Approval request validation failed:", {
@@ -3645,10 +3464,17 @@ export class ConsentService {
3645
3464
  // session lookup). The resolved value is threaded into createDelegation so
3646
3465
  // it is never resolved a second time.
3647
3466
  let resolvedUserDid = approvalRequest.user_did;
3648
- if (!resolvedUserDid && approvalRequest.session_id) {
3467
+ // Never for consent-only: the session lookup can return a signed-in
3468
+ // user's DID, and a consent-only approval proves no sign-in. One with a
3469
+ // stored session has a throwaway DID by now or was refused above.
3470
+ if (!resolvedUserDid &&
3471
+ approvalRequest.session_id &&
3472
+ !isConsentOnlyMode) {
3649
3473
  try {
3474
+ // No OAuth identity: a caller-supplied one would select another
3475
+ // user's stored DID (see getUserDidForSession's first lookup).
3650
3476
  resolvedUserDid =
3651
- (await this.getUserDidForSession(approvalRequest.session_id, approvalRequest.oauth_identity || undefined)) ?? undefined;
3477
+ (await this.getUserDidForSession(approvalRequest.session_id, undefined)) ?? undefined;
3652
3478
  }
3653
3479
  catch (error) {
3654
3480
  logger.debug("[ConsentService] Failed to resolve userDid ahead of identity gate:", error);
@@ -3690,7 +3516,7 @@ export class ConsentService {
3690
3516
  }
3691
3517
  // Create delegation via Checkpoint API
3692
3518
  logger.debug("[ConsentService] Creating delegation...");
3693
- const delegationResult = await this.createDelegation(approvalRequest, resolveExpirationDays(consentConfig.expirationDays), resolvedUserDid ?? null);
3519
+ const delegationResult = await this.createDelegation(approvalRequest, resolveExpirationDays(consentConfig.expirationDays), resolvedUserDid ?? null, consentOnlySigningKey);
3694
3520
  if (!delegationResult.success) {
3695
3521
  logger.error("[ConsentService] Delegation creation failed:", {
3696
3522
  error: delegationResult.error,
@@ -3723,7 +3549,7 @@ export class ConsentService {
3723
3549
  // match the tool's authorization.type; 'credential'/'password' form
3724
3550
  // submissions were routed to handleCredentialApproval earlier), so it
3725
3551
  // maps cleanly through normalizeAuthType — no 'credential' mis-fold here.
3726
- effectiveProviderType);
3552
+ effectiveProviderType, keepSessionUserDid);
3727
3553
  // ✅ After successful delegation creation - log audit events
3728
3554
  if (auditService && delegationResult.success) {
3729
3555
  try {
@@ -3791,9 +3617,12 @@ export class ConsentService {
3791
3617
  * Create delegation via Checkpoint API
3792
3618
  *
3793
3619
  * @param request - Approval request
3620
+ * @param signingKeyPair - Key that signs the delegation VC when the user DID
3621
+ * is a throwaway identity not stored on the session (consent-only in a
3622
+ * signed-in session). Otherwise the session's own key is used.
3794
3623
  * @returns Delegation creation result
3795
3624
  */
3796
- async createDelegation(request, expirationDaysOverride, resolvedUserDid) {
3625
+ async createDelegation(request, expirationDaysOverride, resolvedUserDid, signingKeyPair) {
3797
3626
  // Resolve the mandate from the APPROVED scopes first: nothing (no VC, no
3798
3627
  // POST) may happen when a configured provider cannot answer.
3799
3628
  const mandateResolution = await this.resolveMandate(request.scopes ?? [], request.project_id);
@@ -3840,12 +3669,13 @@ export class ConsentService {
3840
3669
  oauthProvider: request.oauth_identity?.provider,
3841
3670
  hasStorage: !!this.env.DELEGATION_STORAGE,
3842
3671
  });
3843
- // Pass OAuth identity if available in approval request (can be null/undefined)
3672
+ // SECURITY: never the request's oauth_identity. It is caller
3673
+ // input, and getUserDidForSession would return the DID stored for
3674
+ // whichever OAuth identity it names.
3844
3675
  // getUserDidForSession can work without DELEGATION_STORAGE (uses in-memory UserDidManager)
3845
3676
  // Phase 5: Returns null if no identity found (session stays anonymous)
3846
3677
  userDid =
3847
- (await this.getUserDidForSession(request.session_id, request.oauth_identity || undefined // Explicitly handle null as undefined
3848
- )) ?? undefined;
3678
+ (await this.getUserDidForSession(request.session_id, undefined)) ?? undefined;
3849
3679
  logger.debug("[ConsentService] User DID retrieved from storage:", {
3850
3680
  userDid: userDid?.substring(0, 20) + "...",
3851
3681
  hasUserDid: !!userDid,
@@ -3901,7 +3731,10 @@ export class ConsentService {
3901
3731
  status: "active",
3902
3732
  createdAt: Date.now(), // milliseconds per schema
3903
3733
  };
3904
- const vcResult = await this.issueDelegationVC(localDelegation, request.session_id, request.oauth_identity || undefined);
3734
+ // SECURITY: no OAuth identity from the request. It would pick the
3735
+ // signing key stored for that identity, i.e. sign as another user.
3736
+ // The key comes from this session's own server-side record.
3737
+ const vcResult = await this.issueDelegationVC(localDelegation, request.session_id, undefined, signingKeyPair);
3905
3738
  credentialJwt = vcResult ?? undefined;
3906
3739
  if (credentialJwt) {
3907
3740
  logger.debug("[ConsentService] VC issued for delegation:", {
@@ -3944,7 +3777,7 @@ export class ConsentService {
3944
3777
  //
3945
3778
  // IMPORTANT: delegation_token is NOT returned by POST /api/v1/bouncer/delegations.
3946
3779
  // delegation_token is only available via OAuth callback flow and is passed as a URL parameter.
3947
- // See: docs/agentshield/API_RESPONSE_FORMATS.md for canonical format documentation.
3780
+ // See: docs/checkpoint/API_RESPONSE_FORMATS.md for canonical format documentation.
3948
3781
  // Try wrapped format first (canonical)
3949
3782
  const wrappedParse = createDelegationAPIResponseSchema.safeParse(responseData);
3950
3783
  if (wrappedParse.success) {
@@ -4114,8 +3947,12 @@ export class ConsentService {
4114
3947
  * persisting so the call-time enforcement gate (Task B2/B3) reads a
4115
3948
  * consistent value ("password" | "oauth2" | "none" | …). Undefined → the
4116
3949
  * field is omitted and the gate fails safe (never satisfies a typed tool).
3950
+ * @param keepSessionUserDid - Keep the session record's existing userDid
3951
+ * instead of overwriting it with this delegation's: set when a
3952
+ * consent-only grant in a signed-in session was minted under a fresh
3953
+ * throwaway DID, so the session keeps its signed-in identity.
4117
3954
  */
4118
- async storeDelegationToken(sessionId, agentDid, token, delegationId, providedUserDid, serverOrigin, authorizationType) {
3955
+ async storeDelegationToken(sessionId, agentDid, token, delegationId, providedUserDid, serverOrigin, authorizationType, keepSessionUserDid = false) {
4119
3956
  const delegationStorage = this.env.DELEGATION_STORAGE;
4120
3957
  // Task B2: normalize once (folds deprecated aliases: oauth→oauth2) so every
4121
3958
  // stored record and every read compares in the same vocabulary.
@@ -4217,7 +4054,9 @@ export class ConsentService {
4217
4054
  const existingData = (await delegationStorage.get(sessionKey, "json"));
4218
4055
  await delegationStorage.put(sessionKey, JSON.stringify({
4219
4056
  ...existingData,
4220
- userDid,
4057
+ userDid: keepSessionUserDid
4058
+ ? (existingData?.userDid ?? userDid)
4059
+ : userDid,
4221
4060
  agentDid,
4222
4061
  delegationToken: token,
4223
4062
  delegationId,
@@ -4646,6 +4485,9 @@ export class ConsentService {
4646
4485
  if (Array.isArray(scopes) && scopes.length > 0) {
4647
4486
  clickwrapUrl.searchParams.set("scopes", scopes.join(","));
4648
4487
  }
4488
+ // The clickwrap approval reads these display fields from server storage;
4489
+ // the copies in the URL below are for the page only.
4490
+ await this.storeCredentialClickwrapUser(session_id, identityResult.userDid, authResult.userEmail, authResult.userId);
4649
4491
  // Include user info for delegation request
4650
4492
  // Field mapping (clear naming):
4651
4493
  // credential_user_email → user_identifier (human-readable for display)
@@ -5416,6 +5258,8 @@ export class ConsentService {
5416
5258
  const customFieldsFromRequest = request.customFields || {};
5417
5259
  if (userDid || fullFormatUserEmail || providerType !== "none") {
5418
5260
  result.custom_fields = {
5261
+ // Caller fields first, so every server-set field below wins
5262
+ ...customFieldsFromRequest,
5419
5263
  ...(userDid
5420
5264
  ? {
5421
5265
  issuer_did: userDid,
@@ -5432,8 +5276,6 @@ export class ConsentService {
5432
5276
  ...(fullFormatProviderUserId
5433
5277
  ? { user_id: fullFormatProviderUserId }
5434
5278
  : {}),
5435
- // Merge with any existing custom_fields from request
5436
- ...customFieldsFromRequest,
5437
5279
  };
5438
5280
  if (fullFormatUserEmail || fullFormatProviderUserId) {
5439
5281
  logger.debug("[ConsentService] Full format: Including user info in custom_fields for Checkpoint compatibility:", {
@@ -5571,6 +5413,8 @@ export class ConsentService {
5571
5413
  // - oauth_provider present → authorization.type = 'oauth'
5572
5414
  // NOTE: Tool protection expects 'password' for credential flows, NOT 'credential'
5573
5415
  simplifiedRequest.custom_fields = {
5416
+ // Caller fields first, so every server-set field below wins
5417
+ ...customFieldsFromRequest,
5574
5418
  ...(userDid
5575
5419
  ? {
5576
5420
  issuer_did: userDid,
@@ -5586,8 +5430,6 @@ export class ConsentService {
5586
5430
  // user_id = provider's internal ID (for business reference, e.g., customer ID 696395)
5587
5431
  ...(userEmail ? { user_email: userEmail } : {}),
5588
5432
  ...(providerUserId ? { user_id: providerUserId } : {}),
5589
- // Merge with any existing custom_fields from request
5590
- ...customFieldsFromRequest,
5591
5433
  };
5592
5434
  if (hasProviderInfo) {
5593
5435
  logger.debug("[ConsentService] Including provider info in custom_fields:", {