@agent-native/core 0.205.0 → 0.206.0-nightly-20261009011246
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/a2a/client.d.ts +2 -2
- package/dist/agent/engine/anthropic-engine.d.ts +1 -1
- package/dist/agent/engine/builder-engine.d.ts +1 -1
- package/dist/agent/engine/builder-engine.js +23 -8
- package/dist/agent/engine/error-telemetry.d.ts +5 -0
- package/dist/agent/engine/error-telemetry.js +41 -0
- package/dist/agent/engine/registry.d.ts +1 -6
- package/dist/agent/engine/registry.js +2 -31
- package/dist/agent/model-config.d.ts +26 -14
- package/dist/agent/model-config.js +131 -8
- package/dist/agent/model-version.d.ts +19 -0
- package/dist/agent/model-version.js +153 -0
- package/dist/agent/production-agent.d.ts +1 -0
- package/dist/agent/production-agent.js +24 -6
- package/dist/agent/provider-model-selection.js +6 -4
- package/dist/agent/run-manager.js +5 -11
- package/dist/agent/thread-data-builder.js +14 -6
- package/dist/authorization/check-action.js +8 -4
- package/dist/chat-threads/store.js +22 -9
- package/dist/client/analytics.d.ts +1 -0
- package/dist/client/analytics.js +10 -2
- package/dist/client/chat/runtime.js +18 -0
- package/dist/client/chat-model-groups.d.ts +3 -0
- package/dist/client/error-format.js +10 -7
- package/dist/client/onboarding/use-onboarding.d.ts +12 -0
- package/dist/client/onboarding/use-onboarding.js +491 -4
- package/dist/client/session-replay-privacy.d.ts +8 -0
- package/dist/client/session-replay-privacy.js +8 -0
- package/dist/client/session-replay.d.ts +1 -0
- package/dist/client/session-replay.js +18 -9
- package/dist/client/sse-event-processor.js +10 -6
- package/dist/client/use-chat-models.d.ts +5 -0
- package/dist/client/use-chat-models.js +51 -5
- package/dist/client/use-session.d.ts +8 -1
- package/dist/client/use-session.js +20 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/deploy/build.js +353 -3
- package/dist/file-upload/actions/upload-image.d.ts +2 -2
- package/dist/jobs/backfill-run-history-ownership.d.ts +12 -0
- package/dist/jobs/backfill-run-history-ownership.js +87 -0
- package/dist/jobs/background-automation-runner.js +3 -9
- package/dist/jobs/remote-execution.js +3 -9
- package/dist/jobs/run-history-ownership.d.ts +5 -0
- package/dist/jobs/run-history-ownership.js +16 -0
- package/dist/jobs/run-now.js +2 -3
- package/dist/localization/agent-resources-messages.d.ts +3 -0
- package/dist/localization/agent-resources-messages.js +33 -0
- package/dist/mcp/actions/create-org-service-token.js +5 -7
- package/dist/mcp/build-server.d.ts +23 -18
- package/dist/mcp/build-server.js +220 -212
- package/dist/mcp/connect-route.d.ts +11 -2
- package/dist/mcp/connect-route.js +34 -24
- package/dist/mcp/connect-store.d.ts +9 -13
- package/dist/mcp/connect-store.js +11 -24
- package/dist/mcp/credential-membership.js +17 -6
- package/dist/mcp/oauth-route.d.ts +11 -1
- package/dist/mcp/oauth-route.js +28 -6
- package/dist/mcp/oauth-token.d.ts +12 -2
- package/dist/mcp/oauth-token.js +15 -4
- package/dist/mcp/server.js +9 -5
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/posthog-ai.d.ts +1 -0
- package/dist/observability/posthog-ai.js +20 -8
- package/dist/observability/routes.d.ts +6 -6
- package/dist/observability/traces.js +61 -39
- package/dist/org/app-roles.d.ts +8 -1
- package/dist/org/app-roles.js +29 -9
- package/dist/org/context.js +24 -8
- package/dist/org/index.d.ts +2 -2
- package/dist/org/workspace-app-identity.d.ts +1 -0
- package/dist/org/workspace-app-identity.js +25 -0
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +11 -11
- package/dist/provider-api/actions/provider-api.d.ts +3 -3
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/scripts/agent-engines/list-agent-engines.js +1 -0
- package/dist/server/action-routes.js +8 -1
- package/dist/server/agent-chat-plugin.js +1 -0
- package/dist/server/agent-engine-default-model-route.d.ts +1 -1
- package/dist/server/auth.js +33 -17
- package/dist/server/bearer-credential-refusal.d.ts +23 -0
- package/dist/server/bearer-credential-refusal.js +67 -0
- package/dist/server/better-auth-instance.js +13 -6
- package/dist/server/builder-browser.d.ts +2 -0
- package/dist/server/builder-browser.js +16 -3
- package/dist/server/capture-error.d.ts +1 -0
- package/dist/server/capture-error.js +28 -4
- package/dist/server/complete-text.js +16 -3
- package/dist/server/core-routes-plugin.js +2 -0
- package/dist/server/dev-action-bridge.js +7 -1
- package/dist/server/google-oauth.js +6 -3
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/recap-image-route.js +4 -0
- package/dist/server/request-context.d.ts +16 -0
- package/dist/server/request-context.js +17 -0
- package/dist/shared/agent-chat-context.d.ts +1 -0
- package/dist/shared/agent-chat-context.js +26 -0
- package/dist/shared/index.d.ts +1 -1
- package/dist/shared/index.js +1 -1
- package/dist/shared/reasoning-effort.js +10 -1
- package/dist/shared/ssr-session-bootstrap.d.ts +7 -0
- package/dist/shared/ssr-session-bootstrap.js +21 -0
- package/dist/sharing/access.js +86 -5
- package/dist/templates/builder-code-starter/server/routes/index.get.ts +2 -0
- package/dist/triggers/routes.js +2 -1
- package/docs/content/automation-connectors.mdx +5 -3
- package/docs/content/automations.mdx +78 -49
- package/docs/content/observability.mdx +16 -5
- package/docs/content/organizations-teams-permissions.mdx +44 -2
- package/docs/content/recurring-jobs.mdx +4 -8
- package/docs/content/template-forms.mdx +6 -0
- package/package.json +5 -4
- package/src/templates/builder-code-starter/server/routes/index.get.ts +2 -0
package/dist/mcp/build-server.js
CHANGED
|
@@ -6,6 +6,7 @@ import { isActionContractError, isActionExposedToExternalAgents, isActionHiddenF
|
|
|
6
6
|
import { describeToolResultImages, extractAgentImagesFromActionResult, } from "../agent/tool-result-images.js";
|
|
7
7
|
import { getAppConfig } from "../app-config/store.js";
|
|
8
8
|
import { isMcpActionResult } from "../mcp-client/app-result.js";
|
|
9
|
+
import { implicitServiceOrgRole } from "../org/service-identity.js";
|
|
9
10
|
import { assertServicePrincipalMayCall, assertServicePrincipalMayRun, recordServicePrincipalDenial, ServicePrincipalRefusedError, } from "../org/service-principal-guard.js";
|
|
10
11
|
import { isActionGranted } from "../org/service-principal-policy.js";
|
|
11
12
|
import { writeActionChangeMarker } from "../server/action-change-marker-write.js";
|
|
@@ -21,7 +22,7 @@ import { consumeMcpApprovalGrant, createMcpApprovalGrant, } from "./approval-sto
|
|
|
21
22
|
import { getBuiltinCrossAppTools } from "./builtin-tools.js";
|
|
22
23
|
import { MCP_CONNECT_OAUTH_CLIENT_ID, MCP_CONNECT_SCOPE, } from "./connect-store.js";
|
|
23
24
|
import { MCP_APP_REQUEST_ORIGIN_CSP_SOURCE } from "./embed-app.js";
|
|
24
|
-
import { hasMcpOAuthScope, parseMcpOAuthOrgIdClaim, verifyMcpOAuthAccessToken, } from "./oauth-token.js";
|
|
25
|
+
import { MCP_OAUTH_TOKEN_TYPE, hasMcpOAuthScope, parseMcpOAuthOrgIdClaim, verifyMcpOAuthAccessToken, } from "./oauth-token.js";
|
|
25
26
|
import { mcpToolInputSchema } from "./tool-input-schema.js";
|
|
26
27
|
const PRESERVE_MCP_OBJECT_RESULT = Symbol("preserveMcpObjectResult");
|
|
27
28
|
// A GET action is a query, so its result is the payload the model asked for
|
|
@@ -32,6 +33,18 @@ function returnsQueryPayload(entry) {
|
|
|
32
33
|
return (entry.readOnly === true ||
|
|
33
34
|
(entry.http !== false && entry.http?.method === "GET"));
|
|
34
35
|
}
|
|
36
|
+
function verifiedServiceIdentityForRequest(identity, requestOrgId) {
|
|
37
|
+
const userEmail = identity?.userEmail?.trim();
|
|
38
|
+
const orgId = identity?.orgId;
|
|
39
|
+
if (identity?.identityAssurance !== "service" ||
|
|
40
|
+
!userEmail ||
|
|
41
|
+
typeof orgId !== "string" ||
|
|
42
|
+
orgId !== requestOrgId ||
|
|
43
|
+
!implicitServiceOrgRole({ email: userEmail, orgId, requestOrgId })) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return { userEmail, orgId };
|
|
47
|
+
}
|
|
35
48
|
function hasVerifiedMcpUserIdentity(identity) {
|
|
36
49
|
return (identity?.identityAssurance === "user" &&
|
|
37
50
|
Boolean(identity.userEmail?.trim()));
|
|
@@ -1628,9 +1641,11 @@ export async function createMCPServerForRequest(config, identity, requestMeta) {
|
|
|
1628
1641
|
const scopeVisibleActions = Object.fromEntries(Object.entries(actions).filter(([, entry]) => isActionVisibleForOAuthScope(entry, effectiveIdentity?.oauthScopes)));
|
|
1629
1642
|
const orgIdPromise = resolveMcpIdentityOrgId(effectiveIdentity);
|
|
1630
1643
|
const orgId = await orgIdPromise;
|
|
1644
|
+
const verifiedServiceIdentity = verifiedServiceIdentityForRequest(effectiveIdentity, orgId);
|
|
1631
1645
|
const visibleActions = await runWithRequestContext({
|
|
1632
1646
|
userEmail: effectiveIdentity?.userEmail,
|
|
1633
1647
|
orgId,
|
|
1648
|
+
...(verifiedServiceIdentity ? { verifiedServiceIdentity } : {}),
|
|
1634
1649
|
...(effectiveIdentity?.orgId === null
|
|
1635
1650
|
? { orgScope: "personal" }
|
|
1636
1651
|
: {}),
|
|
@@ -1754,6 +1769,7 @@ export async function createMCPServerForRequest(config, identity, requestMeta) {
|
|
|
1754
1769
|
return runWithRequestContext({
|
|
1755
1770
|
userEmail: effectiveIdentity?.userEmail,
|
|
1756
1771
|
orgId,
|
|
1772
|
+
...(verifiedServiceIdentity ? { verifiedServiceIdentity } : {}),
|
|
1757
1773
|
...(effectiveIdentity?.orgId === null
|
|
1758
1774
|
? { orgScope: "personal" }
|
|
1759
1775
|
: {}),
|
|
@@ -2584,9 +2600,6 @@ async function verifyA2AJwtForMcp(token, resourceUrl) {
|
|
|
2584
2600
|
if (globalSecret) {
|
|
2585
2601
|
const payload = await verifyWithSecret(globalSecret);
|
|
2586
2602
|
if (payload) {
|
|
2587
|
-
const tokenScope = typeof payload.scope === "string" ? payload.scope : undefined;
|
|
2588
|
-
const firstPartyMcp = payload.agent_native_first_party_mcp === true;
|
|
2589
|
-
const locallyIssuedConnectToken = tokenScope === MCP_CONNECT_SCOPE && !firstPartyMcp;
|
|
2590
2603
|
if (orgDomain) {
|
|
2591
2604
|
let organization;
|
|
2592
2605
|
try {
|
|
@@ -2597,19 +2610,18 @@ async function verifyA2AJwtForMcp(token, resourceUrl) {
|
|
|
2597
2610
|
catch (error) {
|
|
2598
2611
|
throw new McpIdentityVerificationUnavailableError(error);
|
|
2599
2612
|
}
|
|
2600
|
-
|
|
2601
|
-
// their active JTI row confirms the stored owner and organization.
|
|
2602
|
-
if (organization?.secret.trim() === globalSecret &&
|
|
2603
|
-
!locallyIssuedConnectToken) {
|
|
2613
|
+
if (organization?.secret.trim() === globalSecret) {
|
|
2604
2614
|
return organizationPrincipalClaims(payload, organization);
|
|
2605
2615
|
}
|
|
2606
2616
|
}
|
|
2617
|
+
const firstPartyMcp = payload.agent_native_first_party_mcp === true;
|
|
2607
2618
|
const hasOrganizationClaim = Object.prototype.hasOwnProperty.call(payload, "org_id") &&
|
|
2608
2619
|
payload.org_id !== null;
|
|
2609
2620
|
const hasOrganizationDomainClaim = Object.prototype.hasOwnProperty.call(payload, "org_domain") &&
|
|
2610
2621
|
payload.org_domain !== null;
|
|
2611
|
-
|
|
2612
|
-
|
|
2622
|
+
if (firstPartyMcp &&
|
|
2623
|
+
!hasOrganizationClaim &&
|
|
2624
|
+
!hasOrganizationDomainClaim) {
|
|
2613
2625
|
return payload;
|
|
2614
2626
|
}
|
|
2615
2627
|
let verifiedOrganization;
|
|
@@ -2660,22 +2672,6 @@ function mcpAudienceList(resource) {
|
|
|
2660
2672
|
}
|
|
2661
2673
|
return out;
|
|
2662
2674
|
}
|
|
2663
|
-
/**
|
|
2664
|
-
* Null while the connect token is active. A revoked or jti-less token is
|
|
2665
|
-
* refused, and unreadable revocation state answers a retryable 503.
|
|
2666
|
-
*/
|
|
2667
|
-
async function refuseInactiveConnectToken(jti) {
|
|
2668
|
-
if (!jti)
|
|
2669
|
-
return { authed: false };
|
|
2670
|
-
const { isJtiRevoked } = await import("./connect-store.js");
|
|
2671
|
-
try {
|
|
2672
|
-
return (await isJtiRevoked(jti)) ? { authed: false } : null;
|
|
2673
|
-
}
|
|
2674
|
-
catch (error) {
|
|
2675
|
-
console.error("[mcp] Connect-token revocation check failed; refusing the token:", error);
|
|
2676
|
-
return { authed: false, unavailable: true };
|
|
2677
|
-
}
|
|
2678
|
-
}
|
|
2679
2675
|
/**
|
|
2680
2676
|
* Records a connect token's use once the request is admitted, so a refused
|
|
2681
2677
|
* call (revoked, removed member, membership check unavailable) never moves
|
|
@@ -2693,48 +2689,131 @@ async function markConnectTokenUsed(jti) {
|
|
|
2693
2689
|
}
|
|
2694
2690
|
}
|
|
2695
2691
|
/**
|
|
2696
|
-
*
|
|
2697
|
-
*
|
|
2698
|
-
*
|
|
2692
|
+
* Connect tokens minted before Connect used the MCP OAuth format: an
|
|
2693
|
+
* A2A-shaped JWT with `scope: "mcp-connect"` and no audience, signed with this
|
|
2694
|
+
* deployment's A2A_SECRET. They are this app's own credentials, not cross-app
|
|
2695
|
+
* assertions, so organization secrets and organization-principal rules never
|
|
2696
|
+
* apply to them: their identity is their stored row.
|
|
2699
2697
|
*/
|
|
2700
|
-
async function
|
|
2701
|
-
|
|
2702
|
-
if (
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2698
|
+
async function verifyLegacyConnectToken(token, claims) {
|
|
2699
|
+
const secret = readDeployCredentialEnv("A2A_SECRET")?.trim();
|
|
2700
|
+
if (!secret || claims.aud !== undefined)
|
|
2701
|
+
return "unverified";
|
|
2702
|
+
const jose = await import("jose");
|
|
2703
|
+
let payload;
|
|
2704
|
+
try {
|
|
2705
|
+
payload = (await jose.jwtVerify(token, new TextEncoder().encode(secret)))
|
|
2706
|
+
.payload;
|
|
2707
2707
|
}
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2708
|
+
catch {
|
|
2709
|
+
// coercion-ok: a bad signature or an expired token is unverified, which verifyAuth refuses unless it is a configured static token.
|
|
2710
|
+
return "unverified";
|
|
2711
|
+
}
|
|
2712
|
+
const orgIdClaim = parseMcpOAuthOrgIdClaim(payload);
|
|
2713
|
+
if (typeof payload.sub !== "string" || !payload.sub || !orgIdClaim) {
|
|
2714
|
+
return "unverified";
|
|
2714
2715
|
}
|
|
2715
|
-
return
|
|
2716
|
+
return {
|
|
2717
|
+
userEmail: payload.sub,
|
|
2718
|
+
orgId: orgIdClaim.orgId,
|
|
2719
|
+
orgDomain: typeof payload.org_domain === "string" ? payload.org_domain : undefined,
|
|
2720
|
+
connect: { jti: typeof payload.jti === "string" ? payload.jti : undefined },
|
|
2721
|
+
...(payload.catalog_scope === "full"
|
|
2722
|
+
? { catalogScope: "full" }
|
|
2723
|
+
: {}),
|
|
2724
|
+
issuedAt: typeof payload.iat === "number" ? payload.iat : undefined,
|
|
2725
|
+
};
|
|
2716
2726
|
}
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2727
|
+
/**
|
|
2728
|
+
* Classifies a bearer token by the credential it declares itself to be, then
|
|
2729
|
+
* verifies it as that. A token claiming to be one this app issued never
|
|
2730
|
+
* reaches the A2A checks, even when it fails to verify: that is
|
|
2731
|
+
* `unverified`. Null means it makes no such claim.
|
|
2732
|
+
*/
|
|
2733
|
+
async function verifyIssuedMcpCredential(token, resourceUrl) {
|
|
2734
|
+
const jose = await import("jose");
|
|
2735
|
+
let claims;
|
|
2736
|
+
try {
|
|
2737
|
+
claims = jose.decodeJwt(token);
|
|
2738
|
+
}
|
|
2739
|
+
catch {
|
|
2740
|
+
// coercion-ok: a token that is not a JWT is not an issued credential; the static-token check still runs.
|
|
2741
|
+
return null;
|
|
2742
|
+
}
|
|
2743
|
+
if (claims.scope === MCP_CONNECT_SCOPE &&
|
|
2744
|
+
claims.agent_native_first_party_mcp !== true) {
|
|
2745
|
+
return verifyLegacyConnectToken(token, claims);
|
|
2720
2746
|
}
|
|
2721
|
-
|
|
2722
|
-
// this app knows. Its `org_domain` claim must not grant that domain's org.
|
|
2723
|
-
if (resolution.status === "missing")
|
|
2747
|
+
if (claims.typ !== MCP_OAUTH_TOKEN_TYPE)
|
|
2724
2748
|
return null;
|
|
2725
|
-
|
|
2749
|
+
const oauth = await verifyMcpOAuthAccessToken(token, resourceUrl);
|
|
2750
|
+
if (!oauth)
|
|
2751
|
+
return "unverified";
|
|
2752
|
+
return {
|
|
2753
|
+
userEmail: oauth.userEmail,
|
|
2754
|
+
orgId: oauth.orgId,
|
|
2755
|
+
orgDomain: oauth.orgDomain,
|
|
2756
|
+
...(oauth.clientId === MCP_CONNECT_OAUTH_CLIENT_ID
|
|
2757
|
+
? { connect: { jti: oauth.jti } }
|
|
2758
|
+
: {}),
|
|
2759
|
+
oauthScopes: oauth.scopes,
|
|
2760
|
+
oauthClientId: oauth.clientId,
|
|
2761
|
+
...(oauth.catalogScope ? { catalogScope: oauth.catalogScope } : {}),
|
|
2762
|
+
issuedAt: oauth.issuedAt,
|
|
2763
|
+
};
|
|
2726
2764
|
}
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
(
|
|
2765
|
+
/**
|
|
2766
|
+
* The one admission path for MCP credentials this app issued. A connect token
|
|
2767
|
+
* is admitted only while its stored row exists, is unrevoked, and names the
|
|
2768
|
+
* token's subject and org claim; the row also supplies the org of a token
|
|
2769
|
+
* without a claim and marks service identities. Every credential then passes
|
|
2770
|
+
* `admitIssuedCredential`.
|
|
2771
|
+
*/
|
|
2772
|
+
async function admitIssuedMcpCredential(credential, requestOrigin) {
|
|
2773
|
+
let stored;
|
|
2774
|
+
if (credential.connect) {
|
|
2775
|
+
if (!credential.connect.jti)
|
|
2776
|
+
return { authed: false, refusal: "invalid" };
|
|
2777
|
+
const { lookupConnectTokenOrg } = await import("./connect-store.js");
|
|
2778
|
+
const lookup = await lookupConnectTokenOrg(credential.connect.jti);
|
|
2779
|
+
if (lookup.status === "unavailable") {
|
|
2780
|
+
return { authed: false, unavailable: true };
|
|
2781
|
+
}
|
|
2782
|
+
if (lookup.status === "revoked") {
|
|
2783
|
+
return { authed: false, refusal: "revoked" };
|
|
2784
|
+
}
|
|
2785
|
+
if (lookup.status === "missing") {
|
|
2786
|
+
return { authed: false, refusal: "unknown-connect-token" };
|
|
2787
|
+
}
|
|
2788
|
+
if (lookup.ownerEmail.trim().toLowerCase() !==
|
|
2789
|
+
credential.userEmail.trim().toLowerCase() ||
|
|
2790
|
+
(credential.orgId !== undefined && lookup.orgId !== credential.orgId)) {
|
|
2791
|
+
return { authed: false, refusal: "identity-mismatch" };
|
|
2792
|
+
}
|
|
2793
|
+
stored = lookup;
|
|
2794
|
+
}
|
|
2795
|
+
const orgId = credential.orgId !== undefined ? credential.orgId : stored?.orgId;
|
|
2796
|
+
const admitted = await admitIssuedCredential({
|
|
2797
|
+
authed: true,
|
|
2798
|
+
identity: {
|
|
2799
|
+
userEmail: credential.userEmail,
|
|
2800
|
+
identityAssurance: stored?.kind === "service" ? "service" : "user",
|
|
2801
|
+
...(orgId !== undefined ? { orgId } : {}),
|
|
2802
|
+
orgDomain: credential.orgDomain,
|
|
2803
|
+
...(credential.oauthScopes
|
|
2804
|
+
? { oauthScopes: credential.oauthScopes }
|
|
2805
|
+
: {}),
|
|
2806
|
+
...(credential.oauthClientId
|
|
2807
|
+
? { oauthClientId: credential.oauthClientId }
|
|
2808
|
+
: {}),
|
|
2809
|
+
},
|
|
2810
|
+
fullSurface: true,
|
|
2811
|
+
fullCatalog: credential.catalogScope === "full",
|
|
2812
|
+
}, requestOrigin, stored, credential.issuedAt);
|
|
2813
|
+
if (admitted.authed && credential.connect) {
|
|
2814
|
+
await markConnectTokenUsed(credential.connect.jti);
|
|
2815
|
+
}
|
|
2816
|
+
return admitted;
|
|
2738
2817
|
}
|
|
2739
2818
|
/**
|
|
2740
2819
|
* Credentials this app issues (MCP OAuth access tokens and connect tokens)
|
|
@@ -2746,14 +2825,9 @@ function matchesStoredConnectTokenIdentity(resolution, input) {
|
|
|
2746
2825
|
*
|
|
2747
2826
|
* They also carry the subject's address, which an email change retires; a
|
|
2748
2827
|
* credential signed for it before the change is refused, Personal or not.
|
|
2749
|
-
*
|
|
2750
|
-
* Shared-secret A2A org claims must resolve against local metadata before they
|
|
2751
|
-
* become request scope. A sibling app's user roster is not this app's roster;
|
|
2752
|
-
* locally issued connect credentials instead use their stored JTI owner and
|
|
2753
|
-
* organization.
|
|
2754
2828
|
*/
|
|
2755
|
-
async function admitIssuedCredential(result, requestOrigin,
|
|
2756
|
-
const admitted = await checkIssuedCredential(result, requestOrigin,
|
|
2829
|
+
async function admitIssuedCredential(result, requestOrigin, storedConnectToken, issuedAt) {
|
|
2830
|
+
const admitted = await checkIssuedCredential(result, requestOrigin, storedConnectToken, issuedAt);
|
|
2757
2831
|
return admitted.authed ? admitServicePrincipal(admitted) : admitted;
|
|
2758
2832
|
}
|
|
2759
2833
|
/**
|
|
@@ -2785,10 +2859,10 @@ async function admitServicePrincipal(result) {
|
|
|
2785
2859
|
}
|
|
2786
2860
|
return error.statusCode === 503
|
|
2787
2861
|
? { authed: false, unavailable: true }
|
|
2788
|
-
: { authed: false };
|
|
2862
|
+
: { authed: false, refusal: "service-principal-inactive" };
|
|
2789
2863
|
}
|
|
2790
2864
|
}
|
|
2791
|
-
async function checkIssuedCredential(result, requestOrigin,
|
|
2865
|
+
async function checkIssuedCredential(result, requestOrigin, storedConnectToken, issuedAt) {
|
|
2792
2866
|
const { checkCredentialEmailRetirement, checkCredentialOrgMembership } = await import("./credential-membership.js");
|
|
2793
2867
|
if (result.identity.userEmail) {
|
|
2794
2868
|
const retirement = await checkCredentialEmailRetirement({
|
|
@@ -2798,7 +2872,7 @@ async function checkIssuedCredential(result, requestOrigin, orgResolution, issue
|
|
|
2798
2872
|
if (retirement !== "current")
|
|
2799
2873
|
return retirement === "unavailable"
|
|
2800
2874
|
? { authed: false, unavailable: true }
|
|
2801
|
-
: { authed: false };
|
|
2875
|
+
: { authed: false, refusal: "email-retired" };
|
|
2802
2876
|
}
|
|
2803
2877
|
const orgId = result.identity.orgId;
|
|
2804
2878
|
if (typeof orgId !== "string" || !orgId)
|
|
@@ -2807,90 +2881,54 @@ async function checkIssuedCredential(result, requestOrigin, orgResolution, issue
|
|
|
2807
2881
|
orgId,
|
|
2808
2882
|
email: result.identity.userEmail,
|
|
2809
2883
|
requestOrigin,
|
|
2810
|
-
...(
|
|
2811
|
-
? { storedConnectToken: orgResolution }
|
|
2812
|
-
: orgResolution.status === "claimed" && orgResolution.storedConnectToken
|
|
2813
|
-
? { storedConnectToken: orgResolution.storedConnectToken }
|
|
2814
|
-
: {}),
|
|
2884
|
+
...(storedConnectToken ? { storedConnectToken } : {}),
|
|
2815
2885
|
});
|
|
2816
2886
|
if (membership === "member")
|
|
2817
2887
|
return result;
|
|
2818
2888
|
return membership === "unavailable"
|
|
2819
2889
|
? { authed: false, unavailable: true }
|
|
2820
|
-
: { authed: false };
|
|
2890
|
+
: { authed: false, refusal: "not-member" };
|
|
2821
2891
|
}
|
|
2822
2892
|
/**
|
|
2823
2893
|
* Verify the inbound auth header. Returns:
|
|
2824
|
-
* - { authed: true, identity } when verified. A
|
|
2894
|
+
* - { authed: true, identity } when verified. A credential this app issued
|
|
2895
|
+
* (an MCP OAuth access token or a connect token) supplies its subject and
|
|
2896
|
+
* org, checked against live membership. A deployment-secret A2A JWT may
|
|
2825
2897
|
* supply its asserted user (`sub`), while an org-secret JWT supplies only
|
|
2826
|
-
* verified organization scope.
|
|
2827
|
-
*
|
|
2828
|
-
*
|
|
2829
|
-
*
|
|
2830
|
-
* - { authed: false } on rejection
|
|
2898
|
+
* verified organization scope. Static-token auth gets identity from
|
|
2899
|
+
* `AGENT_NATIVE_OWNER_EMAIL` or `X-Agent-Native-Owner-Email` (the
|
|
2900
|
+
* `agent-native mcp install` flow). `identity` is undefined only for true
|
|
2901
|
+
* dev-open with no owner hint.
|
|
2902
|
+
* - { authed: false, refusal? } on rejection; `refusal` is set whenever a
|
|
2903
|
+
* presented token was refused.
|
|
2831
2904
|
*
|
|
2832
|
-
*
|
|
2833
|
-
*
|
|
2834
|
-
* `org_domain` to local organization metadata.
|
|
2835
|
-
*
|
|
2836
|
-
*
|
|
2837
|
-
*
|
|
2838
|
-
*
|
|
2905
|
+
* Credentials this app issued are recognized and verified before any A2A
|
|
2906
|
+
* rule runs, so a change to cross-app trust cannot reclassify them. Both A2A
|
|
2907
|
+
* paths bind `org_domain` to local organization metadata. The MCP endpoint
|
|
2908
|
+
* wraps tool runs in `runWithRequestContext({ userEmail, orgId })`. Without
|
|
2909
|
+
* that wrap, the MCP endpoint loses tenant identity and downstream
|
|
2910
|
+
* `accessFilter` / `resolveCredential` calls fall back to platform-wide
|
|
2911
|
+
* defaults.
|
|
2839
2912
|
*
|
|
2840
2913
|
* `ownerEmailHeader` is the forwarded `X-Agent-Native-Owner-Email` value; it
|
|
2841
2914
|
* is consulted ONLY on the static-token / dev-open path (never to influence
|
|
2842
2915
|
* verified JWT identity), so the install flow runs tools as the configured
|
|
2843
2916
|
* owner instead of an unscoped anonymous caller.
|
|
2844
|
-
*
|
|
2845
|
-
* A credential this app issued that names an organization is admitted only
|
|
2846
|
-
* while its subject is still a member, and none is admitted for an address an
|
|
2847
|
-
* email change retired after signing (see `admitIssuedCredential`).
|
|
2848
2917
|
*/
|
|
2849
2918
|
export async function verifyAuth(authHeader, ownerEmailHeader, options = {}) {
|
|
2850
2919
|
const accessTokens = getAccessTokens();
|
|
2851
2920
|
const hasA2ASecret = !!readDeployCredentialEnv("A2A_SECRET")?.trim();
|
|
2852
2921
|
const token = getBearerToken(authHeader);
|
|
2853
2922
|
if (token) {
|
|
2854
|
-
const
|
|
2855
|
-
if (
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
}
|
|
2861
|
-
const orgResolution = await resolveConnectTokenOrgId(oauthIdentity.clientId === MCP_CONNECT_OAUTH_CLIENT_ID
|
|
2862
|
-
? oauthIdentity.jti
|
|
2863
|
-
: undefined, oauthIdentity.orgId);
|
|
2864
|
-
if (orgResolution.status === "unavailable") {
|
|
2865
|
-
return { authed: false, unavailable: true };
|
|
2866
|
-
}
|
|
2867
|
-
if (oauthIdentity.clientId === MCP_CONNECT_OAUTH_CLIENT_ID &&
|
|
2868
|
-
!matchesStoredConnectTokenIdentity(orgResolution, {
|
|
2869
|
-
ownerEmail: oauthIdentity.userEmail,
|
|
2870
|
-
orgId: oauthIdentity.orgId,
|
|
2871
|
-
})) {
|
|
2872
|
-
return { authed: false };
|
|
2873
|
-
}
|
|
2874
|
-
const orgId = orgIdFromConnectTokenResolution(orgResolution);
|
|
2875
|
-
const admitted = await admitIssuedCredential({
|
|
2876
|
-
authed: true,
|
|
2877
|
-
identity: {
|
|
2878
|
-
userEmail: oauthIdentity.userEmail,
|
|
2879
|
-
identityAssurance: "user",
|
|
2880
|
-
...(orgId !== undefined ? { orgId } : {}),
|
|
2881
|
-
orgDomain: oauthIdentity.orgDomain,
|
|
2882
|
-
oauthScopes: oauthIdentity.scopes,
|
|
2883
|
-
oauthClientId: oauthIdentity.clientId,
|
|
2884
|
-
},
|
|
2885
|
-
fullSurface: true,
|
|
2886
|
-
fullCatalog: oauthIdentity.catalogScope === "full",
|
|
2887
|
-
}, options.requestOrigin, orgResolution, oauthIdentity.issuedAt);
|
|
2888
|
-
if (admitted.authed &&
|
|
2889
|
-
oauthIdentity.clientId === MCP_CONNECT_OAUTH_CLIENT_ID) {
|
|
2890
|
-
await markConnectTokenUsed(oauthIdentity.jti);
|
|
2891
|
-
}
|
|
2892
|
-
return admitted;
|
|
2923
|
+
const issued = await verifyIssuedMcpCredential(token, options.resourceUrl);
|
|
2924
|
+
if (issued === "unverified") {
|
|
2925
|
+
return ((await matchStaticAccessToken(token, accessTokens, ownerEmailHeader)) ?? {
|
|
2926
|
+
authed: false,
|
|
2927
|
+
refusal: "invalid",
|
|
2928
|
+
});
|
|
2893
2929
|
}
|
|
2930
|
+
if (issued)
|
|
2931
|
+
return admitIssuedMcpCredential(issued, options.requestOrigin);
|
|
2894
2932
|
}
|
|
2895
2933
|
if (accessTokens.length === 0 && !hasA2ASecret && !token) {
|
|
2896
2934
|
if (options.allowDevOpen === false) {
|
|
@@ -2915,58 +2953,33 @@ export async function verifyAuth(authHeader, ownerEmailHeader, options = {}) {
|
|
|
2915
2953
|
throw error;
|
|
2916
2954
|
}
|
|
2917
2955
|
if (payload) {
|
|
2956
|
+
// Connect tokens this app minted never reach here, so a connect-scoped
|
|
2957
|
+
// token is a sibling app's per-call first-party MCP token.
|
|
2918
2958
|
const tokenScope = typeof payload.scope === "string" ? payload.scope : undefined;
|
|
2919
2959
|
if (tokenScope && tokenScope !== MCP_CONNECT_SCOPE) {
|
|
2920
|
-
return { authed: false };
|
|
2921
|
-
}
|
|
2922
|
-
// Connect-minted tokens (scope === "mcp-connect") carry a random `jti`
|
|
2923
|
-
// and are individually revocable. Only these tokens hit the revoke
|
|
2924
|
-
// store — ordinary A2A delegation JWTs skip the DB lookup entirely so
|
|
2925
|
-
// the hot path is unchanged.
|
|
2926
|
-
if (tokenScope === MCP_CONNECT_SCOPE) {
|
|
2927
|
-
const refused = await refuseInactiveConnectToken(payload.jti);
|
|
2928
|
-
if (refused)
|
|
2929
|
-
return refused;
|
|
2960
|
+
return { authed: false, refusal: "invalid" };
|
|
2930
2961
|
}
|
|
2931
2962
|
const orgIdClaim = parseMcpOAuthOrgIdClaim(payload);
|
|
2932
2963
|
if (!orgIdClaim)
|
|
2933
|
-
return { authed: false };
|
|
2964
|
+
return { authed: false, refusal: "invalid" };
|
|
2934
2965
|
const firstPartyMcp = payload.agent_native_first_party_mcp === true;
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
}
|
|
2942
|
-
if (tokenScope === MCP_CONNECT_SCOPE && !firstPartyMcp) {
|
|
2943
|
-
if (!matchesStoredConnectTokenIdentity(orgResolution, {
|
|
2944
|
-
ownerEmail: typeof payload.sub === "string" ? payload.sub : undefined,
|
|
2945
|
-
orgId: orgIdClaim.orgId,
|
|
2946
|
-
})) {
|
|
2947
|
-
return { authed: false };
|
|
2948
|
-
}
|
|
2949
|
-
}
|
|
2950
|
-
const orgId = orgIdFromConnectTokenResolution(orgResolution);
|
|
2951
|
-
const storedConnectToken = orgResolution.status === "found"
|
|
2952
|
-
? orgResolution
|
|
2953
|
-
: orgResolution.status === "claimed"
|
|
2954
|
-
? orgResolution.storedConnectToken
|
|
2966
|
+
// A first-party token without an org claim was not issued for any org
|
|
2967
|
+
// this app knows. Its `org_domain` or caller email must not grant one.
|
|
2968
|
+
const orgId = orgIdClaim.orgId !== undefined
|
|
2969
|
+
? orgIdClaim.orgId
|
|
2970
|
+
: tokenScope === MCP_CONNECT_SCOPE
|
|
2971
|
+
? null
|
|
2955
2972
|
: undefined;
|
|
2956
|
-
|
|
2973
|
+
return admitServicePrincipal({
|
|
2957
2974
|
authed: true,
|
|
2958
2975
|
identity: {
|
|
2959
2976
|
userEmail: typeof payload.sub === "string" ? payload.sub : undefined,
|
|
2960
|
-
...(
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
: typeof payload.org_id === "string" ||
|
|
2967
|
-
typeof payload.org_domain === "string"
|
|
2968
|
-
? { identityAssurance: "organization" }
|
|
2969
|
-
: {}),
|
|
2977
|
+
...(typeof payload.sub === "string"
|
|
2978
|
+
? { identityAssurance: "user" }
|
|
2979
|
+
: typeof payload.org_id === "string" ||
|
|
2980
|
+
typeof payload.org_domain === "string"
|
|
2981
|
+
? { identityAssurance: "organization" }
|
|
2982
|
+
: {}),
|
|
2970
2983
|
...(orgId !== undefined ? { orgId } : {}),
|
|
2971
2984
|
orgDomain: typeof payload.org_domain === "string"
|
|
2972
2985
|
? payload.org_domain
|
|
@@ -2975,45 +2988,40 @@ export async function verifyAuth(authHeader, ownerEmailHeader, options = {}) {
|
|
|
2975
2988
|
},
|
|
2976
2989
|
fullSurface: true,
|
|
2977
2990
|
fullCatalog: payload.catalog_scope === "full",
|
|
2978
|
-
};
|
|
2979
|
-
// First-party MCP tokens share the connect scope but are minted by a
|
|
2980
|
-
// sibling app per call, so they are cross-app A2A tokens.
|
|
2981
|
-
const admitted = tokenScope === MCP_CONNECT_SCOPE && !firstPartyMcp
|
|
2982
|
-
? await admitIssuedCredential(verified, options.requestOrigin, orgResolution, typeof payload.iat === "number" ? payload.iat : undefined)
|
|
2983
|
-
: await admitServicePrincipal(verified);
|
|
2984
|
-
if (admitted.authed && tokenScope === MCP_CONNECT_SCOPE) {
|
|
2985
|
-
await markConnectTokenUsed(payload.jti);
|
|
2986
|
-
}
|
|
2987
|
-
return admitted;
|
|
2988
|
-
}
|
|
2989
|
-
if (accessTokens.length === 0) {
|
|
2990
|
-
// A supplied bearer that failed JWT verification must not fall through to
|
|
2991
|
-
// dev-open auth or reuse a forwarded owner-email hint.
|
|
2992
|
-
return { authed: false };
|
|
2993
|
-
}
|
|
2994
|
-
// Try ACCESS_TOKEN / ACCESS_TOKENS exact match. Static tokens carry no
|
|
2995
|
-
// per-caller claims, so derive identity from the forwarded owner-email
|
|
2996
|
-
// hint (install flow) — otherwise tools would run unscoped. Compare in
|
|
2997
|
-
// constant time (matching the rest of this subsystem's secret-comparison
|
|
2998
|
-
// discipline); node:crypto is imported dynamically because this module is
|
|
2999
|
-
// bundled into the serverless function and avoids static Node-only imports.
|
|
3000
|
-
if (accessTokens.length > 0) {
|
|
3001
|
-
const { timingSafeEqual } = await import("node:crypto");
|
|
3002
|
-
const candidate = Buffer.from(token, "utf8");
|
|
3003
|
-
const matched = accessTokens.some((configured) => {
|
|
3004
|
-
const expected = Buffer.from(configured, "utf8");
|
|
3005
|
-
return (expected.length === candidate.length &&
|
|
3006
|
-
timingSafeEqual(expected, candidate));
|
|
3007
2991
|
});
|
|
3008
|
-
if (matched) {
|
|
3009
|
-
return {
|
|
3010
|
-
authed: true,
|
|
3011
|
-
identity: deriveStaticTokenIdentity(ownerEmailHeader),
|
|
3012
|
-
fullSurface: true,
|
|
3013
|
-
};
|
|
3014
|
-
}
|
|
3015
2992
|
}
|
|
3016
|
-
|
|
2993
|
+
// A supplied bearer that failed JWT verification must not fall through to
|
|
2994
|
+
// dev-open auth or reuse a forwarded owner-email hint.
|
|
2995
|
+
return ((await matchStaticAccessToken(token, accessTokens, ownerEmailHeader)) ?? {
|
|
2996
|
+
authed: false,
|
|
2997
|
+
refusal: "invalid",
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
/**
|
|
3001
|
+
* ACCESS_TOKEN / ACCESS_TOKENS exact match. Static tokens carry no per-caller
|
|
3002
|
+
* claims, so identity comes from the forwarded owner-email hint (install flow)
|
|
3003
|
+
* — otherwise tools would run unscoped. Compared in constant time, matching
|
|
3004
|
+
* the rest of this subsystem's secret-comparison discipline; node:crypto is
|
|
3005
|
+
* imported dynamically because this module is bundled into the serverless
|
|
3006
|
+
* function and avoids static Node-only imports.
|
|
3007
|
+
*/
|
|
3008
|
+
async function matchStaticAccessToken(token, accessTokens, ownerEmailHeader) {
|
|
3009
|
+
if (accessTokens.length === 0)
|
|
3010
|
+
return null;
|
|
3011
|
+
const { timingSafeEqual } = await import("node:crypto");
|
|
3012
|
+
const candidate = Buffer.from(token, "utf8");
|
|
3013
|
+
const matched = accessTokens.some((configured) => {
|
|
3014
|
+
const expected = Buffer.from(configured, "utf8");
|
|
3015
|
+
return (expected.length === candidate.length &&
|
|
3016
|
+
timingSafeEqual(expected, candidate));
|
|
3017
|
+
});
|
|
3018
|
+
if (!matched)
|
|
3019
|
+
return null;
|
|
3020
|
+
return {
|
|
3021
|
+
authed: true,
|
|
3022
|
+
identity: deriveStaticTokenIdentity(ownerEmailHeader),
|
|
3023
|
+
fullSurface: true,
|
|
3024
|
+
};
|
|
3017
3025
|
}
|
|
3018
3026
|
export async function resolveOrgIdFromDomain(orgDomain) {
|
|
3019
3027
|
if (!orgDomain)
|
|
@@ -4,6 +4,14 @@ export interface McpConnectRouteOptions {
|
|
|
4
4
|
appName?: string;
|
|
5
5
|
serverName?: string;
|
|
6
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Neither the request nor configuration names this app's public URL, so no
|
|
9
|
+
* audience `verifyAuth` would accept can be resolved. Guessing one would mint a
|
|
10
|
+
* token the app then refuses.
|
|
11
|
+
*/
|
|
12
|
+
export declare class OrgServiceTokenAppUrlError extends Error {
|
|
13
|
+
constructor();
|
|
14
|
+
}
|
|
7
15
|
/**
|
|
8
16
|
* Mint an ORG SERVICE token: a connect-scoped, revocable bearer whose subject
|
|
9
17
|
* is the synthetic service identity `svc-<name>@service.<orgId>` instead of a
|
|
@@ -14,7 +22,7 @@ export interface McpConnectRouteOptions {
|
|
|
14
22
|
*
|
|
15
23
|
* The token value is returned exactly once and never persisted — only the
|
|
16
24
|
* random `jti` is stored, so the standard revocation path
|
|
17
|
-
* (`
|
|
25
|
+
* (`lookupConnectTokenOrg` in `verifyAuth`) applies to service tokens identically.
|
|
18
26
|
*
|
|
19
27
|
* The `create-org-service-token` action gates on org owner/admin before
|
|
20
28
|
* calling this. Offboarding can remove that admin before the mint, so the
|
|
@@ -27,7 +35,8 @@ export declare function mintOrgServiceToken(params: {
|
|
|
27
35
|
/** The human minting the token — stored for audit, never used as identity. */
|
|
28
36
|
createdBy: string;
|
|
29
37
|
ttlDays?: number;
|
|
30
|
-
|
|
38
|
+
/** The caller's request origin, when there is a request. */
|
|
39
|
+
appUrl: string | undefined;
|
|
31
40
|
}): Promise<{
|
|
32
41
|
token: string;
|
|
33
42
|
jti: string;
|