@kya-os/mcp-i-cloudflare 1.13.2 → 1.13.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/dist/agent.d.ts +7 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +73 -7
- package/dist/agent.js.map +1 -1
- package/dist/constants/storage-keys.d.ts +37 -0
- package/dist/constants/storage-keys.d.ts.map +1 -1
- package/dist/constants/storage-keys.js +50 -0
- package/dist/constants/storage-keys.js.map +1 -1
- package/dist/runtime/oauth-handler.d.ts.map +1 -1
- package/dist/runtime/oauth-handler.js +91 -12
- package/dist/runtime/oauth-handler.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +17 -0
- package/dist/runtime.js.map +1 -1
- package/dist/services/batch-queue.d.ts +3 -1
- package/dist/services/batch-queue.d.ts.map +1 -1
- package/dist/services/batch-queue.js +16 -10
- package/dist/services/batch-queue.js.map +1 -1
- package/dist/services/consent.service.d.ts +117 -0
- package/dist/services/consent.service.d.ts.map +1 -1
- package/dist/services/consent.service.js +482 -41
- package/dist/services/consent.service.js.map +1 -1
- package/dist/services/delegation.service.d.ts.map +1 -1
- package/dist/services/delegation.service.js +4 -0
- package/dist/services/delegation.service.js.map +1 -1
- package/dist/utils/hidden-consent-tools.d.ts +37 -0
- package/dist/utils/hidden-consent-tools.d.ts.map +1 -0
- package/dist/utils/hidden-consent-tools.js +60 -0
- package/dist/utils/hidden-consent-tools.js.map +1 -0
- package/package.json +6 -5
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
import { ConsentConfigService } from "./consent-config.service";
|
|
10
10
|
import { TemplateRenderer } from "./consent-templates/template-renderer";
|
|
11
11
|
import { DEFAULT_AGENTSHIELD_URL, DEFAULT_SESSION_CACHE_TTL, KEY_PAIR_TTL_SECONDS, CONSENT_PROVIDER_TYPES, } from "../constants";
|
|
12
|
-
import { STORAGE_KEYS } from "../constants/storage-keys";
|
|
12
|
+
import { STORAGE_KEYS, isPendingDelegationId, } from "../constants/storage-keys";
|
|
13
13
|
import { loadDay0Config, getDelegationFieldName } from "../utils/day0-config";
|
|
14
14
|
import { buildProviderAuthorizeUrl } from "../runtime/oidc/authorize-url.js";
|
|
15
15
|
import { resolveIdpScopes } from "../runtime/oidc/idp-scopes.js";
|
|
16
16
|
import { validateConsentApprovalRequest, } from "@kya-os/contracts/consent";
|
|
17
17
|
import { AGENTSHIELD_ENDPOINTS, createDelegationAPIResponseSchema, createDelegationResponseSchema, } from "@kya-os/contracts/agentshield-api";
|
|
18
18
|
import { createUnsignedVCJWT, completeVCJWT, parseVCJWT, generateDidKeyFromBase64, createDelegationVerifier, createDidKeyResolver, logger, base64urlEncodeFromBytes, base64urlDecodeToBytes, bytesToBase64, wrapDelegationAsVC, } from "@kya-os/mcp";
|
|
19
|
-
import { fetchRemoteConfig, } from "@kya-os/mcp-i-runtime"; // product-layer symbols stay on OLD (C2 #2918)
|
|
19
|
+
import { fetchRemoteConfig, normalizeAuthType, } from "@kya-os/mcp-i-runtime"; // product-layer symbols stay on OLD (C2 #2918)
|
|
20
20
|
import { WebCryptoProvider } from "../providers/crypto";
|
|
21
21
|
import { ConsentAuditService } from "./consent-audit.service";
|
|
22
22
|
import { CloudflareProofGenerator } from "../proof-generator";
|
|
@@ -146,7 +146,7 @@ export class ConsentService {
|
|
|
146
146
|
* @returns true if DO storage succeeded (strongly consistent), false if fell back to KV only
|
|
147
147
|
*/
|
|
148
148
|
async storeDelegationForOAuth(params, serverOrigin) {
|
|
149
|
-
return this.storeDelegationToken(params.sessionId, params.agentDid, params.delegationToken, params.delegationId, params.userDid, serverOrigin);
|
|
149
|
+
return this.storeDelegationToken(params.sessionId, params.agentDid, params.delegationToken, params.delegationId, params.userDid, serverOrigin, params.authorizationType);
|
|
150
150
|
}
|
|
151
151
|
/**
|
|
152
152
|
* Initialize audit service - fetches config from remote API
|
|
@@ -2130,6 +2130,10 @@ export class ConsentService {
|
|
|
2130
2130
|
headers: {
|
|
2131
2131
|
"Content-Type": "text/html; charset=utf-8",
|
|
2132
2132
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
2133
|
+
// SECURITY (A2, spec §3.1): the identity assertion is delivered to
|
|
2134
|
+
// this page in the URL fragment; `no-referrer` guarantees the token
|
|
2135
|
+
// (and any query state) never leaks to a third party via `Referer`.
|
|
2136
|
+
"Referrer-Policy": "no-referrer",
|
|
2133
2137
|
},
|
|
2134
2138
|
});
|
|
2135
2139
|
}
|
|
@@ -2140,6 +2144,10 @@ export class ConsentService {
|
|
|
2140
2144
|
headers: {
|
|
2141
2145
|
"Content-Type": "text/html; charset=utf-8",
|
|
2142
2146
|
"Cache-Control": "no-cache, no-store, must-revalidate",
|
|
2147
|
+
// SECURITY (A2, spec §3.1): the identity assertion is delivered to
|
|
2148
|
+
// this page in the URL fragment; `no-referrer` guarantees the token
|
|
2149
|
+
// (and any query state) never leaks to a third party via `Referer`.
|
|
2150
|
+
"Referrer-Policy": "no-referrer",
|
|
2143
2151
|
},
|
|
2144
2152
|
});
|
|
2145
2153
|
}
|
|
@@ -3241,6 +3249,23 @@ export class ConsentService {
|
|
|
3241
3249
|
const normalized = String(raw).trim().toLowerCase();
|
|
3242
3250
|
return normalized !== "false" && normalized !== "0";
|
|
3243
3251
|
}
|
|
3252
|
+
/**
|
|
3253
|
+
* 403 returned by `handleApproval` when a credential/OAuth clickwrap mint is
|
|
3254
|
+
* attempted without a valid, grant-bound identity assertion (A3, spec §3.2).
|
|
3255
|
+
* A distinct `error_code` from the plain `identity_required` gate so callers
|
|
3256
|
+
* and tests can tell "no server-signed proof of authentication" apart from
|
|
3257
|
+
* "no identity resolved at all".
|
|
3258
|
+
*/
|
|
3259
|
+
identityAssertionRequiredResponse() {
|
|
3260
|
+
return new Response(JSON.stringify({
|
|
3261
|
+
success: false,
|
|
3262
|
+
error: "A valid identity assertion is required to approve this request",
|
|
3263
|
+
error_code: "identity_assertion_required",
|
|
3264
|
+
}), {
|
|
3265
|
+
status: 403,
|
|
3266
|
+
headers: { "Content-Type": "application/json" },
|
|
3267
|
+
});
|
|
3268
|
+
}
|
|
3244
3269
|
/**
|
|
3245
3270
|
* Handle consent approval
|
|
3246
3271
|
*
|
|
@@ -3266,6 +3291,16 @@ export class ConsentService {
|
|
|
3266
3291
|
// - 'none': Consent-only mode (clickwrap) - user agrees without authentication
|
|
3267
3292
|
const bodyObj = body;
|
|
3268
3293
|
const providerType = bodyObj?.provider_type;
|
|
3294
|
+
// SECURITY (A3, spec §3.1/§3.2, defect #2): the identity a delegation is
|
|
3295
|
+
// minted under is authoritative ONLY from a trusted source resolved below
|
|
3296
|
+
// — a server-signed identity assertion (credential/OAuth clickwrap) or the
|
|
3297
|
+
// server-generated ephemeral / session-stored DID (pure consent-only).
|
|
3298
|
+
// A caller-supplied `user_did` body field is NEVER an identity source.
|
|
3299
|
+
// This holds the trusted value; it overwrites `bodyObj.user_did`
|
|
3300
|
+
// unconditionally before validation so no downstream consumer can read a
|
|
3301
|
+
// forged one. `undefined` for the plain authenticated (non-clickwrap)
|
|
3302
|
+
// shapes, which fall back to server-side session resolution.
|
|
3303
|
+
let trustedUserDid;
|
|
3269
3304
|
// CRED-003: Check for credential provider submission
|
|
3270
3305
|
// Credential submissions include `provider_type: 'credential'` or 'password' and are handled separately
|
|
3271
3306
|
// IMPORTANT: AgentShield tool protection uses 'password' for credential-based auth validation,
|
|
@@ -3304,18 +3339,36 @@ export class ConsentService {
|
|
|
3304
3339
|
provider_type: CONSENT_PROVIDER_TYPES.PASSWORD,
|
|
3305
3340
|
};
|
|
3306
3341
|
}
|
|
3307
|
-
//
|
|
3308
|
-
//
|
|
3309
|
-
//
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
//
|
|
3318
|
-
|
|
3342
|
+
// SECURITY (A3, spec §3.2): the userDid for a post-credential
|
|
3343
|
+
// clickwrap mint comes ONLY from the server-signed identity assertion
|
|
3344
|
+
// minted by handleCredentialApproval after it authenticated against
|
|
3345
|
+
// the provider — bound to this sid AND grant (agent/tool/scopes/
|
|
3346
|
+
// project). The bare `user_did` redirect param that used to be trusted
|
|
3347
|
+
// here is gone: it was caller-forgeable (subject/issuer forgery). A
|
|
3348
|
+
// missing/invalid/expired/mismatched assertion → 403, no mint.
|
|
3349
|
+
if (this.isDevConsentTestMode()) {
|
|
3350
|
+
// Dev/test escape hatch (BOTH MCPI_TEST_CONSENT and a development
|
|
3351
|
+
// env), mirroring consent-anchor-bridge's call-site CSRF relaxation:
|
|
3352
|
+
// no signed assertion is required, so local/miniflare runs work
|
|
3353
|
+
// without OAUTH_ENCRYPTION_SECRET. Never on prod/staging/unset.
|
|
3354
|
+
trustedUserDid = bodyObj.user_did;
|
|
3355
|
+
}
|
|
3356
|
+
else {
|
|
3357
|
+
const assertion = bodyObj.identity_assertion;
|
|
3358
|
+
const verifiedUserDid = typeof assertion === "string"
|
|
3359
|
+
? await this.verifyIdentityAssertion(assertion, {
|
|
3360
|
+
sid: sessionId,
|
|
3361
|
+
agentDid: bodyObj.agent_did ?? "",
|
|
3362
|
+
tool: bodyObj.tool ?? "",
|
|
3363
|
+
scopes: this.parseScopes(bodyObj.scopes),
|
|
3364
|
+
projectId: bodyObj.project_id ?? "",
|
|
3365
|
+
})
|
|
3366
|
+
: null;
|
|
3367
|
+
if (!verifiedUserDid) {
|
|
3368
|
+
logger.warn("[ConsentService] Post-credential clickwrap has no valid identity assertion — refusing to mint");
|
|
3369
|
+
return this.identityAssertionRequiredResponse();
|
|
3370
|
+
}
|
|
3371
|
+
trustedUserDid = verifiedUserDid;
|
|
3319
3372
|
}
|
|
3320
3373
|
// Read user info from redirect URL params
|
|
3321
3374
|
// Field mapping (clear naming):
|
|
@@ -3341,7 +3394,7 @@ export class ConsentService {
|
|
|
3341
3394
|
sessionId: sessionId?.substring(0, 20) + "...",
|
|
3342
3395
|
providerType: CONSENT_PROVIDER_TYPES.PASSWORD,
|
|
3343
3396
|
provider: credentialProvider,
|
|
3344
|
-
hasUserDid: !!
|
|
3397
|
+
hasUserDid: !!trustedUserDid,
|
|
3345
3398
|
hasCredentialUserEmail: !!credentialUserEmailFromRedirect,
|
|
3346
3399
|
hasProviderUserId: !!credentialProviderUserIdFromRedirect,
|
|
3347
3400
|
note: "Delegation will have authorization.type='password' to match tool protection",
|
|
@@ -3370,12 +3423,46 @@ export class ConsentService {
|
|
|
3370
3423
|
provider: oauthProvider || bodyObj.provider,
|
|
3371
3424
|
note: "Delegation will have authorization.type='oauth' to match tool protection",
|
|
3372
3425
|
});
|
|
3426
|
+
// SECURITY (A3, spec §3.2): as with the credential clickwrap, the
|
|
3427
|
+
// userDid for a post-OAuth clickwrap mint comes ONLY from the
|
|
3428
|
+
// server-signed identity assertion minted by the OAuth callback
|
|
3429
|
+
// (oauth-handler.ts) after it linked the OAuth identity to the user
|
|
3430
|
+
// DID — bound to this sid AND grant. No valid assertion → 403, no
|
|
3431
|
+
// mint. The bare `user_did` redirect param is no longer trusted here.
|
|
3432
|
+
if (this.isDevConsentTestMode()) {
|
|
3433
|
+
trustedUserDid = bodyObj.user_did;
|
|
3434
|
+
}
|
|
3435
|
+
else {
|
|
3436
|
+
const assertion = bodyObj.identity_assertion;
|
|
3437
|
+
const verifiedUserDid = typeof assertion === "string"
|
|
3438
|
+
? await this.verifyIdentityAssertion(assertion, {
|
|
3439
|
+
sid: sessionId,
|
|
3440
|
+
agentDid: bodyObj.agent_did ?? "",
|
|
3441
|
+
tool: bodyObj.tool ?? "",
|
|
3442
|
+
scopes: this.parseScopes(bodyObj.scopes),
|
|
3443
|
+
projectId: bodyObj.project_id ?? "",
|
|
3444
|
+
})
|
|
3445
|
+
: null;
|
|
3446
|
+
if (!verifiedUserDid) {
|
|
3447
|
+
logger.warn("[ConsentService] Post-OAuth clickwrap has no valid identity assertion — refusing to mint");
|
|
3448
|
+
return this.identityAssertionRequiredResponse();
|
|
3449
|
+
}
|
|
3450
|
+
trustedUserDid = verifiedUserDid;
|
|
3451
|
+
}
|
|
3373
3452
|
}
|
|
3374
3453
|
// If neither credential nor OAuth, this is pure consent-only
|
|
3375
3454
|
if (!(credentialProviderType === CONSENT_PROVIDER_TYPES.PASSWORD ||
|
|
3376
3455
|
oauthProviderType === "oauth")) {
|
|
3377
3456
|
// Pure consent-only mode - no prior authentication
|
|
3378
3457
|
logger.debug("[ConsentService] Consent-only mode detected (pure clickwrap)");
|
|
3458
|
+
// SECURITY (A3, spec §3.2, review #5): a pure consent-only mint is
|
|
3459
|
+
// intentionally anonymous/ephemeral — it never had a victim identity
|
|
3460
|
+
// to attribute. Any caller-supplied `user_did` is forgeable and must
|
|
3461
|
+
// NEVER survive. Discard it up-front, before the ephemeral/session DID
|
|
3462
|
+
// is (maybe) resolved below, so no sub-path — including the
|
|
3463
|
+
// DELEGATION_STORAGE-unbound `else` fallback that used to leave the
|
|
3464
|
+
// body value intact — can carry a forged DID into the mint.
|
|
3465
|
+
trustedUserDid = undefined;
|
|
3379
3466
|
if (sessionId && this.env.DELEGATION_STORAGE) {
|
|
3380
3467
|
// Check if session already has userDid (from credential auth or OAuth)
|
|
3381
3468
|
let existingUserDid;
|
|
@@ -3393,7 +3480,9 @@ export class ConsentService {
|
|
|
3393
3480
|
sessionId: sessionId.substring(0, 20) + "...",
|
|
3394
3481
|
userDid: existingUserDid.substring(0, 30) + "...",
|
|
3395
3482
|
});
|
|
3396
|
-
|
|
3483
|
+
// Server-side session identity (set by a prior auth step), not a
|
|
3484
|
+
// caller-supplied body value — safe to trust.
|
|
3485
|
+
trustedUserDid = existingUserDid;
|
|
3397
3486
|
}
|
|
3398
3487
|
else {
|
|
3399
3488
|
// Generate ephemeral userDid for pure consent-only
|
|
@@ -3404,8 +3493,9 @@ export class ConsentService {
|
|
|
3404
3493
|
// This ensures storeDelegationToken can find it and use PRIORITY 1 key
|
|
3405
3494
|
await this.updateSessionWithIdentity(sessionId, ephemeralUserDid, null // No OAuth identity for consent-only
|
|
3406
3495
|
);
|
|
3407
|
-
//
|
|
3408
|
-
|
|
3496
|
+
// Server-generated ephemeral DID (not a caller-supplied value)
|
|
3497
|
+
// — safe to trust as the anonymous consent-only subject.
|
|
3498
|
+
trustedUserDid = ephemeralUserDid;
|
|
3409
3499
|
logger.debug("[ConsentService] ✅ Ephemeral userDid stored in session for consent-only flow:", {
|
|
3410
3500
|
sessionId: sessionId.substring(0, 20) + "...",
|
|
3411
3501
|
userDid: ephemeralUserDid.substring(0, 30) + "...",
|
|
@@ -3422,6 +3512,16 @@ export class ConsentService {
|
|
|
3422
3512
|
}
|
|
3423
3513
|
}
|
|
3424
3514
|
}
|
|
3515
|
+
// SECURITY (A3, spec §3.2, defect #2 :4419/:4477): overwrite the forgeable
|
|
3516
|
+
// `user_did` body field with the trusted value resolved above — a signed
|
|
3517
|
+
// assertion, or the server-generated ephemeral / session DID — for EVERY
|
|
3518
|
+
// shape, unconditionally. For the plain authenticated (non-clickwrap)
|
|
3519
|
+
// shapes `trustedUserDid` is undefined, which wipes any caller-supplied
|
|
3520
|
+
// `user_did` so it can never be an identity source; those shapes then fall
|
|
3521
|
+
// back to server-side session resolution below. This is the single choke
|
|
3522
|
+
// point that guarantees no downstream consumer (validation,
|
|
3523
|
+
// createDelegation, storeDelegationToken) ever reads a caller-forged DID.
|
|
3524
|
+
bodyObj.user_did = trustedUserDid;
|
|
3425
3525
|
// Convert null oauth_identity to undefined for proper schema validation
|
|
3426
3526
|
// Zod's .nullish() should handle null, but converting to undefined is more explicit
|
|
3427
3527
|
// and avoids potential edge cases with FormData parsing
|
|
@@ -3553,11 +3653,21 @@ export class ConsentService {
|
|
|
3553
3653
|
delegationId: delegationResult.delegation_id?.substring(0, 20) + "...",
|
|
3554
3654
|
});
|
|
3555
3655
|
// Store delegation token to DO (strongly consistent) and KV (backward compat)
|
|
3556
|
-
//
|
|
3656
|
+
// SECURITY (A3, defect #2 :4477): pass the trusted `resolvedUserDid`
|
|
3657
|
+
// (assertion / ephemeral / session-resolved), NOT the raw body
|
|
3658
|
+
// `approvalRequest.user_did`, so the stored delegation key is never scoped
|
|
3659
|
+
// to a caller-forged DID. `resolvedUserDid` is a superset of the body
|
|
3660
|
+
// value (which is already neutralised above) plus server-side resolution.
|
|
3557
3661
|
const serverOrigin = new URL(request.url).origin;
|
|
3558
|
-
await this.storeDelegationToken(approvalRequest.session_id, approvalRequest.agent_did, delegationResult.delegation_token, delegationResult.delegation_id,
|
|
3559
|
-
serverOrigin // Pass origin for DO URL construction
|
|
3560
|
-
|
|
3662
|
+
await this.storeDelegationToken(approvalRequest.session_id, approvalRequest.agent_did, delegationResult.delegation_token, delegationResult.delegation_id, resolvedUserDid, // trusted identity only (see above)
|
|
3663
|
+
serverOrigin, // Pass origin for DO URL construction
|
|
3664
|
+
// Task B2: the EFFECTIVE provider_type is the delegation's auth type.
|
|
3665
|
+
// On this ('none') branch it is one of 'none' | 'password' | 'oauth'
|
|
3666
|
+
// (the post-credential / post-OAuth clickwrap branches rewrote it to
|
|
3667
|
+
// match the tool's authorization.type; 'credential'/'password' form
|
|
3668
|
+
// submissions were routed to handleCredentialApproval earlier), so it
|
|
3669
|
+
// maps cleanly through normalizeAuthType — no 'credential' mis-fold here.
|
|
3670
|
+
effectiveProviderType);
|
|
3561
3671
|
// ✅ After successful delegation creation - log audit events
|
|
3562
3672
|
if (auditService && delegationResult.success) {
|
|
3563
3673
|
try {
|
|
@@ -3925,9 +4035,44 @@ export class ConsentService {
|
|
|
3925
4035
|
* @param delegationId - Delegation ID
|
|
3926
4036
|
* @param providedUserDid - Optional userDid passed directly (bypasses KV read for consistency)
|
|
3927
4037
|
* @param serverOrigin - Server origin for DO URL construction
|
|
4038
|
+
* @param authorizationType - The delegation's authorization type as recorded
|
|
4039
|
+
* at mint (raw provider_type / authorization.type). Normalized here before
|
|
4040
|
+
* persisting so the call-time enforcement gate (Task B2/B3) reads a
|
|
4041
|
+
* consistent value ("password" | "oauth2" | "none" | …). Undefined → the
|
|
4042
|
+
* field is omitted and the gate fails safe (never satisfies a typed tool).
|
|
3928
4043
|
*/
|
|
3929
|
-
async storeDelegationToken(sessionId, agentDid, token, delegationId, providedUserDid, serverOrigin) {
|
|
4044
|
+
async storeDelegationToken(sessionId, agentDid, token, delegationId, providedUserDid, serverOrigin, authorizationType) {
|
|
3930
4045
|
const delegationStorage = this.env.DELEGATION_STORAGE;
|
|
4046
|
+
// Task B2: normalize once (folds deprecated aliases: oauth→oauth2) so every
|
|
4047
|
+
// stored record and every read compares in the same vocabulary.
|
|
4048
|
+
//
|
|
4049
|
+
// SECURITY — the authoritative pre-consent guard. This method is the single
|
|
4050
|
+
// choke point through which an authorization type reaches BOTH destinations
|
|
4051
|
+
// the enforcement gate can read: the Durable Object record
|
|
4052
|
+
// (`storeDelegationViaDO` below) and the KV sidecar. A `pending_` delegation
|
|
4053
|
+
// id means the OAuth callback parked a placeholder whose token is still the
|
|
4054
|
+
// raw IdP access token — the real delegation is only minted once the user
|
|
4055
|
+
// approves on the clickwrap. Recording a type for it would let the gate
|
|
4056
|
+
// treat a pre-consent token as a satisfying delegation and run a protected
|
|
4057
|
+
// tool before the user ever consented.
|
|
4058
|
+
//
|
|
4059
|
+
// Guarding HERE rather than at each caller is deliberate: `oauth-handler.ts`
|
|
4060
|
+
// reaches this via `storeDelegationForOAuth` with a hardcoded "oauth2" and
|
|
4061
|
+
// the pending tokenData, so a per-call-site guard would have to be repeated
|
|
4062
|
+
// at every present and future writer. Dropping the type to undefined makes
|
|
4063
|
+
// every downstream consumer fail safe, because absent already means
|
|
4064
|
+
// "reject" at the gate.
|
|
4065
|
+
const isPendingDelegation = isPendingDelegationId(delegationId);
|
|
4066
|
+
const normalizedAuthorizationType = isPendingDelegation
|
|
4067
|
+
? undefined
|
|
4068
|
+
: normalizeAuthType(authorizationType);
|
|
4069
|
+
if (isPendingDelegation && authorizationType) {
|
|
4070
|
+
logger.warn("[ConsentService] Refusing to record an authorization type for a pending (pre-consent) delegation", {
|
|
4071
|
+
delegationId,
|
|
4072
|
+
requestedAuthorizationType: authorizationType,
|
|
4073
|
+
note: "The real delegation is minted after clickwrap approval; typing this token would authorize it pre-consent.",
|
|
4074
|
+
});
|
|
4075
|
+
}
|
|
3931
4076
|
// Resolve userDid if not provided
|
|
3932
4077
|
let userDid = providedUserDid;
|
|
3933
4078
|
if (!userDid && delegationStorage) {
|
|
@@ -3943,7 +4088,14 @@ export class ConsentService {
|
|
|
3943
4088
|
// PRIMARY: Store to Durable Object (strongly consistent)
|
|
3944
4089
|
// This eliminates eventual consistency issues with KV
|
|
3945
4090
|
const origin = serverOrigin || this.env.MCP_SERVER_URL || "https://localhost";
|
|
3946
|
-
const storedViaDO = await this.storeDelegationViaDO({
|
|
4091
|
+
const storedViaDO = await this.storeDelegationViaDO({
|
|
4092
|
+
sessionId,
|
|
4093
|
+
delegationToken: token,
|
|
4094
|
+
delegationId,
|
|
4095
|
+
userDid,
|
|
4096
|
+
agentDid,
|
|
4097
|
+
authorizationType: normalizedAuthorizationType,
|
|
4098
|
+
}, origin);
|
|
3947
4099
|
// ALSO store to KV for backward compatibility and cross-session persistence
|
|
3948
4100
|
if (delegationStorage) {
|
|
3949
4101
|
try {
|
|
@@ -3954,6 +4106,36 @@ export class ConsentService {
|
|
|
3954
4106
|
await delegationStorage.put(userAgentKey, token, {
|
|
3955
4107
|
expirationTtl: ttl,
|
|
3956
4108
|
});
|
|
4109
|
+
// Record the auth type alongside it (separate key — the value above
|
|
4110
|
+
// is a bare token that several readers consume as the JWT itself).
|
|
4111
|
+
// Without this, a delegation recovered via the KV-legacy path in
|
|
4112
|
+
// agent.ts carries no type and can never satisfy a typed tool, so a
|
|
4113
|
+
// DO miss becomes a permanent re-challenge loop rather than a
|
|
4114
|
+
// one-time re-auth.
|
|
4115
|
+
//
|
|
4116
|
+
// INVARIANT: a sidecar present ⇒ it describes the token currently
|
|
4117
|
+
// under the key above. So an untyped write must CLEAR any existing
|
|
4118
|
+
// sidecar rather than leave it: the token was just replaced, and a
|
|
4119
|
+
// leftover type from a previous mint would be inherited by the new
|
|
4120
|
+
// (weaker or unknown) token on the next KV read. Revocation already
|
|
4121
|
+
// deletes the sidecar for exactly this reason; a remint must too.
|
|
4122
|
+
const authTypeKey = STORAGE_KEYS.delegationAuthType(userDid, agentDid);
|
|
4123
|
+
if (normalizedAuthorizationType) {
|
|
4124
|
+
await delegationStorage.put(authTypeKey, normalizedAuthorizationType, { expirationTtl: ttl });
|
|
4125
|
+
}
|
|
4126
|
+
else {
|
|
4127
|
+
// Clearing a stale sidecar is cleanup, not the primary write: it
|
|
4128
|
+
// must never abort the session-key write that follows. Swallow its
|
|
4129
|
+
// failure rather than letting it escape to the enclosing catch.
|
|
4130
|
+
// Failing to clear is safe in the conservative direction only for
|
|
4131
|
+
// the token it described, so it is logged rather than ignored.
|
|
4132
|
+
try {
|
|
4133
|
+
await delegationStorage.delete?.(authTypeKey);
|
|
4134
|
+
}
|
|
4135
|
+
catch (clearError) {
|
|
4136
|
+
logger.warn("[ConsentService] Could not clear stale delegation auth-type sidecar", clearError);
|
|
4137
|
+
}
|
|
4138
|
+
}
|
|
3957
4139
|
logger.debug("[ConsentService] Token also stored to KV (user+agent key)");
|
|
3958
4140
|
}
|
|
3959
4141
|
// Store session key (for session data preservation)
|
|
@@ -3965,6 +4147,10 @@ export class ConsentService {
|
|
|
3965
4147
|
agentDid,
|
|
3966
4148
|
delegationToken: token,
|
|
3967
4149
|
delegationId,
|
|
4150
|
+
// Task B2: mirror the auth type into the KV session record too, so
|
|
4151
|
+
// the two store paths (DO + KV) stay consistent. The DO record is
|
|
4152
|
+
// the one the enforcement gate reads; this is for parity/debugging.
|
|
4153
|
+
authorizationType: normalizedAuthorizationType,
|
|
3968
4154
|
cachedAt: Date.now(),
|
|
3969
4155
|
}), { expirationTtl: ttl });
|
|
3970
4156
|
logger.debug("[ConsentService] Token also stored to KV (session key)");
|
|
@@ -4110,7 +4296,14 @@ export class ConsentService {
|
|
|
4110
4296
|
// tool handler runs server-side through the MCP tools/call proxy, so
|
|
4111
4297
|
// CSRF is not applicable.
|
|
4112
4298
|
const isInlineMode = body.inline_mode === true;
|
|
4113
|
-
|
|
4299
|
+
// SECURITY (A4, spec §3.3): the ONLY sanctioned CSRF relaxation is the
|
|
4300
|
+
// dev/test escape hatch — BOTH MCPI_TEST_CONSENT and a development env —
|
|
4301
|
+
// applied here at the call site (mirroring consent-anchor-bridge's
|
|
4302
|
+
// `isTestConsentMode` gate on `validateCsrfToken`), never inside the
|
|
4303
|
+
// validator/minter, which are strictly fail-closed. Production, staging,
|
|
4304
|
+
// and unset always enforce CSRF.
|
|
4305
|
+
const skipCsrf = isInlineMode || this.isDevConsentTestMode();
|
|
4306
|
+
if (!skipCsrf) {
|
|
4114
4307
|
// Validate CSRF token
|
|
4115
4308
|
if (!csrf_token || typeof csrf_token !== "string") {
|
|
4116
4309
|
logger.warn("[ConsentService] Missing or invalid CSRF token");
|
|
@@ -4304,7 +4497,12 @@ export class ConsentService {
|
|
|
4304
4497
|
}
|
|
4305
4498
|
// Store delegation token (DO + KV)
|
|
4306
4499
|
const serverUrl = this.env.MCP_SERVER_URL || new URL(request.url).origin;
|
|
4307
|
-
await this.storeDelegationToken(session_id, agent_did, delegationResult.delegation_token, delegationResult.delegation_id, identityResult.userDid, serverUrl
|
|
4500
|
+
await this.storeDelegationToken(session_id, agent_did, delegationResult.delegation_token, delegationResult.delegation_id, identityResult.userDid, serverUrl,
|
|
4501
|
+
// Task B2: the inline path is the credential (password) auth flow —
|
|
4502
|
+
// it authenticated against the provider and minted with
|
|
4503
|
+
// provider_type=PASSWORD (see approvalBody above), so the delegation's
|
|
4504
|
+
// authorization type is "password".
|
|
4505
|
+
CONSENT_PROVIDER_TYPES.PASSWORD);
|
|
4308
4506
|
logger.info("[ConsentService] ✅ Inline credential auth + delegation complete", {
|
|
4309
4507
|
delegationId: delegationResult.delegation_id?.substring(0, 20) + "...",
|
|
4310
4508
|
});
|
|
@@ -4387,17 +4585,49 @@ export class ConsentService {
|
|
|
4387
4585
|
// Provider's internal ID (e.g., customer ID 696395) for business reference
|
|
4388
4586
|
clickwrapUrl.searchParams.set("credential_provider_user_id", authResult.userId);
|
|
4389
4587
|
}
|
|
4390
|
-
//
|
|
4391
|
-
//
|
|
4392
|
-
//
|
|
4393
|
-
//
|
|
4394
|
-
//
|
|
4395
|
-
|
|
4588
|
+
// SECURITY (A2, spec §3.1): carry the authenticated identity to the
|
|
4589
|
+
// clickwrap as a server-signed, grant-bound `identity_assertion` instead
|
|
4590
|
+
// of the bare, caller-forgeable `user_did` query param that used to live
|
|
4591
|
+
// here. The assertion binds {sid, userDid, provider, agentDid, tool,
|
|
4592
|
+
// scopesHash, projectId}; handleApproval re-verifies it before minting, so
|
|
4593
|
+
// the delegation's subject can no longer be forged and the KV-race the old
|
|
4594
|
+
// param dodged is still dodged (the identity travels in the signed token).
|
|
4595
|
+
//
|
|
4596
|
+
// Delivery: the token goes in the URL *fragment* (`#identity_assertion=…`),
|
|
4597
|
+
// never the query string, so it stays out of access logs, `Referer`, and
|
|
4598
|
+
// browser history. The consent page's client script reads the fragment and
|
|
4599
|
+
// posts it back (see the @kya-os/consent component). The consent render
|
|
4600
|
+
// Response also sets `Referrer-Policy: no-referrer` as defence in depth.
|
|
4601
|
+
if (this.isDevConsentTestMode()) {
|
|
4602
|
+
// Dev/test escape hatch (BOTH MCPI_TEST_CONSENT and a development env):
|
|
4603
|
+
// no signed assertion is minted, so local/miniflare runs work without
|
|
4604
|
+
// OAUTH_ENCRYPTION_SECRET. Mirrors consent-anchor-bridge's call-site
|
|
4605
|
+
// relaxation. Never reached on production/staging/unset deployments.
|
|
4606
|
+
clickwrapUrl.searchParams.set("user_did", identityResult.userDid);
|
|
4607
|
+
}
|
|
4608
|
+
else {
|
|
4609
|
+
// Fail-closed: mintIdentityAssertion throws if OAUTH_ENCRYPTION_SECRET
|
|
4610
|
+
// is unset, surfacing as a 500 rather than a silent unsigned fallback.
|
|
4611
|
+
const identityAssertion = await this.mintIdentityAssertion({
|
|
4612
|
+
sid: session_id,
|
|
4613
|
+
userDid: identityResult.userDid,
|
|
4614
|
+
provider: provider,
|
|
4615
|
+
agentDid: agent_did,
|
|
4616
|
+
tool: tool,
|
|
4617
|
+
scopes,
|
|
4618
|
+
projectId: project_id,
|
|
4619
|
+
});
|
|
4620
|
+
clickwrapUrl.hash = `identity_assertion=${identityAssertion}`;
|
|
4621
|
+
}
|
|
4396
4622
|
logger.debug("[ConsentService] ✅ Credential auth complete, redirecting to clickwrap", {
|
|
4397
4623
|
sessionId: session_id.substring(0, 20) + "...",
|
|
4398
4624
|
userDid: identityResult.userDid.substring(0, 30) + "...",
|
|
4399
4625
|
provider: provider,
|
|
4400
|
-
|
|
4626
|
+
// Never log clickwrapUrl.toString()/.href here: the URL's fragment
|
|
4627
|
+
// carries the signed identity_assertion (see clickwrapUrl.hash
|
|
4628
|
+
// above), and query params carry other session identifiers. Log
|
|
4629
|
+
// only the origin + pathname.
|
|
4630
|
+
clickwrapUrl: clickwrapUrl.origin + clickwrapUrl.pathname,
|
|
4401
4631
|
});
|
|
4402
4632
|
// Return success with redirect to clickwrap page
|
|
4403
4633
|
return new Response(JSON.stringify({
|
|
@@ -4701,9 +4931,15 @@ export class ConsentService {
|
|
|
4701
4931
|
async validateCredentialCsrfToken(token, sessionId) {
|
|
4702
4932
|
const secret = this.env.OAUTH_ENCRYPTION_SECRET;
|
|
4703
4933
|
if (!secret) {
|
|
4704
|
-
|
|
4705
|
-
//
|
|
4706
|
-
|
|
4934
|
+
// SECURITY (A4, spec §3.3, defect #4): fail CLOSED. The old code returned
|
|
4935
|
+
// `true` here ("graceful degradation"), silently disabling CSRF on a
|
|
4936
|
+
// state-changing endpoint whenever the secret was unset — the exact
|
|
4937
|
+
// fail-open this PR removes. Matches consent-anchor-bridge's
|
|
4938
|
+
// `validateCsrfToken`. The single allowed relaxation is the dev/test
|
|
4939
|
+
// escape hatch applied at the CALL SITE (see `handleCredentialApproval`),
|
|
4940
|
+
// never here in the validator.
|
|
4941
|
+
logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET for CSRF validation — rejecting (fail-closed)");
|
|
4942
|
+
return false;
|
|
4707
4943
|
}
|
|
4708
4944
|
try {
|
|
4709
4945
|
const dotIndex = token.indexOf(".");
|
|
@@ -4747,10 +4983,19 @@ export class ConsentService {
|
|
|
4747
4983
|
async storeCredentialCsrfToken(sessionId) {
|
|
4748
4984
|
const secret = this.env.OAUTH_ENCRYPTION_SECRET;
|
|
4749
4985
|
if (!secret) {
|
|
4750
|
-
|
|
4751
|
-
//
|
|
4752
|
-
|
|
4753
|
-
|
|
4986
|
+
// SECURITY (A4, spec §3.3, defect #4): do NOT mint a random,
|
|
4987
|
+
// never-validating token when the secret is unset. The old fallback
|
|
4988
|
+
// handed the form a token that `validateCredentialCsrfToken` (now
|
|
4989
|
+
// fail-closed) can never accept — pure theatre that paired with the old
|
|
4990
|
+
// validate fail-OPEN to silently disable CSRF. Fail closed instead: only
|
|
4991
|
+
// the dev/test escape hatch may render a form without a real secret, in
|
|
4992
|
+
// which case the call-site skips CSRF entirely so a placeholder is safe.
|
|
4993
|
+
if (this.isDevConsentTestMode()) {
|
|
4994
|
+
logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET; issuing a dev/test placeholder CSRF token (CSRF is skipped at the call site in dev mode)");
|
|
4995
|
+
const tokenBytes = crypto.getRandomValues(new Uint8Array(32));
|
|
4996
|
+
return this.base64UrlEncode(tokenBytes);
|
|
4997
|
+
}
|
|
4998
|
+
throw new Error("[ConsentService] OAUTH_ENCRYPTION_SECRET is unset; refusing to mint a CSRF token (fail-closed)");
|
|
4754
4999
|
}
|
|
4755
5000
|
// 10 minute expiration (matches previous KV TTL)
|
|
4756
5001
|
const exp = Math.floor(Date.now() / 1000) + 600;
|
|
@@ -4807,6 +5052,202 @@ export class ConsentService {
|
|
|
4807
5052
|
}
|
|
4808
5053
|
return result === 0;
|
|
4809
5054
|
}
|
|
5055
|
+
/**
|
|
5056
|
+
* Whether local/miniflare testing may relax consent security checks.
|
|
5057
|
+
*
|
|
5058
|
+
* Mirrors `consent-anchor-bridge.ts`'s `isTestConsentMode` verbatim: requires
|
|
5059
|
+
* BOTH `MCPI_TEST_CONSENT === "true"` AND an explicit `development`
|
|
5060
|
+
* environment. The flag alone must NEVER relax a check on a
|
|
5061
|
+
* production/staging/unset deployment.
|
|
5062
|
+
*
|
|
5063
|
+
* NOT used by `mintIdentityAssertion`/`verifyIdentityAssertion` — those are
|
|
5064
|
+
* strictly fail-closed with no dev relaxation of any kind (a review found
|
|
5065
|
+
* that signing/verifying under an attacker-known empty key when this flag
|
|
5066
|
+
* is active was a latent total-forgery bypass if the two dev flags ever
|
|
5067
|
+
* co-occurred in production). If a dev bypass for the identity-assertion
|
|
5068
|
+
* requirement is ever needed, it belongs at the CALL SITE (mirroring how
|
|
5069
|
+
* the anchor bridge bypasses CSRF via this flag at the handler, before
|
|
5070
|
+
* `validateCsrfToken` is even invoked — never inside the crypto itself).
|
|
5071
|
+
* Public so the OAuth callback path (runtime/oauth-handler.ts) can gate its
|
|
5072
|
+
* own redirect mint on the identical dev/test relaxation used by the
|
|
5073
|
+
* credential redirect above, rather than re-deriving the two-flag check.
|
|
5074
|
+
*/
|
|
5075
|
+
isDevConsentTestMode() {
|
|
5076
|
+
if (this.env.MCPI_TEST_CONSENT !== "true") {
|
|
5077
|
+
return false;
|
|
5078
|
+
}
|
|
5079
|
+
const environment = (this.env.ENVIRONMENT ??
|
|
5080
|
+
this.env.MCPI_ENV ??
|
|
5081
|
+
"").toLowerCase();
|
|
5082
|
+
return environment === "development";
|
|
5083
|
+
}
|
|
5084
|
+
/**
|
|
5085
|
+
* SHA-256 hex digest of the given scopes, sorted ascending and
|
|
5086
|
+
* JSON-array-encoded. Used as the identity assertion's `scopesHash` so the
|
|
5087
|
+
* signed grant binds to the scope *set* regardless of the order scopes
|
|
5088
|
+
* arrive in the request body. Mirrors the digest-to-hex shape already used
|
|
5089
|
+
* elsewhere in this codebase (e.g. `DelegationService.hashToken`).
|
|
5090
|
+
*
|
|
5091
|
+
* `JSON.stringify` (not a `\n`-joined string) is required for injectivity:
|
|
5092
|
+
* a plain join collides distinct scope sets that share the same joined
|
|
5093
|
+
* bytes — e.g. `["a\nb"]` and `["a", "b"]` both join to `"a\nb"`. JSON
|
|
5094
|
+
* array encoding preserves each element's boundary (quoting + `,`
|
|
5095
|
+
* separators), so distinct scope sets can never hash identically.
|
|
5096
|
+
*/
|
|
5097
|
+
async hashScopes(scopes) {
|
|
5098
|
+
const canonical = JSON.stringify([...scopes].sort());
|
|
5099
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(canonical));
|
|
5100
|
+
return Array.from(new Uint8Array(digest))
|
|
5101
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
5102
|
+
.join("");
|
|
5103
|
+
}
|
|
5104
|
+
/**
|
|
5105
|
+
* Compute HMAC-SHA256 for identity-assertion generation/validation.
|
|
5106
|
+
*
|
|
5107
|
+
* Structurally identical to `computeCsrfHmac`, but `computeCsrfHmac`
|
|
5108
|
+
* hardcodes the `csrf:` key prefix internally so it cannot be reused for a
|
|
5109
|
+
* different domain — this mirrors its shape with an `identity:` prefix
|
|
5110
|
+
* instead, so a CSRF token and an identity assertion are signed under
|
|
5111
|
+
* disjoint keys and can never validate as one another.
|
|
5112
|
+
*/
|
|
5113
|
+
async computeIdentityHmac(secret, data) {
|
|
5114
|
+
const encoder = new TextEncoder();
|
|
5115
|
+
const keyMaterial = encoder.encode(`identity:${secret}`);
|
|
5116
|
+
const key = await crypto.subtle.importKey("raw", keyMaterial, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
5117
|
+
return crypto.subtle.sign("HMAC", key, encoder.encode(data));
|
|
5118
|
+
}
|
|
5119
|
+
/**
|
|
5120
|
+
* Mint a grant-bound, domain-separated HMAC identity assertion.
|
|
5121
|
+
*
|
|
5122
|
+
* Carries the `userDid` resolved by a completed authentication step
|
|
5123
|
+
* (credential or OAuth) across the consent redirect, replacing the
|
|
5124
|
+
* caller-forgeable `user_did` body field (see
|
|
5125
|
+
* docs/superpowers/specs/2026-07-29-consent-approve-auth-gate-design.md
|
|
5126
|
+
* §3.1). Format mirrors `storeCredentialCsrfToken`:
|
|
5127
|
+
* `${hmacB64url}.${payloadB64url}`.
|
|
5128
|
+
*
|
|
5129
|
+
* Binds the grant, not just the identity: `agentDid`, `tool`, `scopesHash`
|
|
5130
|
+
* (SHA-256 of the sorted scope set), and `projectId` are signed in and must
|
|
5131
|
+
* be re-checked against the request body at mint time by the caller — a
|
|
5132
|
+
* leaked assertion must not be replayable to mint an arbitrary grant under
|
|
5133
|
+
* the victim's `userDid` within the 10-minute window.
|
|
5134
|
+
*
|
|
5135
|
+
* FAIL-CLOSED, ALWAYS: throws if `OAUTH_ENCRYPTION_SECRET` is unset or
|
|
5136
|
+
* empty. There is deliberately NO dev/test relaxation inside this helper —
|
|
5137
|
+
* signing under a predictable key (e.g. an empty-string fallback) would be
|
|
5138
|
+
* an attacker-known key and a latent total-forgery bypass if a dev flag
|
|
5139
|
+
* were ever misconfigured in production. A mint must never silently
|
|
5140
|
+
* produce an unsigned/unverifiable token — unlike
|
|
5141
|
+
* `storeCredentialCsrfToken`'s legacy "graceful degradation," which this
|
|
5142
|
+
* assertion deliberately does not inherit. Any future dev/test bypass
|
|
5143
|
+
* belongs at the call site (see `isDevConsentTestMode`'s docstring).
|
|
5144
|
+
*
|
|
5145
|
+
* Public so both authenticated redirect sites can mint under this service's
|
|
5146
|
+
* `identity:`-keyed secret: the credential redirect (in-class) and the OAuth
|
|
5147
|
+
* callback redirect (runtime/oauth-handler.ts, a separate module).
|
|
5148
|
+
*/
|
|
5149
|
+
async mintIdentityAssertion(params) {
|
|
5150
|
+
const secret = this.env.OAUTH_ENCRYPTION_SECRET;
|
|
5151
|
+
if (!secret) {
|
|
5152
|
+
throw new Error("[ConsentService] OAUTH_ENCRYPTION_SECRET is unset; refusing to mint an identity assertion (fail-closed)");
|
|
5153
|
+
}
|
|
5154
|
+
const exp = Math.floor(Date.now() / 1000) + 600;
|
|
5155
|
+
const scopesHash = await this.hashScopes(params.scopes);
|
|
5156
|
+
const payload = JSON.stringify({
|
|
5157
|
+
v: 1,
|
|
5158
|
+
sid: params.sid,
|
|
5159
|
+
userDid: params.userDid,
|
|
5160
|
+
provider: params.provider,
|
|
5161
|
+
agentDid: params.agentDid,
|
|
5162
|
+
tool: params.tool,
|
|
5163
|
+
scopesHash,
|
|
5164
|
+
projectId: params.projectId,
|
|
5165
|
+
exp,
|
|
5166
|
+
});
|
|
5167
|
+
const payloadB64 = this.base64UrlEncodeString(payload);
|
|
5168
|
+
const hmac = await this.computeIdentityHmac(secret, payloadB64);
|
|
5169
|
+
const hmacB64 = this.base64UrlEncode(new Uint8Array(hmac));
|
|
5170
|
+
return `${hmacB64}.${payloadB64}`;
|
|
5171
|
+
}
|
|
5172
|
+
/**
|
|
5173
|
+
* Verify a grant-bound identity assertion minted by `mintIdentityAssertion`.
|
|
5174
|
+
*
|
|
5175
|
+
* Returns the payload's `userDid` iff: the HMAC matches (constant-time),
|
|
5176
|
+
* the assertion has not expired, and every bound field (`sid`, `agentDid`,
|
|
5177
|
+
* `tool`, `projectId`, and `scopesHash` recomputed from `expected.scopes`)
|
|
5178
|
+
* equals the caller-supplied `expected` values. Any mismatch, tamper, or
|
|
5179
|
+
* expiry returns `null` — never throws, so callers can treat `null`
|
|
5180
|
+
* uniformly as "no verified identity."
|
|
5181
|
+
*
|
|
5182
|
+
* FAIL-CLOSED, ALWAYS: returns `null` if `OAUTH_ENCRYPTION_SECRET` is unset
|
|
5183
|
+
* or empty. There is deliberately NO dev/test relaxation inside this
|
|
5184
|
+
* helper — mirrors `consent-anchor-bridge.ts`'s `validateCsrfToken`, NOT
|
|
5185
|
+
* `validateCredentialCsrfToken`'s legacy "return true to allow flow to
|
|
5186
|
+
* continue" behavior (the fail-open this PR closes). Any future dev/test
|
|
5187
|
+
* bypass belongs at the call site (see `isDevConsentTestMode`'s
|
|
5188
|
+
* docstring), never as a predictable/empty signing key inside this helper.
|
|
5189
|
+
*/
|
|
5190
|
+
async verifyIdentityAssertion(token, expected) {
|
|
5191
|
+
const secret = this.env.OAUTH_ENCRYPTION_SECRET;
|
|
5192
|
+
if (!secret) {
|
|
5193
|
+
logger.warn("[ConsentService] No OAUTH_ENCRYPTION_SECRET for identity-assertion verification; rejecting (fail-closed)");
|
|
5194
|
+
return null;
|
|
5195
|
+
}
|
|
5196
|
+
try {
|
|
5197
|
+
const dotIndex = token.indexOf(".");
|
|
5198
|
+
if (dotIndex === -1) {
|
|
5199
|
+
logger.warn("[ConsentService] Identity assertion missing separator");
|
|
5200
|
+
return null;
|
|
5201
|
+
}
|
|
5202
|
+
const hmacB64 = token.substring(0, dotIndex);
|
|
5203
|
+
const payloadB64 = token.substring(dotIndex + 1);
|
|
5204
|
+
const payloadJson = this.base64UrlDecodeString(payloadB64);
|
|
5205
|
+
const payload = JSON.parse(payloadJson);
|
|
5206
|
+
if (payload.sid !== expected.sid) {
|
|
5207
|
+
logger.warn("[ConsentService] Identity assertion sid mismatch");
|
|
5208
|
+
return null;
|
|
5209
|
+
}
|
|
5210
|
+
if (payload.agentDid !== expected.agentDid) {
|
|
5211
|
+
logger.warn("[ConsentService] Identity assertion agentDid mismatch");
|
|
5212
|
+
return null;
|
|
5213
|
+
}
|
|
5214
|
+
if (payload.tool !== expected.tool) {
|
|
5215
|
+
logger.warn("[ConsentService] Identity assertion tool mismatch");
|
|
5216
|
+
return null;
|
|
5217
|
+
}
|
|
5218
|
+
if (payload.projectId !== expected.projectId) {
|
|
5219
|
+
logger.warn("[ConsentService] Identity assertion projectId mismatch");
|
|
5220
|
+
return null;
|
|
5221
|
+
}
|
|
5222
|
+
const expectedScopesHash = await this.hashScopes(expected.scopes);
|
|
5223
|
+
if (payload.scopesHash !== expectedScopesHash) {
|
|
5224
|
+
logger.warn("[ConsentService] Identity assertion scopes mismatch");
|
|
5225
|
+
return null;
|
|
5226
|
+
}
|
|
5227
|
+
if (typeof payload.exp !== "number" ||
|
|
5228
|
+
payload.exp < Math.floor(Date.now() / 1000)) {
|
|
5229
|
+
logger.warn("[ConsentService] Identity assertion expired");
|
|
5230
|
+
return null;
|
|
5231
|
+
}
|
|
5232
|
+
// Recompute HMAC and constant-time compare. Kept last so tampering with
|
|
5233
|
+
// any bound field above is already caught by the field checks; this is
|
|
5234
|
+
// the authoritative integrity check.
|
|
5235
|
+
const expectedHmac = await this.computeIdentityHmac(secret, payloadB64);
|
|
5236
|
+
const expectedB64 = this.base64UrlEncode(new Uint8Array(expectedHmac));
|
|
5237
|
+
if (!this.constantTimeCompare(hmacB64, expectedB64)) {
|
|
5238
|
+
logger.warn("[ConsentService] Identity assertion HMAC mismatch");
|
|
5239
|
+
return null;
|
|
5240
|
+
}
|
|
5241
|
+
if (typeof payload.userDid !== "string" || !payload.userDid) {
|
|
5242
|
+
return null;
|
|
5243
|
+
}
|
|
5244
|
+
return payload.userDid;
|
|
5245
|
+
}
|
|
5246
|
+
catch (error) {
|
|
5247
|
+
logger.error("[ConsentService] Identity assertion verification error:", error);
|
|
5248
|
+
return null;
|
|
5249
|
+
}
|
|
5250
|
+
}
|
|
4810
5251
|
/**
|
|
4811
5252
|
* Build full DelegationRecord format request (future format)
|
|
4812
5253
|
*/
|