@kya-os/mcp-i-cloudflare 1.13.0 → 1.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent.d.ts +7 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +43 -7
- package/dist/agent.js.map +1 -1
- package/dist/helpers/env-mapper.d.ts.map +1 -1
- package/dist/helpers/env-mapper.js +6 -0
- package/dist/helpers/env-mapper.js.map +1 -1
- package/dist/runtime/oauth-handler.d.ts.map +1 -1
- package/dist/runtime/oauth-handler.js +71 -5
- package/dist/runtime/oauth-handler.js.map +1 -1
- package/dist/runtime/oidc/idp-scopes.d.ts +26 -0
- package/dist/runtime/oidc/idp-scopes.d.ts.map +1 -0
- package/dist/runtime/oidc/idp-scopes.js +24 -0
- package/dist/runtime/oidc/idp-scopes.js.map +1 -0
- package/dist/services/batch-queue.d.ts +103 -0
- package/dist/services/batch-queue.d.ts.map +1 -0
- package/dist/services/batch-queue.js +180 -0
- package/dist/services/batch-queue.js.map +1 -0
- package/dist/services/consent.service.d.ts +124 -0
- package/dist/services/consent.service.d.ts.map +1 -1
- package/dist/services/consent.service.js +542 -101
- package/dist/services/consent.service.js.map +1 -1
- package/dist/services/kya-os-events.service.d.ts +131 -0
- package/dist/services/kya-os-events.service.d.ts.map +1 -0
- package/dist/services/kya-os-events.service.js +135 -0
- package/dist/services/kya-os-events.service.js.map +1 -0
- package/dist/services/proof-batch-queue.d.ts +15 -67
- package/dist/services/proof-batch-queue.d.ts.map +1 -1
- package/dist/services/proof-batch-queue.js +12 -147
- package/dist/services/proof-batch-queue.js.map +1 -1
- package/dist/services/proof.service.d.ts +7 -0
- package/dist/services/proof.service.d.ts.map +1 -1
- package/dist/services/proof.service.js +35 -0
- package/dist/services/proof.service.js.map +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -3
|
@@ -12,10 +12,11 @@ import { DEFAULT_AGENTSHIELD_URL, DEFAULT_SESSION_CACHE_TTL, KEY_PAIR_TTL_SECOND
|
|
|
12
12
|
import { STORAGE_KEYS } from "../constants/storage-keys";
|
|
13
13
|
import { loadDay0Config, getDelegationFieldName } from "../utils/day0-config";
|
|
14
14
|
import { buildProviderAuthorizeUrl } from "../runtime/oidc/authorize-url.js";
|
|
15
|
+
import { resolveIdpScopes } from "../runtime/oidc/idp-scopes.js";
|
|
15
16
|
import { validateConsentApprovalRequest, } from "@kya-os/contracts/consent";
|
|
16
17
|
import { AGENTSHIELD_ENDPOINTS, createDelegationAPIResponseSchema, createDelegationResponseSchema, } from "@kya-os/contracts/agentshield-api";
|
|
17
18
|
import { createUnsignedVCJWT, completeVCJWT, parseVCJWT, generateDidKeyFromBase64, createDelegationVerifier, createDidKeyResolver, logger, base64urlEncodeFromBytes, base64urlDecodeToBytes, bytesToBase64, wrapDelegationAsVC, } from "@kya-os/mcp";
|
|
18
|
-
import { fetchRemoteConfig, } from "@kya-os/mcp-i-runtime"; // product-layer symbols stay on OLD (C2 #2918)
|
|
19
|
+
import { fetchRemoteConfig, normalizeAuthType, } from "@kya-os/mcp-i-runtime"; // product-layer symbols stay on OLD (C2 #2918)
|
|
19
20
|
import { WebCryptoProvider } from "../providers/crypto";
|
|
20
21
|
import { ConsentAuditService } from "./consent-audit.service";
|
|
21
22
|
import { CloudflareProofGenerator } from "../proof-generator";
|
|
@@ -145,7 +146,7 @@ export class ConsentService {
|
|
|
145
146
|
* @returns true if DO storage succeeded (strongly consistent), false if fell back to KV only
|
|
146
147
|
*/
|
|
147
148
|
async storeDelegationForOAuth(params, serverOrigin) {
|
|
148
|
-
return this.storeDelegationToken(params.sessionId, params.agentDid, params.delegationToken, params.delegationId, params.userDid, serverOrigin);
|
|
149
|
+
return this.storeDelegationToken(params.sessionId, params.agentDid, params.delegationToken, params.delegationId, params.userDid, serverOrigin, params.authorizationType);
|
|
149
150
|
}
|
|
150
151
|
/**
|
|
151
152
|
* Initialize audit service - fetches config from remote API
|
|
@@ -1154,13 +1155,19 @@ export class ConsentService {
|
|
|
1154
1155
|
!providerConfig.proxyMode) {
|
|
1155
1156
|
// Use providerConfig.clientId from AgentShield dashboard config
|
|
1156
1157
|
const oauthClientId = providerConfig.clientId || projectId;
|
|
1158
|
+
// The `scopes` argument holds MCP-I delegation scopes (e.g. "greet:execute"),
|
|
1159
|
+
// which the identity provider has never heard of. Only the provider's own
|
|
1160
|
+
// scopes belong in its authorization URL; delegation scopes already travel
|
|
1161
|
+
// separately in `stateData.scopes` above, for the callback to mint the
|
|
1162
|
+
// delegation.
|
|
1163
|
+
const idpScopes = resolveIdpScopes(providerConfig);
|
|
1157
1164
|
logger.debug("[ConsentService] Using direct OAuth mode (PKCE)", {
|
|
1158
1165
|
provider: provider || "unknown",
|
|
1159
1166
|
authorizationUrl: providerConfig.authorizationUrl,
|
|
1160
1167
|
supportsPKCE: providerConfig.supportsPKCE,
|
|
1161
1168
|
clientId: oauthClientId.substring(0, 8) + "...",
|
|
1162
1169
|
});
|
|
1163
|
-
return this.buildDirectOAuthUrl(providerConfig, oauthClientId, `${serverUrl}/oauth/callback`,
|
|
1170
|
+
return this.buildDirectOAuthUrl(providerConfig, oauthClientId, `${serverUrl}/oauth/callback`, idpScopes, stateParam, codeChallenge);
|
|
1164
1171
|
}
|
|
1165
1172
|
// Phase 3: Validate custom parameters don't conflict with reserved parameters
|
|
1166
1173
|
const RESERVED_PARAMS = [
|
|
@@ -1195,7 +1202,9 @@ export class ConsentService {
|
|
|
1195
1202
|
oauthUrl.searchParams.set("response_type", "code");
|
|
1196
1203
|
oauthUrl.searchParams.set("client_id", projectId); // Use projectId as client_id
|
|
1197
1204
|
oauthUrl.searchParams.set("redirect_uri", `${serverUrl}/oauth/callback`);
|
|
1198
|
-
|
|
1205
|
+
// Same separation as the direct-PKCE branch above: `scopes` is the MCP-I
|
|
1206
|
+
// delegation scopes, not what belongs in the IdP-facing `scope` param.
|
|
1207
|
+
oauthUrl.searchParams.set("scope", resolveIdpScopes(providerConfig).join(" "));
|
|
1199
1208
|
oauthUrl.searchParams.set("state", stateParam);
|
|
1200
1209
|
// ✅ Pass provider to AgentShield bouncer so it selects the correct auth method
|
|
1201
1210
|
// This is critical when multiple providers are configured for a project
|
|
@@ -2121,6 +2130,10 @@ export class ConsentService {
|
|
|
2121
2130
|
headers: {
|
|
2122
2131
|
"Content-Type": "text/html; charset=utf-8",
|
|
2123
2132
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
2133
|
+
// SECURITY (A2, spec §3.1): the identity assertion is delivered to
|
|
2134
|
+
// this page in the URL fragment; `no-referrer` guarantees the token
|
|
2135
|
+
// (and any query state) never leaks to a third party via `Referer`.
|
|
2136
|
+
"Referrer-Policy": "no-referrer",
|
|
2124
2137
|
},
|
|
2125
2138
|
});
|
|
2126
2139
|
}
|
|
@@ -2131,6 +2144,10 @@ export class ConsentService {
|
|
|
2131
2144
|
headers: {
|
|
2132
2145
|
"Content-Type": "text/html; charset=utf-8",
|
|
2133
2146
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
2147
|
+
// SECURITY (A2, spec §3.1): the identity assertion is delivered to
|
|
2148
|
+
// this page in the URL fragment; `no-referrer` guarantees the token
|
|
2149
|
+
// (and any query state) never leaks to a third party via `Referer`.
|
|
2150
|
+
"Referrer-Policy": "no-referrer",
|
|
2134
2151
|
},
|
|
2135
2152
|
});
|
|
2136
2153
|
}
|
|
@@ -3219,6 +3236,36 @@ export class ConsentService {
|
|
|
3219
3236
|
throw new Error(`Failed to parse request body: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
3220
3237
|
}
|
|
3221
3238
|
}
|
|
3239
|
+
/**
|
|
3240
|
+
* Interpret the `CONSENT_REQUIRE_IDENTITY` env override that gates the
|
|
3241
|
+
* identity-required refusal in `handleApproval`. Defaults to enabled - only
|
|
3242
|
+
* an explicit `"false"`/`"0"` disables it, mirroring the escape-hatch
|
|
3243
|
+
* convention used for `MCPI_HTTP_CHALLENGE` in config.ts.
|
|
3244
|
+
*/
|
|
3245
|
+
isIdentityRequired() {
|
|
3246
|
+
const raw = this.env.CONSENT_REQUIRE_IDENTITY;
|
|
3247
|
+
if (raw === undefined || raw === null)
|
|
3248
|
+
return true;
|
|
3249
|
+
const normalized = String(raw).trim().toLowerCase();
|
|
3250
|
+
return normalized !== "false" && normalized !== "0";
|
|
3251
|
+
}
|
|
3252
|
+
/**
|
|
3253
|
+
* 403 returned by `handleApproval` when a credential/OAuth clickwrap mint is
|
|
3254
|
+
* attempted without a valid, grant-bound identity assertion (A3, spec §3.2).
|
|
3255
|
+
* A distinct `error_code` from the plain `identity_required` gate so callers
|
|
3256
|
+
* and tests can tell "no server-signed proof of authentication" apart from
|
|
3257
|
+
* "no identity resolved at all".
|
|
3258
|
+
*/
|
|
3259
|
+
identityAssertionRequiredResponse() {
|
|
3260
|
+
return new Response(JSON.stringify({
|
|
3261
|
+
success: false,
|
|
3262
|
+
error: "A valid identity assertion is required to approve this request",
|
|
3263
|
+
error_code: "identity_assertion_required",
|
|
3264
|
+
}), {
|
|
3265
|
+
status: 403,
|
|
3266
|
+
headers: { "Content-Type": "application/json" },
|
|
3267
|
+
});
|
|
3268
|
+
}
|
|
3222
3269
|
/**
|
|
3223
3270
|
* Handle consent approval
|
|
3224
3271
|
*
|
|
@@ -3244,6 +3291,16 @@ export class ConsentService {
|
|
|
3244
3291
|
// - 'none': Consent-only mode (clickwrap) - user agrees without authentication
|
|
3245
3292
|
const bodyObj = body;
|
|
3246
3293
|
const providerType = bodyObj?.provider_type;
|
|
3294
|
+
// SECURITY (A3, spec §3.1/§3.2, defect #2): the identity a delegation is
|
|
3295
|
+
// minted under is authoritative ONLY from a trusted source resolved below
|
|
3296
|
+
// — a server-signed identity assertion (credential/OAuth clickwrap) or the
|
|
3297
|
+
// server-generated ephemeral / session-stored DID (pure consent-only).
|
|
3298
|
+
// A caller-supplied `user_did` body field is NEVER an identity source.
|
|
3299
|
+
// This holds the trusted value; it overwrites `bodyObj.user_did`
|
|
3300
|
+
// unconditionally before validation so no downstream consumer can read a
|
|
3301
|
+
// forged one. `undefined` for the plain authenticated (non-clickwrap)
|
|
3302
|
+
// shapes, which fall back to server-side session resolution.
|
|
3303
|
+
let trustedUserDid;
|
|
3247
3304
|
// CRED-003: Check for credential provider submission
|
|
3248
3305
|
// Credential submissions include `provider_type: 'credential'` or 'password' and are handled separately
|
|
3249
3306
|
// IMPORTANT: AgentShield tool protection uses 'password' for credential-based auth validation,
|
|
@@ -3282,18 +3339,36 @@ export class ConsentService {
|
|
|
3282
3339
|
provider_type: CONSENT_PROVIDER_TYPES.PASSWORD,
|
|
3283
3340
|
};
|
|
3284
3341
|
}
|
|
3285
|
-
//
|
|
3286
|
-
//
|
|
3287
|
-
//
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
//
|
|
3296
|
-
|
|
3342
|
+
// SECURITY (A3, spec §3.2): the userDid for a post-credential
|
|
3343
|
+
// clickwrap mint comes ONLY from the server-signed identity assertion
|
|
3344
|
+
// minted by handleCredentialApproval after it authenticated against
|
|
3345
|
+
// the provider — bound to this sid AND grant (agent/tool/scopes/
|
|
3346
|
+
// project). The bare `user_did` redirect param that used to be trusted
|
|
3347
|
+
// here is gone: it was caller-forgeable (subject/issuer forgery). A
|
|
3348
|
+
// missing/invalid/expired/mismatched assertion → 403, no mint.
|
|
3349
|
+
if (this.isDevConsentTestMode()) {
|
|
3350
|
+
// Dev/test escape hatch (BOTH MCPI_TEST_CONSENT and a development
|
|
3351
|
+
// env), mirroring consent-anchor-bridge's call-site CSRF relaxation:
|
|
3352
|
+
// no signed assertion is required, so local/miniflare runs work
|
|
3353
|
+
// without OAUTH_ENCRYPTION_SECRET. Never on prod/staging/unset.
|
|
3354
|
+
trustedUserDid = bodyObj.user_did;
|
|
3355
|
+
}
|
|
3356
|
+
else {
|
|
3357
|
+
const assertion = bodyObj.identity_assertion;
|
|
3358
|
+
const verifiedUserDid = typeof assertion === "string"
|
|
3359
|
+
? await this.verifyIdentityAssertion(assertion, {
|
|
3360
|
+
sid: sessionId,
|
|
3361
|
+
agentDid: bodyObj.agent_did ?? "",
|
|
3362
|
+
tool: bodyObj.tool ?? "",
|
|
3363
|
+
scopes: this.parseScopes(bodyObj.scopes),
|
|
3364
|
+
projectId: bodyObj.project_id ?? "",
|
|
3365
|
+
})
|
|
3366
|
+
: null;
|
|
3367
|
+
if (!verifiedUserDid) {
|
|
3368
|
+
logger.warn("[ConsentService] Post-credential clickwrap has no valid identity assertion — refusing to mint");
|
|
3369
|
+
return this.identityAssertionRequiredResponse();
|
|
3370
|
+
}
|
|
3371
|
+
trustedUserDid = verifiedUserDid;
|
|
3297
3372
|
}
|
|
3298
3373
|
// Read user info from redirect URL params
|
|
3299
3374
|
// Field mapping (clear naming):
|
|
@@ -3319,7 +3394,7 @@ export class ConsentService {
|
|
|
3319
3394
|
sessionId: sessionId?.substring(0, 20) + "...",
|
|
3320
3395
|
providerType: CONSENT_PROVIDER_TYPES.PASSWORD,
|
|
3321
3396
|
provider: credentialProvider,
|
|
3322
|
-
hasUserDid: !!
|
|
3397
|
+
hasUserDid: !!trustedUserDid,
|
|
3323
3398
|
hasCredentialUserEmail: !!credentialUserEmailFromRedirect,
|
|
3324
3399
|
hasProviderUserId: !!credentialProviderUserIdFromRedirect,
|
|
3325
3400
|
note: "Delegation will have authorization.type='password' to match tool protection",
|
|
@@ -3348,12 +3423,46 @@ export class ConsentService {
|
|
|
3348
3423
|
provider: oauthProvider || bodyObj.provider,
|
|
3349
3424
|
note: "Delegation will have authorization.type='oauth' to match tool protection",
|
|
3350
3425
|
});
|
|
3426
|
+
// SECURITY (A3, spec §3.2): as with the credential clickwrap, the
|
|
3427
|
+
// userDid for a post-OAuth clickwrap mint comes ONLY from the
|
|
3428
|
+
// server-signed identity assertion minted by the OAuth callback
|
|
3429
|
+
// (oauth-handler.ts) after it linked the OAuth identity to the user
|
|
3430
|
+
// DID — bound to this sid AND grant. No valid assertion → 403, no
|
|
3431
|
+
// mint. The bare `user_did` redirect param is no longer trusted here.
|
|
3432
|
+
if (this.isDevConsentTestMode()) {
|
|
3433
|
+
trustedUserDid = bodyObj.user_did;
|
|
3434
|
+
}
|
|
3435
|
+
else {
|
|
3436
|
+
const assertion = bodyObj.identity_assertion;
|
|
3437
|
+
const verifiedUserDid = typeof assertion === "string"
|
|
3438
|
+
? await this.verifyIdentityAssertion(assertion, {
|
|
3439
|
+
sid: sessionId,
|
|
3440
|
+
agentDid: bodyObj.agent_did ?? "",
|
|
3441
|
+
tool: bodyObj.tool ?? "",
|
|
3442
|
+
scopes: this.parseScopes(bodyObj.scopes),
|
|
3443
|
+
projectId: bodyObj.project_id ?? "",
|
|
3444
|
+
})
|
|
3445
|
+
: null;
|
|
3446
|
+
if (!verifiedUserDid) {
|
|
3447
|
+
logger.warn("[ConsentService] Post-OAuth clickwrap has no valid identity assertion — refusing to mint");
|
|
3448
|
+
return this.identityAssertionRequiredResponse();
|
|
3449
|
+
}
|
|
3450
|
+
trustedUserDid = verifiedUserDid;
|
|
3451
|
+
}
|
|
3351
3452
|
}
|
|
3352
3453
|
// If neither credential nor OAuth, this is pure consent-only
|
|
3353
3454
|
if (!(credentialProviderType === CONSENT_PROVIDER_TYPES.PASSWORD ||
|
|
3354
3455
|
oauthProviderType === "oauth")) {
|
|
3355
3456
|
// Pure consent-only mode - no prior authentication
|
|
3356
3457
|
logger.debug("[ConsentService] Consent-only mode detected (pure clickwrap)");
|
|
3458
|
+
// SECURITY (A3, spec §3.2, review #5): a pure consent-only mint is
|
|
3459
|
+
// intentionally anonymous/ephemeral — it never had a victim identity
|
|
3460
|
+
// to attribute. Any caller-supplied `user_did` is forgeable and must
|
|
3461
|
+
// NEVER survive. Discard it up-front, before the ephemeral/session DID
|
|
3462
|
+
// is (maybe) resolved below, so no sub-path — including the
|
|
3463
|
+
// DELEGATION_STORAGE-unbound `else` fallback that used to leave the
|
|
3464
|
+
// body value intact — can carry a forged DID into the mint.
|
|
3465
|
+
trustedUserDid = undefined;
|
|
3357
3466
|
if (sessionId && this.env.DELEGATION_STORAGE) {
|
|
3358
3467
|
// Check if session already has userDid (from credential auth or OAuth)
|
|
3359
3468
|
let existingUserDid;
|
|
@@ -3371,7 +3480,9 @@ export class ConsentService {
|
|
|
3371
3480
|
sessionId: sessionId.substring(0, 20) + "...",
|
|
3372
3481
|
userDid: existingUserDid.substring(0, 30) + "...",
|
|
3373
3482
|
});
|
|
3374
|
-
|
|
3483
|
+
// Server-side session identity (set by a prior auth step), not a
|
|
3484
|
+
// caller-supplied body value — safe to trust.
|
|
3485
|
+
trustedUserDid = existingUserDid;
|
|
3375
3486
|
}
|
|
3376
3487
|
else {
|
|
3377
3488
|
// Generate ephemeral userDid for pure consent-only
|
|
@@ -3382,8 +3493,9 @@ export class ConsentService {
|
|
|
3382
3493
|
// This ensures storeDelegationToken can find it and use PRIORITY 1 key
|
|
3383
3494
|
await this.updateSessionWithIdentity(sessionId, ephemeralUserDid, null // No OAuth identity for consent-only
|
|
3384
3495
|
);
|
|
3385
|
-
//
|
|
3386
|
-
|
|
3496
|
+
// Server-generated ephemeral DID (not a caller-supplied value)
|
|
3497
|
+
// — safe to trust as the anonymous consent-only subject.
|
|
3498
|
+
trustedUserDid = ephemeralUserDid;
|
|
3387
3499
|
logger.debug("[ConsentService] ✅ Ephemeral userDid stored in session for consent-only flow:", {
|
|
3388
3500
|
sessionId: sessionId.substring(0, 20) + "...",
|
|
3389
3501
|
userDid: ephemeralUserDid.substring(0, 30) + "...",
|
|
@@ -3400,6 +3512,16 @@ export class ConsentService {
|
|
|
3400
3512
|
}
|
|
3401
3513
|
}
|
|
3402
3514
|
}
|
|
3515
|
+
// SECURITY (A3, spec §3.2, defect #2 :4419/:4477): overwrite the forgeable
|
|
3516
|
+
// `user_did` body field with the trusted value resolved above — a signed
|
|
3517
|
+
// assertion, or the server-generated ephemeral / session DID — for EVERY
|
|
3518
|
+
// shape, unconditionally. For the plain authenticated (non-clickwrap)
|
|
3519
|
+
// shapes `trustedUserDid` is undefined, which wipes any caller-supplied
|
|
3520
|
+
// `user_did` so it can never be an identity source; those shapes then fall
|
|
3521
|
+
// back to server-side session resolution below. This is the single choke
|
|
3522
|
+
// point that guarantees no downstream consumer (validation,
|
|
3523
|
+
// createDelegation, storeDelegationToken) ever reads a caller-forged DID.
|
|
3524
|
+
bodyObj.user_did = trustedUserDid;
|
|
3403
3525
|
// Convert null oauth_identity to undefined for proper schema validation
|
|
3404
3526
|
// Zod's .nullish() should handle null, but converting to undefined is more explicit
|
|
3405
3527
|
// and avoids potential edge cases with FormData parsing
|
|
@@ -3453,11 +3575,30 @@ export class ConsentService {
|
|
|
3453
3575
|
// ✅ Lazy initialization with projectId
|
|
3454
3576
|
const auditService = await this.getAuditService(projectId);
|
|
3455
3577
|
// Check if user needs credentials before delegation
|
|
3456
|
-
// Skip credential requirement for consent-only mode (provider_type: 'none')
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3578
|
+
// Skip credential requirement for consent-only mode (provider_type: 'none').
|
|
3579
|
+
// Read the EFFECTIVE provider_type off bodyObj, not the `providerType`
|
|
3580
|
+
// captured above: the post-OAuth/post-credential clickwrap branches above
|
|
3581
|
+
// rewrite bodyObj.provider_type to 'oauth'/'password' while `providerType`
|
|
3582
|
+
// stays the original raw 'none'. Gating on the stale value would treat
|
|
3583
|
+
// those already-authenticated clickwrap submissions as consent-only and
|
|
3584
|
+
// skip the identity check entirely.
|
|
3585
|
+
const effectiveProviderType = bodyObj.provider_type;
|
|
3586
|
+
const isConsentOnlyMode = effectiveProviderType === CONSENT_PROVIDER_TYPES.NONE;
|
|
3587
|
+
// Resolve identity once, ahead of the gate below, using the same priority
|
|
3588
|
+
// createDelegation used to apply internally (request.user_did first, then
|
|
3589
|
+
// session lookup). The resolved value is threaded into createDelegation so
|
|
3590
|
+
// it is never resolved a second time.
|
|
3591
|
+
let resolvedUserDid = approvalRequest.user_did;
|
|
3592
|
+
if (!resolvedUserDid && approvalRequest.session_id) {
|
|
3593
|
+
try {
|
|
3594
|
+
resolvedUserDid =
|
|
3595
|
+
(await this.getUserDidForSession(approvalRequest.session_id, approvalRequest.oauth_identity || undefined)) ?? undefined;
|
|
3596
|
+
}
|
|
3597
|
+
catch (error) {
|
|
3598
|
+
logger.debug("[ConsentService] Failed to resolve userDid ahead of identity gate:", error);
|
|
3599
|
+
}
|
|
3600
|
+
}
|
|
3601
|
+
const needsCredentials = !isConsentOnlyMode && !resolvedUserDid;
|
|
3461
3602
|
if (needsCredentials && auditService) {
|
|
3462
3603
|
await auditService
|
|
3463
3604
|
.logCredentialRequired({
|
|
@@ -3475,12 +3616,25 @@ export class ConsentService {
|
|
|
3475
3616
|
error: err instanceof Error ? err.message : String(err),
|
|
3476
3617
|
});
|
|
3477
3618
|
});
|
|
3478
|
-
|
|
3479
|
-
|
|
3619
|
+
}
|
|
3620
|
+
// Fail closed: an authenticated-provider approval with no resolvable user
|
|
3621
|
+
// identity must not mint a delegation. Consent-only mode is exempt - it
|
|
3622
|
+
// legitimately has no user identity. Gated on CONSENT_REQUIRE_IDENTITY so
|
|
3623
|
+
// a bad interaction can be switched off in production without a redeploy;
|
|
3624
|
+
// defaults to enabled.
|
|
3625
|
+
if (needsCredentials && this.isIdentityRequired()) {
|
|
3626
|
+
return new Response(JSON.stringify({
|
|
3627
|
+
success: false,
|
|
3628
|
+
error: "User identity is required to approve this request",
|
|
3629
|
+
error_code: "identity_required",
|
|
3630
|
+
}), {
|
|
3631
|
+
status: 403,
|
|
3632
|
+
headers: { "Content-Type": "application/json" },
|
|
3633
|
+
});
|
|
3480
3634
|
}
|
|
3481
3635
|
// Create delegation via AgentShield API
|
|
3482
3636
|
logger.debug("[ConsentService] Creating delegation...");
|
|
3483
|
-
const delegationResult = await this.createDelegation(approvalRequest, resolveExpirationDays(consentConfig.expirationDays));
|
|
3637
|
+
const delegationResult = await this.createDelegation(approvalRequest, resolveExpirationDays(consentConfig.expirationDays), resolvedUserDid ?? null);
|
|
3484
3638
|
if (!delegationResult.success) {
|
|
3485
3639
|
logger.error("[ConsentService] Delegation creation failed:", {
|
|
3486
3640
|
error: delegationResult.error,
|
|
@@ -3499,27 +3653,27 @@ export class ConsentService {
|
|
|
3499
3653
|
delegationId: delegationResult.delegation_id?.substring(0, 20) + "...",
|
|
3500
3654
|
});
|
|
3501
3655
|
// Store delegation token to DO (strongly consistent) and KV (backward compat)
|
|
3502
|
-
//
|
|
3656
|
+
// SECURITY (A3, defect #2 :4477): pass the trusted `resolvedUserDid`
|
|
3657
|
+
// (assertion / ephemeral / session-resolved), NOT the raw body
|
|
3658
|
+
// `approvalRequest.user_did`, so the stored delegation key is never scoped
|
|
3659
|
+
// to a caller-forged DID. `resolvedUserDid` is a superset of the body
|
|
3660
|
+
// value (which is already neutralised above) plus server-side resolution.
|
|
3503
3661
|
const serverOrigin = new URL(request.url).origin;
|
|
3504
|
-
await this.storeDelegationToken(approvalRequest.session_id, approvalRequest.agent_did, delegationResult.delegation_token, delegationResult.delegation_id,
|
|
3505
|
-
serverOrigin // Pass origin for DO URL construction
|
|
3506
|
-
|
|
3662
|
+
await this.storeDelegationToken(approvalRequest.session_id, approvalRequest.agent_did, delegationResult.delegation_token, delegationResult.delegation_id, resolvedUserDid, // trusted identity only (see above)
|
|
3663
|
+
serverOrigin, // Pass origin for DO URL construction
|
|
3664
|
+
// Task B2: the EFFECTIVE provider_type is the delegation's auth type.
|
|
3665
|
+
// On this ('none') branch it is one of 'none' | 'password' | 'oauth'
|
|
3666
|
+
// (the post-credential / post-OAuth clickwrap branches rewrote it to
|
|
3667
|
+
// match the tool's authorization.type; 'credential'/'password' form
|
|
3668
|
+
// submissions were routed to handleCredentialApproval earlier), so it
|
|
3669
|
+
// maps cleanly through normalizeAuthType — no 'credential' mis-fold here.
|
|
3670
|
+
effectiveProviderType);
|
|
3507
3671
|
// ✅ After successful delegation creation - log audit events
|
|
3508
3672
|
if (auditService && delegationResult.success) {
|
|
3509
3673
|
try {
|
|
3510
|
-
//
|
|
3511
|
-
//
|
|
3512
|
-
|
|
3513
|
-
if (approvalRequest.session_id) {
|
|
3514
|
-
try {
|
|
3515
|
-
userDid =
|
|
3516
|
-
(await this.getUserDidForSession(approvalRequest.session_id, approvalRequest.oauth_identity || undefined)) ?? undefined; // Phase 5: Convert null to undefined
|
|
3517
|
-
}
|
|
3518
|
-
catch (error) {
|
|
3519
|
-
logger.warn("[ConsentService] Failed to get userDid for lifecycle recording:", error);
|
|
3520
|
-
// Continue without userDid - audit events can still be logged
|
|
3521
|
-
}
|
|
3522
|
-
}
|
|
3674
|
+
// Reuse the identity resolved ahead of the gate above - do not
|
|
3675
|
+
// read KV a second time for the same session.
|
|
3676
|
+
const userDid = resolvedUserDid;
|
|
3523
3677
|
await auditService.logConsentApproval({
|
|
3524
3678
|
sessionId: approvalRequest.session_id,
|
|
3525
3679
|
userDid,
|
|
@@ -3583,7 +3737,7 @@ export class ConsentService {
|
|
|
3583
3737
|
* @param request - Approval request
|
|
3584
3738
|
* @returns Delegation creation result
|
|
3585
3739
|
*/
|
|
3586
|
-
async createDelegation(request, expirationDaysOverride) {
|
|
3740
|
+
async createDelegation(request, expirationDaysOverride, resolvedUserDid) {
|
|
3587
3741
|
const agentShieldUrl = this.env.AGENTSHIELD_API_URL || DEFAULT_AGENTSHIELD_URL;
|
|
3588
3742
|
const apiKey = this.env.AGENTSHIELD_API_KEY;
|
|
3589
3743
|
if (!apiKey) {
|
|
@@ -3598,46 +3752,59 @@ export class ConsentService {
|
|
|
3598
3752
|
// Load Day0 configuration to determine field name and API capabilities
|
|
3599
3753
|
await loadDay0Config(this.env.DELEGATION_STORAGE);
|
|
3600
3754
|
const fieldName = await getDelegationFieldName(this.env.DELEGATION_STORAGE);
|
|
3601
|
-
// Get userDID
|
|
3602
|
-
//
|
|
3603
|
-
//
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
if (
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
hasOAuthIdentity: !!request.oauth_identity,
|
|
3611
|
-
oauthProvider: request.oauth_identity?.provider,
|
|
3612
|
-
hasStorage: !!this.env.DELEGATION_STORAGE,
|
|
3613
|
-
});
|
|
3614
|
-
// Pass OAuth identity if available in approval request (can be null/undefined)
|
|
3615
|
-
// getUserDidForSession can work without DELEGATION_STORAGE (uses in-memory UserDidManager)
|
|
3616
|
-
// Phase 5: Returns null if no identity found (session stays anonymous)
|
|
3617
|
-
userDid =
|
|
3618
|
-
(await this.getUserDidForSession(request.session_id, request.oauth_identity || undefined // Explicitly handle null as undefined
|
|
3619
|
-
)) ?? undefined;
|
|
3620
|
-
logger.debug("[ConsentService] User DID retrieved from storage:", {
|
|
3621
|
-
userDid: userDid?.substring(0, 20) + "...",
|
|
3622
|
-
hasUserDid: !!userDid,
|
|
3623
|
-
});
|
|
3624
|
-
}
|
|
3625
|
-
catch (error) {
|
|
3626
|
-
logger.debug("[ConsentService] Failed to get/generate userDid:", error);
|
|
3627
|
-
// Continue without userDid - delegation will work without user_identifier
|
|
3628
|
-
// This is valid for non-OAuth scenarios, but we should log this as a warning
|
|
3629
|
-
logger.warn("[ConsentService] Delegation will be created without user_identifier - this may affect user tracking");
|
|
3630
|
-
}
|
|
3631
|
-
}
|
|
3632
|
-
else if (userDid) {
|
|
3633
|
-
// userDid was provided in request (e.g., from credential auth flow)
|
|
3634
|
-
logger.debug("[ConsentService] Using provided user_did from request:", {
|
|
3635
|
-
userDid: userDid.substring(0, 20) + "...",
|
|
3636
|
-
source: "request.user_did",
|
|
3755
|
+
// Get userDID. If the caller (handleApproval) already resolved it, use that
|
|
3756
|
+
// directly and skip the lookup below entirely - it was already attempted
|
|
3757
|
+
// with the same priority (request.user_did first, then session storage),
|
|
3758
|
+
// and repeating it would read KV a second time for no benefit.
|
|
3759
|
+
let userDid;
|
|
3760
|
+
if (resolvedUserDid !== undefined) {
|
|
3761
|
+
userDid = resolvedUserDid ?? undefined;
|
|
3762
|
+
logger.debug("[ConsentService] Using pre-resolved userDid:", {
|
|
3763
|
+
hasUserDid: !!userDid,
|
|
3637
3764
|
});
|
|
3638
3765
|
}
|
|
3639
3766
|
else {
|
|
3640
|
-
|
|
3767
|
+
// FIRST check if passed in request, THEN fallback to session storage
|
|
3768
|
+
// request.user_did takes priority to avoid KV eventual consistency issues
|
|
3769
|
+
// This fixes the bug where credential auth resolves userDid but it's not found in storage
|
|
3770
|
+
userDid = request.user_did;
|
|
3771
|
+
// Only fetch from storage if not already provided in request
|
|
3772
|
+
if (!userDid && request.session_id) {
|
|
3773
|
+
try {
|
|
3774
|
+
logger.debug("[ConsentService] Getting User DID for session:", {
|
|
3775
|
+
sessionId: request.session_id.substring(0, 20) + "...",
|
|
3776
|
+
hasOAuthIdentity: !!request.oauth_identity,
|
|
3777
|
+
oauthProvider: request.oauth_identity?.provider,
|
|
3778
|
+
hasStorage: !!this.env.DELEGATION_STORAGE,
|
|
3779
|
+
});
|
|
3780
|
+
// Pass OAuth identity if available in approval request (can be null/undefined)
|
|
3781
|
+
// getUserDidForSession can work without DELEGATION_STORAGE (uses in-memory UserDidManager)
|
|
3782
|
+
// Phase 5: Returns null if no identity found (session stays anonymous)
|
|
3783
|
+
userDid =
|
|
3784
|
+
(await this.getUserDidForSession(request.session_id, request.oauth_identity || undefined // Explicitly handle null as undefined
|
|
3785
|
+
)) ?? undefined;
|
|
3786
|
+
logger.debug("[ConsentService] User DID retrieved from storage:", {
|
|
3787
|
+
userDid: userDid?.substring(0, 20) + "...",
|
|
3788
|
+
hasUserDid: !!userDid,
|
|
3789
|
+
});
|
|
3790
|
+
}
|
|
3791
|
+
catch (error) {
|
|
3792
|
+
logger.debug("[ConsentService] Failed to get/generate userDid:", error);
|
|
3793
|
+
// Continue without userDid - delegation will work without user_identifier
|
|
3794
|
+
// This is valid for non-OAuth scenarios, but we should log this as a warning
|
|
3795
|
+
logger.warn("[ConsentService] Delegation will be created without user_identifier - this may affect user tracking");
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3798
|
+
else if (userDid) {
|
|
3799
|
+
// userDid was provided in request (e.g., from credential auth flow)
|
|
3800
|
+
logger.debug("[ConsentService] Using provided user_did from request:", {
|
|
3801
|
+
userDid: userDid.substring(0, 20) + "...",
|
|
3802
|
+
source: "request.user_did",
|
|
3803
|
+
});
|
|
3804
|
+
}
|
|
3805
|
+
else {
|
|
3806
|
+
logger.debug("[ConsentService] No session_id provided - skipping User DID generation");
|
|
3807
|
+
}
|
|
3641
3808
|
}
|
|
3642
3809
|
const expiresInDays = expirationDaysOverride ?? DEFAULT_EXPIRATION_DAYS;
|
|
3643
3810
|
// Phase 2 VC-Only: Issue Delegation VC if we have a session and userDid
|
|
@@ -3868,9 +4035,17 @@ export class ConsentService {
|
|
|
3868
4035
|
* @param delegationId - Delegation ID
|
|
3869
4036
|
* @param providedUserDid - Optional userDid passed directly (bypasses KV read for consistency)
|
|
3870
4037
|
* @param serverOrigin - Server origin for DO URL construction
|
|
4038
|
+
* @param authorizationType - The delegation's authorization type as recorded
|
|
4039
|
+
* at mint (raw provider_type / authorization.type). Normalized here before
|
|
4040
|
+
* persisting so the call-time enforcement gate (Task B2/B3) reads a
|
|
4041
|
+
* consistent value ("password" | "oauth2" | "none" | …). Undefined → the
|
|
4042
|
+
* field is omitted and the gate fails safe (never satisfies a typed tool).
|
|
3871
4043
|
*/
|
|
3872
|
-
async storeDelegationToken(sessionId, agentDid, token, delegationId, providedUserDid, serverOrigin) {
|
|
4044
|
+
async storeDelegationToken(sessionId, agentDid, token, delegationId, providedUserDid, serverOrigin, authorizationType) {
|
|
3873
4045
|
const delegationStorage = this.env.DELEGATION_STORAGE;
|
|
4046
|
+
// Task B2: normalize once (folds deprecated aliases: oauth→oauth2) so every
|
|
4047
|
+
// stored record and every read compares in the same vocabulary.
|
|
4048
|
+
const normalizedAuthorizationType = normalizeAuthType(authorizationType);
|
|
3874
4049
|
// Resolve userDid if not provided
|
|
3875
4050
|
let userDid = providedUserDid;
|
|
3876
4051
|
if (!userDid && delegationStorage) {
|
|
@@ -3886,7 +4061,14 @@ export class ConsentService {
|
|
|
3886
4061
|
// PRIMARY: Store to Durable Object (strongly consistent)
|
|
3887
4062
|
// This eliminates eventual consistency issues with KV
|
|
3888
4063
|
const origin = serverOrigin || this.env.MCP_SERVER_URL || "https://localhost";
|
|
3889
|
-
const storedViaDO = await this.storeDelegationViaDO({
|
|
4064
|
+
const storedViaDO = await this.storeDelegationViaDO({
|
|
4065
|
+
sessionId,
|
|
4066
|
+
delegationToken: token,
|
|
4067
|
+
delegationId,
|
|
4068
|
+
userDid,
|
|
4069
|
+
agentDid,
|
|
4070
|
+
authorizationType: normalizedAuthorizationType,
|
|
4071
|
+
}, origin);
|
|
3890
4072
|
// ALSO store to KV for backward compatibility and cross-session persistence
|
|
3891
4073
|
if (delegationStorage) {
|
|
3892
4074
|
try {
|
|
@@ -3908,6 +4090,10 @@ export class ConsentService {
|
|
|
3908
4090
|
agentDid,
|
|
3909
4091
|
delegationToken: token,
|
|
3910
4092
|
delegationId,
|
|
4093
|
+
// Task B2: mirror the auth type into the KV session record too, so
|
|
4094
|
+
// the two store paths (DO + KV) stay consistent. The DO record is
|
|
4095
|
+
// the one the enforcement gate reads; this is for parity/debugging.
|
|
4096
|
+
authorizationType: normalizedAuthorizationType,
|
|
3911
4097
|
cachedAt: Date.now(),
|
|
3912
4098
|
}), { expirationTtl: ttl });
|
|
3913
4099
|
logger.debug("[ConsentService] Token also stored to KV (session key)");
|
|
@@ -4053,7 +4239,14 @@ export class ConsentService {
|
|
|
4053
4239
|
// tool handler runs server-side through the MCP tools/call proxy, so
|
|
4054
4240
|
// CSRF is not applicable.
|
|
4055
4241
|
const isInlineMode = body.inline_mode === true;
|
|
4056
|
-
|
|
4242
|
+
// SECURITY (A4, spec §3.3): the ONLY sanctioned CSRF relaxation is the
|
|
4243
|
+
// dev/test escape hatch — BOTH MCPI_TEST_CONSENT and a development env —
|
|
4244
|
+
// applied here at the call site (mirroring consent-anchor-bridge's
|
|
4245
|
+
// `isTestConsentMode` gate on `validateCsrfToken`), never inside the
|
|
4246
|
+
// validator/minter, which are strictly fail-closed. Production, staging,
|
|
4247
|
+
// and unset always enforce CSRF.
|
|
4248
|
+
const skipCsrf = isInlineMode || this.isDevConsentTestMode();
|
|
4249
|
+
if (!skipCsrf) {
|
|
4057
4250
|
// Validate CSRF token
|
|
4058
4251
|
if (!csrf_token || typeof csrf_token !== "string") {
|
|
4059
4252
|
logger.warn("[ConsentService] Missing or invalid CSRF token");
|
|
@@ -4247,7 +4440,12 @@ export class ConsentService {
|
|
|
4247
4440
|
}
|
|
4248
4441
|
// Store delegation token (DO + KV)
|
|
4249
4442
|
const serverUrl = this.env.MCP_SERVER_URL || new URL(request.url).origin;
|
|
4250
|
-
await this.storeDelegationToken(session_id, agent_did, delegationResult.delegation_token, delegationResult.delegation_id, identityResult.userDid, serverUrl
|
|
4443
|
+
await this.storeDelegationToken(session_id, agent_did, delegationResult.delegation_token, delegationResult.delegation_id, identityResult.userDid, serverUrl,
|
|
4444
|
+
// Task B2: the inline path is the credential (password) auth flow —
|
|
4445
|
+
// it authenticated against the provider and minted with
|
|
4446
|
+
// provider_type=PASSWORD (see approvalBody above), so the delegation's
|
|
4447
|
+
// authorization type is "password".
|
|
4448
|
+
CONSENT_PROVIDER_TYPES.PASSWORD);
|
|
4251
4449
|
logger.info("[ConsentService] ✅ Inline credential auth + delegation complete", {
|
|
4252
4450
|
delegationId: delegationResult.delegation_id?.substring(0, 20) + "...",
|
|
4253
4451
|
});
|
|
@@ -4330,17 +4528,49 @@ export class ConsentService {
|
|
|
4330
4528
|
// Provider's internal ID (e.g., customer ID 696395) for business reference
|
|
4331
4529
|
clickwrapUrl.searchParams.set("credential_provider_user_id", authResult.userId);
|
|
4332
4530
|
}
|
|
4333
|
-
//
|
|
4334
|
-
//
|
|
4335
|
-
//
|
|
4336
|
-
//
|
|
4337
|
-
//
|
|
4338
|
-
|
|
4531
|
+
// SECURITY (A2, spec §3.1): carry the authenticated identity to the
|
|
4532
|
+
// clickwrap as a server-signed, grant-bound `identity_assertion` instead
|
|
4533
|
+
// of the bare, caller-forgeable `user_did` query param that used to live
|
|
4534
|
+
// here. The assertion binds {sid, userDid, provider, agentDid, tool,
|
|
4535
|
+
// scopesHash, projectId}; handleApproval re-verifies it before minting, so
|
|
4536
|
+
// the delegation's subject can no longer be forged and the KV-race the old
|
|
4537
|
+
// param dodged is still dodged (the identity travels in the signed token).
|
|
4538
|
+
//
|
|
4539
|
+
// Delivery: the token goes in the URL *fragment* (`#identity_assertion=…`),
|
|
4540
|
+
// never the query string, so it stays out of access logs, `Referer`, and
|
|
4541
|
+
// browser history. The consent page's client script reads the fragment and
|
|
4542
|
+
// posts it back (see the @kya-os/consent component). The consent render
|
|
4543
|
+
// Response also sets `Referrer-Policy: no-referrer` as defence in depth.
|
|
4544
|
+
if (this.isDevConsentTestMode()) {
|
|
4545
|
+
// Dev/test escape hatch (BOTH MCPI_TEST_CONSENT and a development env):
|
|
4546
|
+
// no signed assertion is minted, so local/miniflare runs work without
|
|
4547
|
+
// OAUTH_ENCRYPTION_SECRET. Mirrors consent-anchor-bridge's call-site
|
|
4548
|
+
// relaxation. Never reached on production/staging/unset deployments.
|
|
4549
|
+
clickwrapUrl.searchParams.set("user_did", identityResult.userDid);
|
|
4550
|
+
}
|
|
4551
|
+
else {
|
|
4552
|
+
// Fail-closed: mintIdentityAssertion throws if OAUTH_ENCRYPTION_SECRET
|
|
4553
|
+
// is unset, surfacing as a 500 rather than a silent unsigned fallback.
|
|
4554
|
+
const identityAssertion = await this.mintIdentityAssertion({
|
|
4555
|
+
sid: session_id,
|
|
4556
|
+
userDid: identityResult.userDid,
|
|
4557
|
+
provider: provider,
|
|
4558
|
+
agentDid: agent_did,
|
|
4559
|
+
tool: tool,
|
|
4560
|
+
scopes,
|
|
4561
|
+
projectId: project_id,
|
|
4562
|
+
});
|
|
4563
|
+
clickwrapUrl.hash = `identity_assertion=${identityAssertion}`;
|
|
4564
|
+
}
|
|
4339
4565
|
logger.debug("[ConsentService] ✅ Credential auth complete, redirecting to clickwrap", {
|
|
4340
4566
|
sessionId: session_id.substring(0, 20) + "...",
|
|
4341
4567
|
userDid: identityResult.userDid.substring(0, 30) + "...",
|
|
4342
4568
|
provider: provider,
|
|
4343
|
-
|
|
4569
|
+
// Never log clickwrapUrl.toString()/.href here: the URL's fragment
|
|
4570
|
+
// carries the signed identity_assertion (see clickwrapUrl.hash
|
|
4571
|
+
// above), and query params carry other session identifiers. Log
|
|
4572
|
+
// only the origin + pathname.
|
|
4573
|
+
clickwrapUrl: clickwrapUrl.origin + clickwrapUrl.pathname,
|
|
4344
4574
|
});
|
|
4345
4575
|
// Return success with redirect to clickwrap page
|
|
4346
4576
|
return new Response(JSON.stringify({
|
|
@@ -4644,9 +4874,15 @@ export class ConsentService {
|
|
|
4644
4874
|
async validateCredentialCsrfToken(token, sessionId) {
|
|
4645
4875
|
const secret = this.env.OAUTH_ENCRYPTION_SECRET;
|
|
4646
4876
|
if (!secret) {
|
|
4647
|
-
|
|
4648
|
-
//
|
|
4649
|
-
|
|
4877
|
+
// SECURITY (A4, spec §3.3, defect #4): fail CLOSED. The old code returned
|
|
4878
|
+
// `true` here ("graceful degradation"), silently disabling CSRF on a
|
|
4879
|
+
// state-changing endpoint whenever the secret was unset — the exact
|
|
4880
|
+
// fail-open this PR removes. Matches consent-anchor-bridge's
|
|
4881
|
+
// `validateCsrfToken`. The single allowed relaxation is the dev/test
|
|
4882
|
+
// escape hatch applied at the CALL SITE (see `handleCredentialApproval`),
|
|
4883
|
+
// never here in the validator.
|
|
4884
|
+
logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET for CSRF validation — rejecting (fail-closed)");
|
|
4885
|
+
return false;
|
|
4650
4886
|
}
|
|
4651
4887
|
try {
|
|
4652
4888
|
const dotIndex = token.indexOf(".");
|
|
@@ -4690,10 +4926,19 @@ export class ConsentService {
|
|
|
4690
4926
|
async storeCredentialCsrfToken(sessionId) {
|
|
4691
4927
|
const secret = this.env.OAUTH_ENCRYPTION_SECRET;
|
|
4692
4928
|
if (!secret) {
|
|
4693
|
-
|
|
4694
|
-
//
|
|
4695
|
-
|
|
4696
|
-
|
|
4929
|
+
// SECURITY (A4, spec §3.3, defect #4): do NOT mint a random,
|
|
4930
|
+
// never-validating token when the secret is unset. The old fallback
|
|
4931
|
+
// handed the form a token that `validateCredentialCsrfToken` (now
|
|
4932
|
+
// fail-closed) can never accept — pure theatre that paired with the old
|
|
4933
|
+
// validate fail-OPEN to silently disable CSRF. Fail closed instead: only
|
|
4934
|
+
// the dev/test escape hatch may render a form without a real secret, in
|
|
4935
|
+
// which case the call-site skips CSRF entirely so a placeholder is safe.
|
|
4936
|
+
if (this.isDevConsentTestMode()) {
|
|
4937
|
+
logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET; issuing a dev/test placeholder CSRF token (CSRF is skipped at the call site in dev mode)");
|
|
4938
|
+
const tokenBytes = crypto.getRandomValues(new Uint8Array(32));
|
|
4939
|
+
return this.base64UrlEncode(tokenBytes);
|
|
4940
|
+
}
|
|
4941
|
+
throw new Error("[ConsentService] OAUTH_ENCRYPTION_SECRET is unset; refusing to mint a CSRF token (fail-closed)");
|
|
4697
4942
|
}
|
|
4698
4943
|
// 10 minute expiration (matches previous KV TTL)
|
|
4699
4944
|
const exp = Math.floor(Date.now() / 1000) + 600;
|
|
@@ -4750,6 +4995,202 @@ export class ConsentService {
|
|
|
4750
4995
|
}
|
|
4751
4996
|
return result === 0;
|
|
4752
4997
|
}
|
|
4998
|
+
/**
|
|
4999
|
+
* Whether local/miniflare testing may relax consent security checks.
|
|
5000
|
+
*
|
|
5001
|
+
* Mirrors `consent-anchor-bridge.ts`'s `isTestConsentMode` verbatim: requires
|
|
5002
|
+
* BOTH `MCPI_TEST_CONSENT === "true"` AND an explicit `development`
|
|
5003
|
+
* environment. The flag alone must NEVER relax a check on a
|
|
5004
|
+
* production/staging/unset deployment.
|
|
5005
|
+
*
|
|
5006
|
+
* NOT used by `mintIdentityAssertion`/`verifyIdentityAssertion` — those are
|
|
5007
|
+
* strictly fail-closed with no dev relaxation of any kind (a review found
|
|
5008
|
+
* that signing/verifying under an attacker-known empty key when this flag
|
|
5009
|
+
* is active was a latent total-forgery bypass if the two dev flags ever
|
|
5010
|
+
* co-occurred in production). If a dev bypass for the identity-assertion
|
|
5011
|
+
* requirement is ever needed, it belongs at the CALL SITE (mirroring how
|
|
5012
|
+
* the anchor bridge bypasses CSRF via this flag at the handler, before
|
|
5013
|
+
* `validateCsrfToken` is even invoked — never inside the crypto itself).
|
|
5014
|
+
* Public so the OAuth callback path (runtime/oauth-handler.ts) can gate its
|
|
5015
|
+
* own redirect mint on the identical dev/test relaxation used by the
|
|
5016
|
+
* credential redirect above, rather than re-deriving the two-flag check.
|
|
5017
|
+
*/
|
|
5018
|
+
isDevConsentTestMode() {
|
|
5019
|
+
if (this.env.MCPI_TEST_CONSENT !== "true") {
|
|
5020
|
+
return false;
|
|
5021
|
+
}
|
|
5022
|
+
const environment = (this.env.ENVIRONMENT ??
|
|
5023
|
+
this.env.MCPI_ENV ??
|
|
5024
|
+
"").toLowerCase();
|
|
5025
|
+
return environment === "development";
|
|
5026
|
+
}
|
|
5027
|
+
/**
|
|
5028
|
+
* SHA-256 hex digest of the given scopes, sorted ascending and
|
|
5029
|
+
* JSON-array-encoded. Used as the identity assertion's `scopesHash` so the
|
|
5030
|
+
* signed grant binds to the scope *set* regardless of the order scopes
|
|
5031
|
+
* arrive in the request body. Mirrors the digest-to-hex shape already used
|
|
5032
|
+
* elsewhere in this codebase (e.g. `DelegationService.hashToken`).
|
|
5033
|
+
*
|
|
5034
|
+
* `JSON.stringify` (not a `\n`-joined string) is required for injectivity:
|
|
5035
|
+
* a plain join collides distinct scope sets that share the same joined
|
|
5036
|
+
* bytes — e.g. `["a\nb"]` and `["a", "b"]` both join to `"a\nb"`. JSON
|
|
5037
|
+
* array encoding preserves each element's boundary (quoting + `,`
|
|
5038
|
+
* separators), so distinct scope sets can never hash identically.
|
|
5039
|
+
*/
|
|
5040
|
+
async hashScopes(scopes) {
|
|
5041
|
+
const canonical = JSON.stringify([...scopes].sort());
|
|
5042
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
|
|
5043
|
+
return Array.from(new Uint8Array(digest))
|
|
5044
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
5045
|
+
.join("");
|
|
5046
|
+
}
|
|
5047
|
+
/**
|
|
5048
|
+
* Compute HMAC-SHA256 for identity-assertion generation/validation.
|
|
5049
|
+
*
|
|
5050
|
+
* Structurally identical to `computeCsrfHmac`, but `computeCsrfHmac`
|
|
5051
|
+
* hardcodes the `csrf:` key prefix internally so it cannot be reused for a
|
|
5052
|
+
* different domain — this mirrors its shape with an `identity:` prefix
|
|
5053
|
+
* instead, so a CSRF token and an identity assertion are signed under
|
|
5054
|
+
* disjoint keys and can never validate as one another.
|
|
5055
|
+
*/
|
|
5056
|
+
async computeIdentityHmac(secret, data) {
|
|
5057
|
+
const encoder = new TextEncoder();
|
|
5058
|
+
const keyMaterial = encoder.encode(`identity:${secret}`);
|
|
5059
|
+
const key = await crypto.subtle.importKey("raw", keyMaterial, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
5060
|
+
return crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
5061
|
+
}
|
|
5062
|
+
/**
|
|
5063
|
+
* Mint a grant-bound, domain-separated HMAC identity assertion.
|
|
5064
|
+
*
|
|
5065
|
+
* Carries the `userDid` resolved by a completed authentication step
|
|
5066
|
+
* (credential or OAuth) across the consent redirect, replacing the
|
|
5067
|
+
* caller-forgeable `user_did` body field (see
|
|
5068
|
+
* docs/superpowers/specs/2026-07-29-consent-approve-auth-gate-design.md
|
|
5069
|
+
* §3.1). Format mirrors `storeCredentialCsrfToken`:
|
|
5070
|
+
* `${hmacB64url}.${payloadB64url}`.
|
|
5071
|
+
*
|
|
5072
|
+
* Binds the grant, not just the identity: `agentDid`, `tool`, `scopesHash`
|
|
5073
|
+
* (SHA-256 of the sorted scope set), and `projectId` are signed in and must
|
|
5074
|
+
* be re-checked against the request body at mint time by the caller — a
|
|
5075
|
+
* leaked assertion must not be replayable to mint an arbitrary grant under
|
|
5076
|
+
* the victim's `userDid` within the 10-minute window.
|
|
5077
|
+
*
|
|
5078
|
+
* FAIL-CLOSED, ALWAYS: throws if `OAUTH_ENCRYPTION_SECRET` is unset or
|
|
5079
|
+
* empty. There is deliberately NO dev/test relaxation inside this helper —
|
|
5080
|
+
* signing under a predictable key (e.g. an empty-string fallback) would be
|
|
5081
|
+
* an attacker-known key and a latent total-forgery bypass if a dev flag
|
|
5082
|
+
* were ever misconfigured in production. A mint must never silently
|
|
5083
|
+
* produce an unsigned/unverifiable token — unlike
|
|
5084
|
+
* `storeCredentialCsrfToken`'s legacy "graceful degradation," which this
|
|
5085
|
+
* assertion deliberately does not inherit. Any future dev/test bypass
|
|
5086
|
+
* belongs at the call site (see `isDevConsentTestMode`'s docstring).
|
|
5087
|
+
*
|
|
5088
|
+
* Public so both authenticated redirect sites can mint under this service's
|
|
5089
|
+
* `identity:`-keyed secret: the credential redirect (in-class) and the OAuth
|
|
5090
|
+
* callback redirect (runtime/oauth-handler.ts, a separate module).
|
|
5091
|
+
*/
|
|
5092
|
+
async mintIdentityAssertion(params) {
|
|
5093
|
+
const secret = this.env.OAUTH_ENCRYPTION_SECRET;
|
|
5094
|
+
if (!secret) {
|
|
5095
|
+
throw new Error("[ConsentService] OAUTH_ENCRYPTION_SECRET is unset; refusing to mint an identity assertion (fail-closed)");
|
|
5096
|
+
}
|
|
5097
|
+
const exp = Math.floor(Date.now() / 1000) + 600;
|
|
5098
|
+
const scopesHash = await this.hashScopes(params.scopes);
|
|
5099
|
+
const payload = JSON.stringify({
|
|
5100
|
+
v: 1,
|
|
5101
|
+
sid: params.sid,
|
|
5102
|
+
userDid: params.userDid,
|
|
5103
|
+
provider: params.provider,
|
|
5104
|
+
agentDid: params.agentDid,
|
|
5105
|
+
tool: params.tool,
|
|
5106
|
+
scopesHash,
|
|
5107
|
+
projectId: params.projectId,
|
|
5108
|
+
exp,
|
|
5109
|
+
});
|
|
5110
|
+
const payloadB64 = this.base64UrlEncodeString(payload);
|
|
5111
|
+
const hmac = await this.computeIdentityHmac(secret, payloadB64);
|
|
5112
|
+
const hmacB64 = this.base64UrlEncode(new Uint8Array(hmac));
|
|
5113
|
+
return `${hmacB64}.${payloadB64}`;
|
|
5114
|
+
}
|
|
5115
|
+
/**
|
|
5116
|
+
* Verify a grant-bound identity assertion minted by `mintIdentityAssertion`.
|
|
5117
|
+
*
|
|
5118
|
+
* Returns the payload's `userDid` iff: the HMAC matches (constant-time),
|
|
5119
|
+
* the assertion has not expired, and every bound field (`sid`, `agentDid`,
|
|
5120
|
+
* `tool`, `projectId`, and `scopesHash` recomputed from `expected.scopes`)
|
|
5121
|
+
* equals the caller-supplied `expected` values. Any mismatch, tamper, or
|
|
5122
|
+
* expiry returns `null` — never throws, so callers can treat `null`
|
|
5123
|
+
* uniformly as "no verified identity."
|
|
5124
|
+
*
|
|
5125
|
+
* FAIL-CLOSED, ALWAYS: returns `null` if `OAUTH_ENCRYPTION_SECRET` is unset
|
|
5126
|
+
* or empty. There is deliberately NO dev/test relaxation inside this
|
|
5127
|
+
* helper — mirrors `consent-anchor-bridge.ts`'s `validateCsrfToken`, NOT
|
|
5128
|
+
* `validateCredentialCsrfToken`'s legacy "return true to allow flow to
|
|
5129
|
+
* continue" behavior (the fail-open this PR closes). Any future dev/test
|
|
5130
|
+
* bypass belongs at the call site (see `isDevConsentTestMode`'s
|
|
5131
|
+
* docstring), never as a predictable/empty signing key inside this helper.
|
|
5132
|
+
*/
|
|
5133
|
+
async verifyIdentityAssertion(token, expected) {
|
|
5134
|
+
const secret = this.env.OAUTH_ENCRYPTION_SECRET;
|
|
5135
|
+
if (!secret) {
|
|
5136
|
+
logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET for identity-assertion verification; rejecting (fail-closed)");
|
|
5137
|
+
return null;
|
|
5138
|
+
}
|
|
5139
|
+
try {
|
|
5140
|
+
const dotIndex = token.indexOf(".");
|
|
5141
|
+
if (dotIndex === -1) {
|
|
5142
|
+
logger.warn("[ConsentService] Identity assertion missing separator");
|
|
5143
|
+
return null;
|
|
5144
|
+
}
|
|
5145
|
+
const hmacB64 = token.substring(0, dotIndex);
|
|
5146
|
+
const payloadB64 = token.substring(dotIndex + 1);
|
|
5147
|
+
const payloadJson = this.base64UrlDecodeString(payloadB64);
|
|
5148
|
+
const payload = JSON.parse(payloadJson);
|
|
5149
|
+
if (payload.sid !== expected.sid) {
|
|
5150
|
+
logger.warn("[ConsentService] Identity assertion sid mismatch");
|
|
5151
|
+
return null;
|
|
5152
|
+
}
|
|
5153
|
+
if (payload.agentDid !== expected.agentDid) {
|
|
5154
|
+
logger.warn("[ConsentService] Identity assertion agentDid mismatch");
|
|
5155
|
+
return null;
|
|
5156
|
+
}
|
|
5157
|
+
if (payload.tool !== expected.tool) {
|
|
5158
|
+
logger.warn("[ConsentService] Identity assertion tool mismatch");
|
|
5159
|
+
return null;
|
|
5160
|
+
}
|
|
5161
|
+
if (payload.projectId !== expected.projectId) {
|
|
5162
|
+
logger.warn("[ConsentService] Identity assertion projectId mismatch");
|
|
5163
|
+
return null;
|
|
5164
|
+
}
|
|
5165
|
+
const expectedScopesHash = await this.hashScopes(expected.scopes);
|
|
5166
|
+
if (payload.scopesHash !== expectedScopesHash) {
|
|
5167
|
+
logger.warn("[ConsentService] Identity assertion scopes mismatch");
|
|
5168
|
+
return null;
|
|
5169
|
+
}
|
|
5170
|
+
if (typeof payload.exp !== "number" ||
|
|
5171
|
+
payload.exp < Math.floor(Date.now() / 1000)) {
|
|
5172
|
+
logger.warn("[ConsentService] Identity assertion expired");
|
|
5173
|
+
return null;
|
|
5174
|
+
}
|
|
5175
|
+
// Recompute HMAC and constant-time compare. Kept last so tampering with
|
|
5176
|
+
// any bound field above is already caught by the field checks; this is
|
|
5177
|
+
// the authoritative integrity check.
|
|
5178
|
+
const expectedHmac = await this.computeIdentityHmac(secret, payloadB64);
|
|
5179
|
+
const expectedB64 = this.base64UrlEncode(new Uint8Array(expectedHmac));
|
|
5180
|
+
if (!this.constantTimeCompare(hmacB64, expectedB64)) {
|
|
5181
|
+
logger.warn("[ConsentService] Identity assertion HMAC mismatch");
|
|
5182
|
+
return null;
|
|
5183
|
+
}
|
|
5184
|
+
if (typeof payload.userDid !== "string" || !payload.userDid) {
|
|
5185
|
+
return null;
|
|
5186
|
+
}
|
|
5187
|
+
return payload.userDid;
|
|
5188
|
+
}
|
|
5189
|
+
catch (error) {
|
|
5190
|
+
logger.error("[ConsentService] Identity assertion verification error:", error);
|
|
5191
|
+
return null;
|
|
5192
|
+
}
|
|
5193
|
+
}
|
|
4753
5194
|
/**
|
|
4754
5195
|
* Build full DelegationRecord format request (future format)
|
|
4755
5196
|
*/
|