@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.
- package/README.md +37 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +81 -51
- package/dist/agent.js.map +1 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +9 -12
- package/dist/app.js.map +1 -1
- package/dist/audit/deployment-binding.d.ts +12 -12
- package/dist/config.d.ts +28 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +16 -1
- package/dist/config.js.map +1 -1
- package/dist/constants/storage-keys.d.ts +9 -0
- package/dist/constants/storage-keys.d.ts.map +1 -1
- package/dist/constants/storage-keys.js +9 -0
- package/dist/constants/storage-keys.js.map +1 -1
- package/dist/constants.d.ts +6 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +6 -0
- package/dist/constants.js.map +1 -1
- package/dist/delegation-http/bearer-retry.d.ts +20 -4
- package/dist/delegation-http/bearer-retry.d.ts.map +1 -1
- package/dist/delegation-http/bearer-retry.js +56 -10
- package/dist/delegation-http/bearer-retry.js.map +1 -1
- package/dist/delegation-http/consent-anchor-bridge.d.ts.map +1 -1
- package/dist/delegation-http/consent-anchor-bridge.js +72 -13
- package/dist/delegation-http/consent-anchor-bridge.js.map +1 -1
- package/dist/delegation-http/native-oauth-state.d.ts +16 -0
- package/dist/delegation-http/native-oauth-state.d.ts.map +1 -1
- package/dist/delegation-http/native-oauth-state.js +24 -9
- package/dist/delegation-http/native-oauth-state.js.map +1 -1
- package/dist/delegation-http/native-oauth.d.ts.map +1 -1
- package/dist/delegation-http/native-oauth.js +26 -7
- package/dist/delegation-http/native-oauth.js.map +1 -1
- package/dist/delegation-http/oauth-recovery.d.ts +3 -0
- package/dist/delegation-http/oauth-recovery.d.ts.map +1 -0
- package/dist/delegation-http/oauth-recovery.js +39 -0
- package/dist/delegation-http/oauth-recovery.js.map +1 -0
- package/dist/delegation-http/remote-issuer.d.ts +15 -0
- package/dist/delegation-http/remote-issuer.d.ts.map +1 -1
- package/dist/delegation-http/remote-issuer.js +27 -8
- package/dist/delegation-http/remote-issuer.js.map +1 -1
- package/dist/helpers/env-mapper.d.ts.map +1 -1
- package/dist/helpers/env-mapper.js +28 -6
- package/dist/helpers/env-mapper.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/proof-generator.d.ts +8 -7
- package/dist/proof-generator.d.ts.map +1 -1
- package/dist/proof-generator.js +32 -34
- package/dist/proof-generator.js.map +1 -1
- package/dist/runtime/oauth-handler.d.ts.map +1 -1
- package/dist/runtime/oauth-handler.js +43 -31
- package/dist/runtime/oauth-handler.js.map +1 -1
- package/dist/runtime/tool-activity.d.ts +31 -4
- package/dist/runtime/tool-activity.d.ts.map +1 -1
- package/dist/runtime/tool-activity.js +48 -0
- package/dist/runtime/tool-activity.js.map +1 -1
- package/dist/runtime.d.ts +31 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +65 -0
- package/dist/runtime.js.map +1 -1
- package/dist/security/admission-refusal.d.ts +32 -0
- package/dist/security/admission-refusal.d.ts.map +1 -0
- package/dist/security/admission-refusal.js +97 -0
- package/dist/security/admission-refusal.js.map +1 -0
- package/dist/security/card-proof-support.d.ts +66 -0
- package/dist/security/card-proof-support.d.ts.map +1 -0
- package/dist/security/card-proof-support.js +121 -0
- package/dist/security/card-proof-support.js.map +1 -0
- package/dist/security/mcp-admission.d.ts +28 -2
- package/dist/security/mcp-admission.d.ts.map +1 -1
- package/dist/security/mcp-admission.js +129 -29
- package/dist/security/mcp-admission.js.map +1 -1
- package/dist/security/mcp-holder-admission.d.ts +12 -1
- package/dist/security/mcp-holder-admission.d.ts.map +1 -1
- package/dist/security/mcp-holder-admission.js +46 -8
- package/dist/security/mcp-holder-admission.js.map +1 -1
- package/dist/security/presented-delegation.d.ts +10 -0
- package/dist/security/presented-delegation.d.ts.map +1 -1
- package/dist/security/presented-delegation.js +23 -3
- package/dist/security/presented-delegation.js.map +1 -1
- package/dist/services/admin.service.d.ts.map +1 -1
- package/dist/services/admin.service.js +24 -9
- package/dist/services/admin.service.js.map +1 -1
- package/dist/services/approval-route.d.ts +48 -0
- package/dist/services/approval-route.d.ts.map +1 -0
- package/dist/services/approval-route.js +87 -0
- package/dist/services/approval-route.js.map +1 -0
- package/dist/services/consent-config.service.d.ts.map +1 -1
- package/dist/services/consent-config.service.js +20 -5
- package/dist/services/consent-config.service.js.map +1 -1
- package/dist/services/consent.service.d.ts +113 -5
- package/dist/services/consent.service.d.ts.map +1 -1
- package/dist/services/consent.service.js +746 -904
- package/dist/services/consent.service.js.map +1 -1
- package/dist/services/credential-auth.handler.d.ts +2 -0
- package/dist/services/credential-auth.handler.d.ts.map +1 -1
- package/dist/services/credential-auth.handler.js +19 -3
- package/dist/services/credential-auth.handler.js.map +1 -1
- package/dist/services/delegation-trust.d.ts +97 -0
- package/dist/services/delegation-trust.d.ts.map +1 -0
- package/dist/services/delegation-trust.js +378 -0
- package/dist/services/delegation-trust.js.map +1 -0
- package/dist/services/delegation.service.d.ts +56 -14
- package/dist/services/delegation.service.d.ts.map +1 -1
- package/dist/services/delegation.service.js +160 -68
- package/dist/services/delegation.service.js.map +1 -1
- package/dist/services/multipart-parser.d.ts +73 -0
- package/dist/services/multipart-parser.d.ts.map +1 -0
- package/dist/services/multipart-parser.js +116 -0
- package/dist/services/multipart-parser.js.map +1 -0
- package/dist/services/proof.service.d.ts +38 -0
- package/dist/services/proof.service.d.ts.map +1 -1
- package/dist/services/proof.service.js +41 -2
- package/dist/services/proof.service.js.map +1 -1
- package/dist/types.d.ts +17 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/checkpoint-project-id.d.ts +31 -0
- package/dist/utils/checkpoint-project-id.d.ts.map +1 -0
- package/dist/utils/checkpoint-project-id.js +35 -0
- package/dist/utils/checkpoint-project-id.js.map +1 -0
- package/dist/utils/oauth-profile.d.ts +7 -0
- package/dist/utils/oauth-profile.d.ts.map +1 -0
- package/dist/utils/oauth-profile.js +24 -0
- package/dist/utils/oauth-profile.js.map +1 -0
- 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
|
-
|
|
683
|
-
|
|
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:
|
|
686
|
-
hasKeyPair: !!existingSession.keyPair,
|
|
809
|
+
userDid: throwawayDid.substring(0, 20) + "...",
|
|
687
810
|
});
|
|
688
|
-
return
|
|
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
|
-
|
|
699
|
-
const
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
|
933
|
-
*
|
|
934
|
-
* - Project has
|
|
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
|
|
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
|
-
//
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
//
|
|
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
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
}
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
const
|
|
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
|
-
|
|
1031
|
-
//
|
|
1032
|
-
|
|
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
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
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
|
-
|
|
2249
|
-
//
|
|
2250
|
-
|
|
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
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
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
|
-
|
|
2268
|
-
//
|
|
2269
|
-
|
|
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
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
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
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
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
|
-
|
|
2378
|
-
if (
|
|
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
|
-
|
|
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
|
|
2480
|
-
|
|
2481
|
-
|
|
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,
|
|
2563
|
-
//
|
|
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
|
-
|
|
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
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
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
|
-
|
|
2718
|
-
if (
|
|
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
|
-
|
|
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
|
|
2817
|
-
|
|
2818
|
-
|
|
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
|
|
2911
|
-
|
|
2912
|
-
|
|
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
|
|
2995
|
-
|
|
2996
|
-
|
|
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
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
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
|
-
|
|
3086
|
-
|
|
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
|
|
3144
|
-
|
|
3145
|
-
|
|
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
|
|
3228
|
-
|
|
3229
|
-
|
|
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':
|
|
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
|
|
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 (
|
|
3365
|
-
|
|
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
|
-
//
|
|
3372
|
-
//
|
|
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 (
|
|
3377
|
-
const bodyObj = body;
|
|
3164
|
+
if (route === "clickwrap" || route === "oauth") {
|
|
3378
3165
|
const sessionId = bodyObj.session_id;
|
|
3379
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
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:
|
|
3229
|
+
provider: signedProvider,
|
|
3453
3230
|
hasUserDid: !!trustedUserDid,
|
|
3454
|
-
hasCredentialUserEmail: !!
|
|
3455
|
-
hasProviderUserId: !!
|
|
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
|
-
// ✅
|
|
3461
|
-
//
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
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 (!(
|
|
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
|
-
|
|
3524
|
-
let existingUserDid;
|
|
3328
|
+
let existingSession = null;
|
|
3525
3329
|
try {
|
|
3526
3330
|
const sessionKey = STORAGE_KEYS.session(sessionId);
|
|
3527
|
-
|
|
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
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
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:
|
|
3342
|
+
userDid: throwawayDid.substring(0, 30) + "...",
|
|
3538
3343
|
});
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
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
|
|
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
|
-
//
|
|
3582
|
-
//
|
|
3583
|
-
//
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
|
|
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/
|
|
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:", {
|