From 5ea13cd0470c4fad97c8456f4060487545b52764 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Wed, 17 Jun 2026 12:44:29 +0000 Subject: [PATCH 1/5] feat(fe): add /mcp delegation authorization flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a /mcp page that lets a deploy-configured MCP server obtain an Internet Identity delegation acting as the user's default account at a given application, plus an MCP access device-gate in Settings. - New mcp_server_origin frontend deploy arg, appended to the form-action CSP and exposed to the frontend; /mcp delivers the delegation to that origin only (rejects any other callback origin). - /mcp route: GET-in/redirect-back flow mirroring /cli — McpHero, the combined 'Allow MCP access' authorize screen, success/invalid/error and the MCP-access-not-enabled gate; default account resolved via get_default_account so MCP acts as the same principal /authorize gives. - Settings: new MCP access card + confirm dialog (CLI card untouched). - mcpAuthorizeFunnel analytics; e2e fixture + spec; CSP unit test. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/frontend-arg-helpers.bash | 3 +- .../src/lib/components/ui/McpLogo.svelte | 21 ++ .../src/lib/constants/store.constants.ts | 1 + .../internet_identity_frontend_idl.js | 2 + .../internet_identity_frontend_types.d.ts | 7 + src/frontend/src/lib/globals.ts | 5 + .../src/lib/stores/mcp-access.store.ts | 48 ++++ .../lib/utils/analytics/mcpAuthorizeFunnel.ts | 32 +++ .../(authenticated)/settings/+page.svelte | 2 + .../components/McpAccessSection.svelte | 90 +++++++ .../components/McpConfirmDialog.svelte | 59 ++++ .../routes/(new-styling)/mcp/+layout.svelte | 179 ++++++++++++ .../src/routes/(new-styling)/mcp/+page.svelte | 254 ++++++++++++++++++ .../src/routes/(new-styling)/mcp/+page.ts | 149 ++++++++++ .../mcp/components/McpHero.svelte | 76 ++++++ .../(new-styling)/mcp/mcp-switcher.store.ts | 12 + .../src/routes/(new-styling)/mcp/utils.ts | 129 +++++++++ .../mcp/views/McpAuthorizeView.svelte | 62 +++++ .../mcp/views/McpCloseWindowView.svelte | 22 ++ .../mcp/views/McpDisabledView.svelte | 43 +++ .../mcp/views/McpErrorView.svelte | 27 ++ .../mcp/views/McpInvalidView.svelte | 30 +++ .../tests/e2e-playwright/fixtures/index.ts | 2 + .../tests/e2e-playwright/fixtures/mcp.ts | 135 ++++++++++ .../tests/e2e-playwright/routes/mcp.spec.ts | 253 +++++++++++++++++ .../internet_identity_frontend.did | 5 + .../local_test_arg.did.template | 1 + src/internet_identity_frontend/src/main.rs | 61 ++++- .../tests/integration/http.rs | 1 + .../src/internet_identity/types.rs | 6 + 30 files changed, 1711 insertions(+), 6 deletions(-) create mode 100644 src/frontend/src/lib/components/ui/McpLogo.svelte create mode 100644 src/frontend/src/lib/stores/mcp-access.store.ts create mode 100644 src/frontend/src/lib/utils/analytics/mcpAuthorizeFunnel.ts create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/McpAccessSection.svelte create mode 100644 src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/McpConfirmDialog.svelte create mode 100644 src/frontend/src/routes/(new-styling)/mcp/+layout.svelte create mode 100644 src/frontend/src/routes/(new-styling)/mcp/+page.svelte create mode 100644 src/frontend/src/routes/(new-styling)/mcp/+page.ts create mode 100644 src/frontend/src/routes/(new-styling)/mcp/components/McpHero.svelte create mode 100644 src/frontend/src/routes/(new-styling)/mcp/mcp-switcher.store.ts create mode 100644 src/frontend/src/routes/(new-styling)/mcp/utils.ts create mode 100644 src/frontend/src/routes/(new-styling)/mcp/views/McpAuthorizeView.svelte create mode 100644 src/frontend/src/routes/(new-styling)/mcp/views/McpCloseWindowView.svelte create mode 100644 src/frontend/src/routes/(new-styling)/mcp/views/McpDisabledView.svelte create mode 100644 src/frontend/src/routes/(new-styling)/mcp/views/McpErrorView.svelte create mode 100644 src/frontend/src/routes/(new-styling)/mcp/views/McpInvalidView.svelte create mode 100644 src/frontend/tests/e2e-playwright/fixtures/mcp.ts create mode 100644 src/frontend/tests/e2e-playwright/routes/mcp.spec.ts diff --git a/scripts/frontend-arg-helpers.bash b/scripts/frontend-arg-helpers.bash index 453bc71689..42329f4328 100644 --- a/scripts/frontend-arg-helpers.bash +++ b/scripts/frontend-arg-helpers.bash @@ -72,7 +72,7 @@ build_frontend_install_arg() { } # All known fields with their defaults (null for missing/new canisters) - local -a field_names=(backend_canister_id backend_origin related_origins fetch_root_key analytics_config dummy_auth dev_csp featured_dashboard_apps) + local -a field_names=(backend_canister_id backend_origin related_origins fetch_root_key analytics_config dummy_auth dev_csp featured_dashboard_apps mcp_server_origin) local -A current_values=( [backend_canister_id]="null" [backend_origin]="null" @@ -82,6 +82,7 @@ build_frontend_install_arg() { [dummy_auth]="null" [dev_csp]="null" [featured_dashboard_apps]="null" + [mcp_server_origin]="null" ) if [ -z "$raw_config" ]; then diff --git a/src/frontend/src/lib/components/ui/McpLogo.svelte b/src/frontend/src/lib/components/ui/McpLogo.svelte new file mode 100644 index 0000000000..e9bcc3d3e8 --- /dev/null +++ b/src/frontend/src/lib/components/ui/McpLogo.svelte @@ -0,0 +1,21 @@ + + + + diff --git a/src/frontend/src/lib/constants/store.constants.ts b/src/frontend/src/lib/constants/store.constants.ts index 71500d16db..db5b7d1a42 100644 --- a/src/frontend/src/lib/constants/store.constants.ts +++ b/src/frontend/src/lib/constants/store.constants.ts @@ -2,6 +2,7 @@ export const storeLocalStorageKey = { LastUsedIdentities: "ii-last-used-identities", Locale: "ii-locale", CliAccess: "ii-cli-access", + McpAccess: "ii-mcp-access", } as const; export type StoreLocalStorageKey = diff --git a/src/frontend/src/lib/generated/internet_identity_frontend_idl.js b/src/frontend/src/lib/generated/internet_identity_frontend_idl.js index e281588b9d..bb07822114 100644 --- a/src/frontend/src/lib/generated/internet_identity_frontend_idl.js +++ b/src/frontend/src/lib/generated/internet_identity_frontend_idl.js @@ -17,6 +17,7 @@ export const idlFactory = ({ IDL }) => { 'backend_origin' : IDL.Text, 'dev_csp' : IDL.Opt(IDL.Bool), 'dummy_auth' : IDL.Opt(IDL.Opt(DummyAuthConfig)), + 'mcp_server_origin' : IDL.Opt(IDL.Text), 'feature_flags' : IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Text, IDL.Bool))), }); const HeaderField = IDL.Tuple(IDL.Text, IDL.Text); @@ -56,6 +57,7 @@ export const init = ({ IDL }) => { 'backend_origin' : IDL.Text, 'dev_csp' : IDL.Opt(IDL.Bool), 'dummy_auth' : IDL.Opt(IDL.Opt(DummyAuthConfig)), + 'mcp_server_origin' : IDL.Opt(IDL.Text), 'feature_flags' : IDL.Opt(IDL.Vec(IDL.Tuple(IDL.Text, IDL.Bool))), }); return [InternetIdentityFrontendInit]; diff --git a/src/frontend/src/lib/generated/internet_identity_frontend_types.d.ts b/src/frontend/src/lib/generated/internet_identity_frontend_types.d.ts index d0640df8a2..cc50cc8bb3 100644 --- a/src/frontend/src/lib/generated/internet_identity_frontend_types.d.ts +++ b/src/frontend/src/lib/generated/internet_identity_frontend_types.d.ts @@ -44,6 +44,13 @@ export interface InternetIdentityFrontendInit { 'backend_origin' : string, 'dev_csp' : [] | [boolean], 'dummy_auth' : [] | [[] | [DummyAuthConfig]], + /** + * Origin of the trusted MCP server, e.g. "https://mcp.id.ai" (no trailing + * slash). The /mcp delegation flow delivers the delegation to this origin + * only (it's added to the form-action CSP and /mcp rejects callbacks on any + * other origin). When unset, the /mcp flow is disabled. + */ + 'mcp_server_origin' : [] | [string], /** * Frontend feature flag overrides keyed by flag name, e.g. * record { "EMAIL_RECOVERY"; true }. Sets the deployment-level baseline for diff --git a/src/frontend/src/lib/globals.ts b/src/frontend/src/lib/globals.ts index d885e2fbf3..d78c570bde 100644 --- a/src/frontend/src/lib/globals.ts +++ b/src/frontend/src/lib/globals.ts @@ -119,3 +119,8 @@ export const getPrimaryOrigin = () => // `undefined` when the operator didn't configure this flag. export const getConfiguredFeatureFlag = (name: string): boolean | undefined => frontendCanisterConfig.feature_flags[0]?.find(([key]) => key === name)?.[1]; + +// Origin of the trusted MCP server configured via the canister deploy args, or +// `undefined` when unset (in which case the `/mcp` delegation flow is disabled). +export const getMcpServerOrigin = (): string | undefined => + frontendCanisterConfig.mcp_server_origin[0]; diff --git a/src/frontend/src/lib/stores/mcp-access.store.ts b/src/frontend/src/lib/stores/mcp-access.store.ts new file mode 100644 index 0000000000..35d7b77503 --- /dev/null +++ b/src/frontend/src/lib/stores/mcp-access.store.ts @@ -0,0 +1,48 @@ +import { derived, get, type Readable } from "svelte/store"; +import { storeLocalStorageKey } from "$lib/constants/store.constants"; +import { writableStored } from "./writable.store"; + +type McpAccessState = { + [identityNumber: string]: boolean; +}; + +type McpAccessStore = Readable & { + isEnabled: (identityNumber: bigint) => boolean; + enable: (identityNumber: bigint) => void; + disable: (identityNumber: bigint) => void; +}; + +export const initMcpAccessStore = (): McpAccessStore => { + const store = writableStored({ + key: storeLocalStorageKey.McpAccess, + defaultValue: {}, + version: 1, + }); + + return { + subscribe: store.subscribe, + isEnabled: (identityNumber) => + get(store)[identityNumber.toString()] === true, + enable: (identityNumber) => { + store.update((state) => ({ + ...state, + [identityNumber.toString()]: true, + })); + }, + disable: (identityNumber) => { + store.update((state) => { + const next = { ...state }; + delete next[identityNumber.toString()]; + return next; + }); + }, + }; +}; + +export const mcpAccessStore = initMcpAccessStore(); + +/** Reactive boolean for a specific identity. */ +export const isMcpAccessEnabledStore = ( + identityNumber: bigint, +): Readable => + derived(mcpAccessStore, (state) => state[identityNumber.toString()] === true); diff --git a/src/frontend/src/lib/utils/analytics/mcpAuthorizeFunnel.ts b/src/frontend/src/lib/utils/analytics/mcpAuthorizeFunnel.ts new file mode 100644 index 0000000000..2a380bfded --- /dev/null +++ b/src/frontend/src/lib/utils/analytics/mcpAuthorizeFunnel.ts @@ -0,0 +1,32 @@ +import { Funnel } from "./Funnel"; + +/** + * /mcp authorize flow events (the funnel prefixes each with `mcp-authorize--`): + * + * start-mcp-authorize (INIT) + * mcp-authorize--request-invalid (fragment missing/malformed, or callback + * origin ≠ configured MCP server origin) + * mcp-authorize--request-received (valid request, authorize screen shown) + * mcp-authorize--confirmed (user clicked Allow access) + * mcp-authorize--access-disabled (MCP access not enabled on this device) + * mcp-authorize--success (MCP server redirected back status=success) + * mcp-authorize--error (MCP server redirected back status=error) + * end-mcp-authorize + * + * The flow navigates away to the MCP server and back, so the success and error + * events fire on a fresh page load — Plausible correlates them with the start + * by visitor session, not by JS lifetime. + */ +export const McpAuthorizeEvents = { + RequestInvalid: "request-invalid", + RequestReceived: "request-received", + Confirmed: "confirmed", + AccessDisabled: "access-disabled", + Success: "success", + Error: "error", +} as const; + +export const mcpAuthorizeFunnel = new Funnel( + "mcp-authorize", + true, +); diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte index cc7ffd1fde..e4d19d83b3 100644 --- a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/+page.svelte @@ -3,6 +3,7 @@ import { Trans } from "$lib/components/locale"; import { t } from "$lib/stores/locale.store"; import CliAccessSection from "./components/CliAccessSection.svelte"; + import McpAccessSection from "./components/McpAccessSection.svelte";
@@ -16,4 +17,5 @@
+
diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/McpAccessSection.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/McpAccessSection.svelte new file mode 100644 index 0000000000..e7033ffdff --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/McpAccessSection.svelte @@ -0,0 +1,90 @@ + + +
+ + +
+
+

+ {$t`MCP access`} +

+ {#if enabled} + + {$t`Enabled`} + + {/if} +
+

+ {#if enabled} + + AI assistants on this device can ask to sign you in to apps. + + {:else} + + Let AI assistants on this device sign in to apps using your identity. + + {/if} +

+
+ +
+ +
+
+ +{#if showConfirm} + (showConfirm = false)} + /> +{/if} diff --git a/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/McpConfirmDialog.svelte b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/McpConfirmDialog.svelte new file mode 100644 index 0000000000..b0d34ff6ee --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/manage/(authenticated)/settings/components/McpConfirmDialog.svelte @@ -0,0 +1,59 @@ + + + +
+ + + + +

+ {$t`Are you sure?`} +

+ +

+ + Enabling this lets AI assistants on this device ask to sign you in to + apps using your identity. + +

+ +
    +
  • + It can send messages or move funds on your behalf. +
  • +
  • + Like any AI, it can hallucinate and make mistakes. +
  • +
+ + + + +
+
diff --git a/src/frontend/src/routes/(new-styling)/mcp/+layout.svelte b/src/frontend/src/routes/(new-styling)/mcp/+layout.svelte new file mode 100644 index 0000000000..72ea27e05b --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/mcp/+layout.svelte @@ -0,0 +1,179 @@ + + +
+
+
+ {#if selectedIdentity !== undefined && $showIdentitySwitcher} + + {#if isIdentityPopoverOpen} + (isIdentityPopoverOpen = false)} + direction="down" + align="end" + distance="0.75rem" + class="!bg-bg-primary" + > + + handleSelectIdentity(identityNumber)} + onUseAnotherIdentity={() => { + isIdentityPopoverOpen = false; + isAuthDialogOpen = true; + }} + onManageIdentity={(): Promise => { + isIdentityPopoverOpen = false; + window.open("/manage", "_blank"); + return Promise.resolve(); + }} + onManageIdentities={() => { + isIdentityPopoverOpen = false; + isManageIdentitiesDialogOpen = true; + }} + onError={(error) => { + isIdentityPopoverOpen = false; + handleError(error); + }} + onClose={() => (isIdentityPopoverOpen = false)} + /> + + {/if} + {#if isAuthDialogOpen} + (isAuthDialogOpen = false)}> + { + isAuthDialogOpen = false; + handleError(error); + }} + > +

+ {$t`Sign in`} +

+

+ {$t`Choose method to continue`} +

+
+
+ {/if} + {/if} +
+ +
+ {@render children()} +
+ +
+
+
+ +{#if isManageIdentitiesDialogOpen} + (isManageIdentitiesDialogOpen = false)}> + + +{/if} diff --git a/src/frontend/src/routes/(new-styling)/mcp/+page.svelte b/src/frontend/src/routes/(new-styling)/mcp/+page.svelte new file mode 100644 index 0000000000..e1ed1c135d --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/mcp/+page.svelte @@ -0,0 +1,254 @@ + + +{#if phase.kind === "invalid"} + +{:else if phase.kind === "error"} + +{:else if phase.kind === "wizard" && params.kind === "valid" && mcpServerHost !== undefined} +
+ + + +

+ {$t`Choose method`} +

+

+ {$t`to allow MCP access to ${params.app}`} +

+
+
+
+{:else if phase.kind === "authorize" && params.kind === "valid" && mcpServerHost !== undefined} + +{:else if phase.kind === "mcp-disabled"} + +{:else if phase.kind === "close"} + +{/if} diff --git a/src/frontend/src/routes/(new-styling)/mcp/+page.ts b/src/frontend/src/routes/(new-styling)/mcp/+page.ts new file mode 100644 index 0000000000..0654efa950 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/mcp/+page.ts @@ -0,0 +1,149 @@ +import type { PageLoad } from "./$types"; +import { fromBase64URL } from "$lib/utils/utils"; + +/** Default delegation lifetime in minutes when the request omits `ttl`. */ +const DEFAULT_TTL_MINUTES = 60; + +/** + * The `/mcp` request, parsed from the URL fragment the MCP server redirects the + * browser to. `valid` carries the validated request — the session public key to + * delegate to, the callback to post the delegation back to, the single-use + * `state` echoed back to the MCP server, the delegation TTL, and the app whose + * account the delegation acts as. `invalid` means the fragment was missing or + * malformed. + * + * The callback's origin is checked against the configured MCP server origin in + * the page component (where the canister config is available), not here. + */ +export type McpParams = + | { + kind: "valid"; + /** base64url-encoded DER session pubkey supplied by the MCP server. */ + publicKey: string; + callback: string; + /** Opaque value echoed back to the MCP server so it can tie the delivered + * delegation to the request it started (CSRF protection). */ + state: string; + ttlMinutes: number; + /** Hostname of the app whose account the delegation acts as. */ + app: string; + } + | { kind: "invalid" }; + +/** + * Outcome the MCP server redirects back with after receiving the delegation. + * `success` and `error` arrive on their own on a fresh page load. + */ +export type McpStatus = "success" | "error"; + +const parseStatus = (raw: string | null): McpStatus | undefined => { + if (raw === "success" || raw === "error") { + return raw; + } + return undefined; +}; + +const parseBase64Url = (raw: string | null): string | undefined => { + if (raw === null || raw === "") { + return undefined; + } + try { + fromBase64URL(raw); + return raw; + } catch { + return undefined; + } +}; + +/** + * Structural callback check: must be an absolute http(s) URL. The exact origin + * match against the configured MCP server origin happens in the component — the + * canister config isn't available here (this `load` also runs at prerender). + */ +const parseCallback = (raw: string | null): string | undefined => { + if (raw === null || raw === "") { + return undefined; + } + let url: URL; + try { + url = new URL(raw); + } catch { + return undefined; + } + if (url.protocol !== "https:" && url.protocol !== "http:") { + return undefined; + } + return raw; +}; + +/** + * Returns the normalised hostname if `raw` is a bare hostname (optionally with + * mixed case), or undefined if it's not. Rejects port, path, query, fragment, + * scheme prefix, and userinfo by requiring the round-trip through `new URL` to + * leave only the hostname behind. + */ +const parseApp = (raw: string | null): string | undefined => { + if (raw === null || raw === "") { + return undefined; + } + let url: URL; + try { + url = new URL(`https://${raw}`); + } catch { + return undefined; + } + if (url.hostname.toLowerCase() !== raw.toLowerCase()) { + return undefined; + } + return url.hostname; +}; + +const parseState = (raw: string | null): string | undefined => { + if (raw === null || raw === "") { + return undefined; + } + return raw; +}; + +const parseTtl = (raw: string | null): number | undefined => { + if (raw === null) { + return DEFAULT_TTL_MINUTES; + } + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + return undefined; + } + return Math.floor(parsed); +}; + +export const load: PageLoad = ({ + url, +}): { params: McpParams; status: McpStatus | undefined } => { + // The MCP server redirects the browser here with the request in the URL + // fragment (never sent to the server, so the session key and callback stay + // out of II's request logs; the address-bar copy is cleared in `+page.svelte` + // via `replaceState`). Reading `url.hash` relies on this universal `load` + // re-running client-side; with `adapter-static` it's empty during prerender. + const params = new URLSearchParams(url.hash.slice(1)); + + const status = parseStatus(params.get("status")); + const publicKey = parseBase64Url(params.get("public_key")); + const callback = parseCallback(params.get("callback")); + const state = parseState(params.get("state")); + const app = parseApp(params.get("app")); + const ttlMinutes = parseTtl(params.get("ttl")); + + if ( + publicKey === undefined || + callback === undefined || + state === undefined || + app === undefined || + ttlMinutes === undefined + ) { + return { params: { kind: "invalid" }, status }; + } + return { + params: { kind: "valid", publicKey, callback, state, ttlMinutes, app }, + status, + }; +}; diff --git a/src/frontend/src/routes/(new-styling)/mcp/components/McpHero.svelte b/src/frontend/src/routes/(new-styling)/mcp/components/McpHero.svelte new file mode 100644 index 0000000000..c15589c20b --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/mcp/components/McpHero.svelte @@ -0,0 +1,76 @@ + + + +
+
+
+ {#if dapp?.logoSrc !== undefined} + {`${dapp.name} + {:else} + + {/if} +
+ + + +
+ +
+ + +
+ +
+ + + + +
+
diff --git a/src/frontend/src/routes/(new-styling)/mcp/mcp-switcher.store.ts b/src/frontend/src/routes/(new-styling)/mcp/mcp-switcher.store.ts new file mode 100644 index 0000000000..eebc9ba3bc --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/mcp/mcp-switcher.store.ts @@ -0,0 +1,12 @@ +import { writable } from "svelte/store"; + +/** + * Whether the identity switcher in the /mcp layout header should be shown. + * + * The page sets this to `true` only during the sign-in phases (wizard, + * authorize) and `false` on the terminal screens (close, error, invalid, + * mcp-disabled), where switching identity is meaningless. It lives in a shared + * store because the switcher is rendered by the layout but its relevance is + * owned by the page's phase. + */ +export const showIdentitySwitcher = writable(false); diff --git a/src/frontend/src/routes/(new-styling)/mcp/utils.ts b/src/frontend/src/routes/(new-styling)/mcp/utils.ts new file mode 100644 index 0000000000..73086303ed --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/mcp/utils.ts @@ -0,0 +1,129 @@ +import type { Authenticated } from "$lib/stores/authentication.store"; +import { DelegationChain, ECDSAKeyIdentity } from "@icp-sdk/core/identity"; +import { remapToLegacyDomain } from "$lib/utils/iiConnection"; +import { + fromBase64URL, + retryFor, + throwCanisterError, + transformSignedDelegation, +} from "$lib/utils/utils"; + +interface McpAuthorizeInput { + authenticated: Authenticated; + /** base64url-encoded DER session pubkey supplied by the MCP server. */ + publicKey: string; + /** Hostname of the app whose account the delegation acts as. */ + app: string; + /** Lifetime in minutes. */ + ttlMinutes: number; + /** Callback URL (on the configured MCP server origin) the delegation chain is + * form-POSTed to. */ + callback: string; + /** Opaque value from the request, echoed back so the MCP server can tie the + * delivered delegation to the request it started. */ + state: string; +} + +/** + * Builds a two-hop delegation chain rooted at the user's identity and ending at + * the MCP server's public key, then delivers it to the MCP server's callback + * via a top-level form-POST navigation. The MCP server reads the post and + * redirects the browser back to `/mcp` with a `status` so this page keeps + * owning the UI. + * + * The chain is derived for the app's account: `get_default_account` resolves + * the user's default account for the app origin, and that account number (which + * may be the unreserved default or a specific one the user set) is what the + * delegation is bound to — so the MCP server acts as the same account a normal + * `/authorize` sign-in to the app would use, not the legacy anchor-seed + * principal a blind `null` produces. + * + * The canister only ever signs a delegation to a freshly-generated, + * non-extractable browser key — never to the public_key from the URL fragment + * (which is attacker-controllable). The MCP server's public key only enters the + * chain via a sub-delegation signed by the ephemeral key. + */ +export const mcpAuthorize = async ({ + authenticated, + publicKey, + app, + ttlMinutes, + callback, + state, +}: McpAuthorizeInput): Promise => { + const { identityNumber, actor } = authenticated; + // Remap an app domain on a gateway (*.icp0.io / *.icp.net) to *.ic0.app so + // the principal matches the one /authorize derives for the same app. + const effectiveOrigin = remapToLegacyDomain(`https://${app}`); + const maxTimeToLiveNanos = BigInt(ttlMinutes) * BigInt(60) * BigInt(1e9); + + const { account_number } = await actor + .get_default_account(identityNumber, effectiveOrigin) + .then(throwCanisterError); + + const ephemeralIdentity = await ECDSAKeyIdentity.generate({ + extractable: false, + }); + const ephemeralPublicKey = new Uint8Array( + ephemeralIdentity.getPublicKey().toDer(), + ); + + const { user_key, expiration } = await actor + .prepare_account_delegation( + identityNumber, + effectiveOrigin, + account_number, + ephemeralPublicKey, + [maxTimeToLiveNanos], + ) + .then(throwCanisterError); + + const canisterChain = await retryFor(5, () => + actor + .get_account_delegation( + identityNumber, + effectiveOrigin, + account_number, + ephemeralPublicKey, + expiration, + ) + .then(throwCanisterError) + .then(transformSignedDelegation) + .then((delegation) => + DelegationChain.fromDelegations([delegation], new Uint8Array(user_key)), + ), + ); + + // Sub-delegate from the ephemeral key to the MCP server's public key. The + // expiration matches the canister-signed delegation so the chain expires as a + // whole. + const mcpPubKey = fromBase64URL(publicKey); + const expirationDate = new Date(Number(expiration / BigInt(1_000_000))); + const chain = await DelegationChain.create( + ephemeralIdentity, + { toDer: () => mcpPubKey }, + expirationDate, + { previous: canisterChain }, + ); + + // Submit as a top-level navigation to the configured MCP server origin (the + // only origin allowed by the `form-action` CSP). The MCP server redirects + // back to /mcp with a status param. + const form = document.createElement("form"); + form.method = "POST"; + form.action = callback; + const delegationInput = document.createElement("input"); + delegationInput.type = "hidden"; + delegationInput.name = "delegation"; + delegationInput.value = JSON.stringify(chain.toJSON()); + form.appendChild(delegationInput); + // Echo the state so the MCP server can match this delivery to the request it + // started. + const stateInput = document.createElement("input"); + stateInput.type = "hidden"; + stateInput.name = "state"; + stateInput.value = state; + form.appendChild(stateInput); + document.body.appendChild(form); + form.submit(); +}; diff --git a/src/frontend/src/routes/(new-styling)/mcp/views/McpAuthorizeView.svelte b/src/frontend/src/routes/(new-styling)/mcp/views/McpAuthorizeView.svelte new file mode 100644 index 0000000000..b879ebf40a --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/mcp/views/McpAuthorizeView.svelte @@ -0,0 +1,62 @@ + + +
+ + + +

+ {$t`Allow MCP access`} +

+

+ {$t`Let ${mcpServer} act as ${accountScope}`} +

+ + +
+
diff --git a/src/frontend/src/routes/(new-styling)/mcp/views/McpCloseWindowView.svelte b/src/frontend/src/routes/(new-styling)/mcp/views/McpCloseWindowView.svelte new file mode 100644 index 0000000000..77642ced29 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/mcp/views/McpCloseWindowView.svelte @@ -0,0 +1,22 @@ + + + +
+ + + +

+ {$t`You're signed in`} +

+

+ {$t`You can close this window.`} +

+
diff --git a/src/frontend/src/routes/(new-styling)/mcp/views/McpDisabledView.svelte b/src/frontend/src/routes/(new-styling)/mcp/views/McpDisabledView.svelte new file mode 100644 index 0000000000..ace15d728e --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/mcp/views/McpDisabledView.svelte @@ -0,0 +1,43 @@ + + + +
+ + + + + +

+ {$t`MCP access not enabled`} +

+

+ + For security, Internet Identity blocks MCP clients from signing in to + apps using your identity. + +

+

+ Enable MCP access for this device, then try again. +

+ + + + {$t`Manage your identity`} + +
+
diff --git a/src/frontend/src/routes/(new-styling)/mcp/views/McpErrorView.svelte b/src/frontend/src/routes/(new-styling)/mcp/views/McpErrorView.svelte new file mode 100644 index 0000000000..2f78fa1a50 --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/mcp/views/McpErrorView.svelte @@ -0,0 +1,27 @@ + + + +
+ + + + +

+ {$t`Something went wrong`} +

+

+ The MCP client couldn't finish connecting. +

+

+ + Return to your MCP client and try again. You can close this window. + +

+
+
diff --git a/src/frontend/src/routes/(new-styling)/mcp/views/McpInvalidView.svelte b/src/frontend/src/routes/(new-styling)/mcp/views/McpInvalidView.svelte new file mode 100644 index 0000000000..7c7bf465aa --- /dev/null +++ b/src/frontend/src/routes/(new-styling)/mcp/views/McpInvalidView.svelte @@ -0,0 +1,30 @@ + + + +
+ + + + +

+ {$t`Invalid request`} +

+

+ This connection link is missing information or has been changed. +

+

+ + Start the connection again from your MCP client. You can close this + window. + +

+
+
diff --git a/src/frontend/tests/e2e-playwright/fixtures/index.ts b/src/frontend/tests/e2e-playwright/fixtures/index.ts index b7263d6a74..09b9aaf916 100644 --- a/src/frontend/tests/e2e-playwright/fixtures/index.ts +++ b/src/frontend/tests/e2e-playwright/fixtures/index.ts @@ -11,6 +11,7 @@ import { test as manageAccessPageTest } from "./manageAccessPage"; import { test as manageRecoveryPageTest } from "./manageRecoveryPage"; import { test as emailRecoveryTest } from "./emailRecovery"; import { test as cliTest } from "./cli"; +import { test as mcpTest } from "./mcp"; export const test = mergeTests( inertWorkaroundTest, @@ -25,4 +26,5 @@ export const test = mergeTests( manageRecoveryPageTest, emailRecoveryTest, cliTest, + mcpTest, ); diff --git a/src/frontend/tests/e2e-playwright/fixtures/mcp.ts b/src/frontend/tests/e2e-playwright/fixtures/mcp.ts new file mode 100644 index 0000000000..ab7b2f454b --- /dev/null +++ b/src/frontend/tests/e2e-playwright/fixtures/mcp.ts @@ -0,0 +1,135 @@ +import { Ed25519KeyIdentity } from "@icp-sdk/core/identity"; +import { test as base, type Page } from "@playwright/test"; +import { toBase64URL } from "../../../src/lib/utils/utils"; +import { II_URL } from "../utils"; + +/** What the MCP server stand-in does on its next form POST. */ +type McpOutcome = "success" | "error"; + +/** + * The origin the e2e canister is deployed with as `mcp_server_origin` (see + * `local_test_arg.did.template`). The `/mcp` page only accepts a callback on + * this origin and the `form-action` CSP only allows posting to it. + */ +const MCP_SERVER_ORIGIN = "https://mcp.id.ai"; + +/** + * Stands in for a remote MCP server. Unlike the CLI loopback fixture there's no + * real HTTP server: the configured MCP origin is a public https origin, so the + * delegation arrives as a top-level form-POST navigation to it. We intercept + * that navigation with `page.route` (which catches it before the network, so no + * server or DNS for `mcp.id.ai` is needed), read the posted delegation, and + * fulfill a 303 redirect back to `/mcp` with a `status` — exactly what a real + * MCP server would do. + * + * `receivedDelegation` resolves with the delegation chain JSON once the flow + * successfully posts it. + */ +export type McpFixture = { + publicKey: string; + state: string; + mcpOrigin: string; + callbackUrl: string; + receivedDelegation: Promise; + /** Every delegation received so far, in order (for multi-post tests). */ + receivedDelegations: unknown[]; + /** Sets what the MCP server stand-in does on its next form POST. */ + setNextOutcome: (outcome: McpOutcome) => void; + /** + * Installs the form-POST interceptor on `page`. Must be called before the + * flow submits the delegation. Reads the posted `delegation`/`state` and + * redirects the browser back to `/mcp` with the configured outcome status. + */ + installInterceptor: (page: Page) => Promise; + /** Builds the `/mcp` authorize URL with the request params in the fragment. */ + buildAuthorizeUrl: (opts: { + app: string; + ttlMinutes?: number; + callbackUrl?: string; + }) => string; +}; + +export const test = base.extend<{ mcp: McpFixture }>({ + // eslint-disable-next-line no-empty-pattern -- playwright fixtures require the destructure + mcp: async ({}, use) => { + const identity = Ed25519KeyIdentity.generate(); + const publicKey = toBase64URL( + new Uint8Array(identity.getPublicKey().toDer()), + ); + // Opaque value the MCP server puts in the request and the frontend echoes + // back in its POST so the server can tie the delivery to the request. + const state = toBase64URL(crypto.getRandomValues(new Uint8Array(32))); + const callbackUrl = `${MCP_SERVER_ORIGIN}/callback`; + + let resolveDelegation: (body: unknown) => void = () => undefined; + const receivedDelegation = new Promise((resolve) => { + resolveDelegation = resolve; + }); + const receivedDelegations: unknown[] = []; + + let nextOutcome: McpOutcome = "success"; + const setNextOutcome = (outcome: McpOutcome): void => { + nextOutcome = outcome; + }; + + const installInterceptor = async (page: Page): Promise => { + const redirectTo = (status: McpOutcome): string => { + const url = new URL("/mcp", II_URL); + url.hash = new URLSearchParams({ status }).toString(); + return url.toString(); + }; + + await page.route(`${MCP_SERVER_ORIGIN}/**`, async (route) => { + let location: string; + const posted = new URLSearchParams(route.request().postData() ?? ""); + if (nextOutcome === "error" || posted.get("state") !== state) { + // On an "error" outcome, or if the frontend didn't echo the request + // state (a real MCP server would reject this), redirect back with an + // error so the test fails loudly instead of `receivedDelegation` + // hanging forever. + location = redirectTo("error"); + } else { + const delegation = posted.get("delegation"); + let parsed: unknown; + try { + parsed = delegation === null ? null : JSON.parse(delegation); + } catch { + parsed = delegation; + } + receivedDelegations.push(parsed); + resolveDelegation(parsed); + location = redirectTo("success"); + } + await route.fulfill({ status: 303, headers: { location } }); + }); + }; + + const buildAuthorizeUrl = (opts: { + app: string; + ttlMinutes?: number; + callbackUrl?: string; + }): string => { + const fragment = new URLSearchParams(); + fragment.set("public_key", publicKey); + fragment.set("callback", opts.callbackUrl ?? callbackUrl); + fragment.set("state", state); + fragment.set("app", opts.app); + if (opts.ttlMinutes !== undefined) { + fragment.set("ttl", String(opts.ttlMinutes)); + } + return `${II_URL}/mcp#${fragment.toString()}`; + }; + + await use({ + publicKey, + state, + mcpOrigin: MCP_SERVER_ORIGIN, + callbackUrl, + receivedDelegation, + receivedDelegations, + setNextOutcome, + installInterceptor, + buildAuthorizeUrl, + }); + }, +}); diff --git a/src/frontend/tests/e2e-playwright/routes/mcp.spec.ts b/src/frontend/tests/e2e-playwright/routes/mcp.spec.ts new file mode 100644 index 0000000000..b08c837ed6 --- /dev/null +++ b/src/frontend/tests/e2e-playwright/routes/mcp.spec.ts @@ -0,0 +1,253 @@ +import { expect, type Page } from "@playwright/test"; +import { Principal } from "@icp-sdk/core/principal"; +import { test } from "../fixtures"; +import { addVirtualAuthenticator, authorize, II_URL } from "../utils"; + +/** The app the MCP delegation acts as, used across the tests. */ +const APP = "nice-name.com"; + +/** Decodes a hex string (as delegation chains encode public keys) to bytes. */ +const hexToBytes = (hex: string): Uint8Array => + Uint8Array.from(hex.match(/.{1,2}/g) ?? [], (byte) => parseInt(byte, 16)); + +const signUp = async (page: Page): Promise => { + const continueWithPasskey = page.getByRole("button", { + name: "Continue with passkey", + }); + const signUpToggle = page.getByRole("button", { + name: "Sign up", + exact: true, + }); + await continueWithPasskey.or(signUpToggle).first().waitFor(); + if (await continueWithPasskey.isVisible()) { + await continueWithPasskey.click(); + await page.getByRole("button", { name: "Create new identity" }).click(); + } else { + await signUpToggle.click(); + await page.getByRole("button", { name: "Sign up with passkey" }).click(); + } + await page.getByLabel("Identity name").fill("Test User"); + await page.getByRole("button", { name: "Create identity" }).click(); +}; + +/** Returns the chain's first-delegation expiration in milliseconds since epoch. */ +const expirationMillis = (body: unknown): number => { + if ( + typeof body !== "object" || + body === null || + !("delegations" in body) || + !Array.isArray(body.delegations) || + body.delegations.length === 0 + ) { + throw new Error("delegation chain missing or empty"); + } + const expiration: unknown = body.delegations[0]?.delegation?.expiration; + if (typeof expiration !== "string") { + throw new Error("delegation expiration missing"); + } + return Number(BigInt(`0x${expiration}`) / BigInt(1_000_000)); +}; + +/** The hex root public key of a delegation chain (the per-account key). */ +const rootPublicKey = (body: unknown): string => { + if ( + typeof body === "object" && + body !== null && + "publicKey" in body && + typeof body.publicKey === "string" + ) { + return body.publicKey; + } + throw new Error("delegation chain missing publicKey"); +}; + +/** + * Enables device-local MCP access for the signed-in identity via Settings. + * Assumes the page is already on `/manage`. + */ +const enableMcpAccessInSettings = async ( + page: Page, + isMobile: boolean, +): Promise => { + if (isMobile) { + await page.getByRole("button", { name: "Open menu" }).click(); + } + await page.getByRole("link", { name: "Settings" }).click(); + await page.waitForURL(II_URL + "/manage/settings"); + await page.getByRole("switch", { name: "MCP access" }).check(); + await page.getByLabel("I understand the risks.").check(); + await page.getByRole("button", { name: "Enable MCP access" }).click(); + await expect(page.getByText("Enabled", { exact: true })).toBeVisible(); +}; + +test("Invalid params show the error screen", async ({ page }) => { + await page.goto(II_URL + "/mcp"); + await expect( + page.getByRole("heading", { name: "Invalid request" }), + ).toBeVisible(); +}); + +test("A callback off the configured MCP origin is rejected", async ({ + page, + mcp, +}) => { + await page.goto( + mcp.buildAuthorizeUrl({ + app: APP, + callbackUrl: "https://attacker.example.com/cb", + }), + ); + await expect( + page.getByRole("heading", { name: "Invalid request" }), + ).toBeVisible(); +}); + +test("Without MCP access enabled the gated screen shows", async ({ + page, + mcp, +}) => { + await addVirtualAuthenticator(page); + await page.goto(mcp.buildAuthorizeUrl({ app: APP })); + await signUp(page); + await expect( + page.getByRole("heading", { name: "MCP access not enabled" }), + ).toBeVisible(); +}); + +test("Returning user without MCP access lands on the gated screen immediately", async ({ + page, + mcp, +}) => { + await addVirtualAuthenticator(page); + await page.goto(II_URL); + await signUp(page); + await page.waitForURL(II_URL + "/manage"); + + await page.goto(mcp.buildAuthorizeUrl({ app: APP })); + await expect( + page.getByRole("heading", { name: "MCP access not enabled" }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Allow access" })).toBeHidden(); +}); + +test("Once MCP access is enabled, Allow access posts a two-hop delegation chain", async ({ + page, + mcp, + isMobile, +}) => { + await addVirtualAuthenticator(page); + await mcp.installInterceptor(page); + await page.goto(II_URL); + await signUp(page); + await page.waitForURL(II_URL + "/manage"); + await enableMcpAccessInSettings(page, isMobile); + + await page.goto(mcp.buildAuthorizeUrl({ app: APP })); + await page.getByRole("button", { name: "Allow access" }).click(); + + const body = await mcp.receivedDelegation; + expect(body).toMatchObject({ + delegations: expect.any(Array), + publicKey: expect.any(String), + }); + // Rooted at the user's principal: canister-signed delegation to the ephemeral + // browser key, then the ephemeral key's sub-delegation to the MCP server's + // public key. + if ( + typeof body === "object" && + body !== null && + "delegations" in body && + Array.isArray(body.delegations) + ) { + expect(body.delegations.length).toBe(2); + } + await expect( + page.getByRole("heading", { name: "You're signed in" }), + ).toBeVisible(); +}); + +test("Identity switcher shows while signing in and hides on the success screen", async ({ + page, + mcp, + isMobile, +}) => { + await addVirtualAuthenticator(page); + await mcp.installInterceptor(page); + await page.goto(II_URL); + await signUp(page); + await page.waitForURL(II_URL + "/manage"); + await enableMcpAccessInSettings(page, isMobile); + + await page.goto(mcp.buildAuthorizeUrl({ app: APP })); + const switcher = page.getByRole("button", { name: "Switch identity" }); + const allow = page.getByRole("button", { name: "Allow access" }); + await expect(allow).toBeVisible(); + await expect(switcher).toBeVisible(); + + await allow.click(); + await expect( + page.getByRole("heading", { name: "You're signed in" }), + ).toBeVisible(); + await expect(switcher).toBeHidden(); +}); + +test("Requested TTL within bounds is honoured", async ({ + page, + mcp, + isMobile, +}) => { + const ttlMinutes = 60; + await addVirtualAuthenticator(page); + await mcp.installInterceptor(page); + await page.goto(II_URL); + await signUp(page); + await page.waitForURL(II_URL + "/manage"); + await enableMcpAccessInSettings(page, isMobile); + + const before = Date.now(); + await page.goto(mcp.buildAuthorizeUrl({ app: APP, ttlMinutes })); + await page.getByRole("button", { name: "Allow access" }).click(); + + const expMillis = expirationMillis(await mcp.receivedDelegation); + const requestedMillis = ttlMinutes * 60 * 1000; + expect(expMillis - before).toBeGreaterThanOrEqual(requestedMillis - 60_000); + expect(expMillis - before).toBeLessThanOrEqual(requestedMillis + 60_000); +}); + +test("MCP acts as the same principal that /authorize gives for that app", async ({ + page, + mcp, + identities, + signInWithIdentity, + isMobile, +}) => { + const identityNumber = identities[0].identityNumber; + + // Principal the app (nice-name.com) sees via the normal /authorize flow — its + // default account. + const authorizePrincipal = await authorize(page, async (authPage) => { + await signInWithIdentity(authPage, identityNumber); + await authPage + .getByRole("button", { name: "Continue", exact: true }) + .click(); + }); + + // Enable device MCP access for the same identity. + await page.goto(II_URL); + await signInWithIdentity(page, identityNumber); + await page.waitForURL(II_URL + "/manage"); + await enableMcpAccessInSettings(page, isMobile); + + // Principal the MCP server is granted: the self-authenticating principal of + // the delegation chain's root public key. + await mcp.installInterceptor(page); + await page.goto(mcp.buildAuthorizeUrl({ app: APP })); + await page.getByRole("button", { name: "Allow access" }).click(); + const chain = await mcp.receivedDelegation; + const mcpPrincipal = Principal.selfAuthenticating( + hexToBytes(rootPublicKey(chain)), + ).toText(); + + // Same identity + same default account for the app ⇒ same principal. + expect(mcpPrincipal).toBe(authorizePrincipal); +}); diff --git a/src/internet_identity_frontend/internet_identity_frontend.did b/src/internet_identity_frontend/internet_identity_frontend.did index 97dbfd7b59..1aa58824b6 100644 --- a/src/internet_identity_frontend/internet_identity_frontend.did +++ b/src/internet_identity_frontend/internet_identity_frontend.did @@ -63,6 +63,11 @@ type InternetIdentityFrontendInit = record { // each flag; localStorage, the flag's init callback and ?feature_flag_* URL // params still take precedence. Unknown flag names are ignored. feature_flags : opt vec record { text; bool }; + // Origin of the trusted MCP server, e.g. "https://mcp.id.ai" (no trailing + // slash). The /mcp delegation flow delivers the delegation to this origin + // only (it's added to the form-action CSP and /mcp rejects callbacks on any + // other origin). When unset, the /mcp flow is disabled. + mcp_server_origin : opt text; }; service : (InternetIdentityFrontendInit) -> { diff --git a/src/internet_identity_frontend/local_test_arg.did.template b/src/internet_identity_frontend/local_test_arg.did.template index 927e93a104..d12cf13b61 100644 --- a/src/internet_identity_frontend/local_test_arg.did.template +++ b/src/internet_identity_frontend/local_test_arg.did.template @@ -15,5 +15,6 @@ "https://oisy.com"; "https://oc.app"; }; + mcp_server_origin = opt "https://mcp.id.ai"; }, ) diff --git a/src/internet_identity_frontend/src/main.rs b/src/internet_identity_frontend/src/main.rs index 614a7c51f7..50d5ca8837 100644 --- a/src/internet_identity_frontend/src/main.rs +++ b/src/internet_identity_frontend/src/main.rs @@ -37,6 +37,7 @@ fn certify_all_assets(args: InternetIdentityFrontendArgs) { let static_assets = get_static_assets(&args); let related_origins = args.related_origins.as_ref(); let dev_csp = args.dev_csp.unwrap_or(false); + let mcp_server_origin = args.mcp_server_origin.as_deref(); // Extract integrity hashes for inline scripts from HTML files let integrity_hashes = static_assets @@ -84,6 +85,7 @@ fn certify_all_assets(args: InternetIdentityFrontendArgs) { integrity_hashes.clone(), related_origins, dev_csp, + mcp_server_origin, vec![( "cache-control".to_string(), NO_CACHE_ASSET_CACHE_CONTROL.to_string(), @@ -121,6 +123,7 @@ fn certify_all_assets(args: InternetIdentityFrontendArgs) { integrity_hashes.clone(), related_origins, dev_csp, + mcp_server_origin, vec![headers], ), fallback_for: vec![], @@ -146,6 +149,7 @@ fn get_asset_headers( integrity_hashes: Vec, related_origins: Option<&Vec>, dev_csp: bool, + mcp_server_origin: Option<&str>, additional_headers: Vec, ) -> Vec { let credentials_allowlist = if let Some(related_origins) = related_origins { @@ -178,7 +182,12 @@ fn get_asset_headers( // Comprehensive policy to prevent XSS attacks and data injection ( "Content-Security-Policy".to_string(), - get_content_security_policy(integrity_hashes, related_origins, dev_csp), + get_content_security_policy( + integrity_hashes, + related_origins, + dev_csp, + mcp_server_origin, + ), ), // Strict-Transport-Security (HSTS) // Forces browsers to use HTTPS for all future requests to this domain @@ -260,7 +269,7 @@ fn get_asset_headers( /// base-uri 'none': /// Prevents injection of tags that could redirect relative URLs /// -/// form-action 'self' http://127.0.0.1:*: +/// form-action 'self' http://127.0.0.1:* [mcp_server_origin]: /// The CLI authorize flow (`/cli`) delivers the delegation to the CLI's /// loopback callback via a top-level form POST (a top-level navigation /// avoids Chrome's Local Network Access permission prompt that a `fetch` @@ -271,6 +280,10 @@ fn get_asset_headers( /// isn't allowlistable here; the `/cli` parser only accepts 127.0.0.1 to /// match. `localhost` is also excluded (it can resolve off-loopback) — so a /// form can never post to a remote origin. +/// The `/mcp` authorize flow form-POSTs to the configured MCP server, so +/// when `mcp_server_origin` is set that single operator-trusted origin is +/// appended here. It's never a wildcard and `form-action` is never broadened +/// to `https:` — only that exact origin is allowed. /// /// style-src 'self' 'unsafe-inline': /// Allow stylesheets from same origin and inline styles @@ -298,6 +311,7 @@ fn get_content_security_policy( integrity_hashes: Vec, related_origins: Option<&Vec>, dev_csp: bool, + mcp_server_origin: Option<&str>, ) -> String { let connect_src = if dev_csp { // Allow connecting via http for development purposes @@ -330,13 +344,22 @@ fn get_content_security_policy( "'self'".to_string() }; + // The `/mcp` authorize flow delivers the delegation to the configured MCP + // server via a top-level form POST, so that origin must be an allowed + // `form-action` source. It's a single operator-configured origin (a deploy + // arg), never a wildcard — `form-action` is never broadened to `https:`. + let form_action = match mcp_server_origin { + Some(origin) => format!("'self' http://127.0.0.1:* {origin}"), + None => "'self' http://127.0.0.1:*".to_string(), + }; + let csp = format!( "default-src 'none';\ connect-src {connect_src};\ img-src 'self' data: https://*.googleusercontent.com;\ script-src {script_src};\ base-uri 'none';\ - form-action 'self' http://127.0.0.1:*;\ + form-action {form_action};\ style-src 'self' 'unsafe-inline';\ style-src-elem 'self' 'unsafe-inline';\ font-src 'self';\ @@ -514,7 +537,7 @@ mod tests { #[test] fn csp_differs_between_dev_and_prod_for_connect_src_and_upgrade_insecure_requests() { // Dev CSP: allow http: in connect-src and omit upgrade-insecure-requests - let dev_csp = get_content_security_policy(Vec::new(), None, true); + let dev_csp = get_content_security_policy(Vec::new(), None, true, None); assert!( dev_csp.contains("connect-src 'self' https: http:"), @@ -526,7 +549,7 @@ mod tests { ); // Prod CSP: disallow http: in connect-src and include upgrade-insecure-requests - let prod_csp = get_content_security_policy(Vec::new(), None, false); + let prod_csp = get_content_security_policy(Vec::new(), None, false, None); assert!( prod_csp.contains("connect-src 'self' https:"), @@ -541,4 +564,32 @@ mod tests { "prod CSP should include upgrade-insecure-requests, got: {prod_csp}" ); } + + #[test] + fn csp_form_action_includes_configured_mcp_origin_only() { + // Without an MCP origin, form-action stays self + loopback only. + let without = get_content_security_policy(Vec::new(), None, false, None); + assert!( + without.contains("form-action 'self' http://127.0.0.1:*;"), + "form-action should be self + loopback when no MCP origin, got: {without}" + ); + + // With an MCP origin, exactly that origin is appended to form-action. + let with = get_content_security_policy( + Vec::new(), + None, + false, + Some("https://mcp.id.ai"), + ); + assert!( + with.contains("form-action 'self' http://127.0.0.1:* https://mcp.id.ai;"), + "form-action should append the configured MCP origin, got: {with}" + ); + + // form-action is never broadened to a bare https: source. + assert!( + !with.contains("form-action 'self' http://127.0.0.1:* https:;"), + "form-action must not be broadened to https:, got: {with}" + ); + } } diff --git a/src/internet_identity_frontend/tests/integration/http.rs b/src/internet_identity_frontend/tests/integration/http.rs index b319cb5dc3..c7eb78c2e9 100644 --- a/src/internet_identity_frontend/tests/integration/http.rs +++ b/src/internet_identity_frontend/tests/integration/http.rs @@ -63,6 +63,7 @@ fn default_frontend_args() -> InternetIdentityFrontendArgs { dev_csp: None, featured_dashboard_apps: None, feature_flags: None, + mcp_server_origin: None, } } diff --git a/src/internet_identity_interface/src/internet_identity/types.rs b/src/internet_identity_interface/src/internet_identity/types.rs index 3f2ae3530b..3d65d371f0 100644 --- a/src/internet_identity_interface/src/internet_identity/types.rs +++ b/src/internet_identity_interface/src/internet_identity/types.rs @@ -243,6 +243,12 @@ pub struct InternetIdentityFrontendArgs { /// own init callback, and `?feature_flag_*` URL params take precedence. /// Names that don't match a known frontend flag are ignored by the frontend. pub feature_flags: Option>, + /// Origin of the trusted MCP server, e.g. "https://mcp.id.ai" (no trailing + /// slash). The `/mcp` delegation flow delivers the delegation to this origin + /// (and only this origin — it's added to the `form-action` CSP and the + /// `/mcp` page rejects callbacks on any other origin). When unset, the + /// `/mcp` flow is disabled. + pub mcp_server_origin: Option, } /// Config fields that are synchronized between the frontend and backend. From ab9087c03f9b455e1e609ae04eeb9546257658f6 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:19:09 +0000 Subject: [PATCH 2/5] address PR review: move MCP icon, harden CSP origin, guard origin parse - Move the MCP logo from lib/components/ui/McpLogo.svelte to lib/components/icons/McpIcon.svelte, alongside PasskeyIcon/SsoIcon, and match their SVGAttributes/size-5 convention. - main.rs: sanitize mcp_server_origin to a strict scheme://host[:port] origin before splicing into the form-action CSP; drop (fail closed) any value carrying whitespace/';'/','/path/query/fragment/userinfo or a disallowed scheme, so a misconfigured deploy arg can't break or inject the policy. Extend the CSP test with malformed cases. (Copilot) - /mcp +page.svelte: parse the configured origin defensively (try/catch); a malformed value is treated as unset so the route shows the invalid screen instead of throwing during render. (Copilot) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../McpLogo.svelte => icons/McpIcon.svelte} | 18 ++---- .../components/McpAccessSection.svelte | 4 +- .../src/routes/(new-styling)/mcp/+page.svelte | 38 +++++++----- .../mcp/components/McpHero.svelte | 4 +- src/internet_identity_frontend/src/main.rs | 60 ++++++++++++++++--- 5 files changed, 86 insertions(+), 38 deletions(-) rename src/frontend/src/lib/components/{ui/McpLogo.svelte => icons/McpIcon.svelte} (73%) diff --git a/src/frontend/src/lib/components/ui/McpLogo.svelte b/src/frontend/src/lib/components/icons/McpIcon.svelte similarity index 73% rename from src/frontend/src/lib/components/ui/McpLogo.svelte rename to src/frontend/src/lib/components/icons/McpIcon.svelte index e9bcc3d3e8..006739311e 100644 --- a/src/frontend/src/lib/components/ui/McpLogo.svelte +++ b/src/frontend/src/lib/components/icons/McpIcon.svelte @@ -1,21 +1,13 @@ - -
diff --git a/src/frontend/src/routes/(new-styling)/mcp/+page.svelte b/src/frontend/src/routes/(new-styling)/mcp/+page.svelte index e1ed1c135d..92495cb0d9 100644 --- a/src/frontend/src/routes/(new-styling)/mcp/+page.svelte +++ b/src/frontend/src/routes/(new-styling)/mcp/+page.svelte @@ -33,29 +33,39 @@ const params = $derived(data.params); const status = $derived(data.status); - // The MCP server is configured as a deploy arg; when unset the flow is - // disabled. The delegation is delivered to this origin only. - const mcpServerOrigin = getMcpServerOrigin(); - const mcpServerHost = - mcpServerOrigin !== undefined ? new URL(mcpServerOrigin).host : undefined; + // The MCP server is configured as a deploy arg; when unset (or misconfigured) + // the flow is disabled. The delegation is delivered to this origin only. + // Parse it once, defensively: a malformed value is treated as unset so the + // route shows the invalid screen rather than throwing during render. + const mcpServer = ((): { origin: string; host: string } | undefined => { + const raw = getMcpServerOrigin(); + if (raw === undefined) { + return undefined; + } + try { + const url = new URL(raw); + return { origin: url.origin, host: url.host }; + } catch { + return undefined; + } + })(); + const mcpServerHost = mcpServer?.host; // The request's callback must point at the configured MCP server origin. This // mirrors the `form-action` CSP, which only allows that origin — checking it // here lets us show a clean invalid screen instead of a silent CSP block. - const originMatches = ( - callback: string, - configuredOrigin: string, - ): boolean => { + const callbackMatchesMcpServer = (callback: string): boolean => { + if (mcpServer === undefined) { + return false; + } try { - return new URL(callback).origin === new URL(configuredOrigin).origin; + return new URL(callback).origin === mcpServer.origin; } catch { return false; } }; const requestValid = $derived( - params.kind === "valid" && - mcpServerOrigin !== undefined && - originMatches(params.callback, mcpServerOrigin), + params.kind === "valid" && callbackMatchesMcpServer(params.callback), ); const authFlow = new AuthLastUsedFlow(); @@ -162,7 +172,7 @@ }); const handleAuthorize = async (): Promise => { - if (params.kind !== "valid" || mcpServerOrigin === undefined) { + if (params.kind !== "valid" || mcpServer === undefined) { return; } const selected = $lastUsedIdentitiesStore.selected; diff --git a/src/frontend/src/routes/(new-styling)/mcp/components/McpHero.svelte b/src/frontend/src/routes/(new-styling)/mcp/components/McpHero.svelte index c15589c20b..8adb7777bd 100644 --- a/src/frontend/src/routes/(new-styling)/mcp/components/McpHero.svelte +++ b/src/frontend/src/routes/(new-styling)/mcp/components/McpHero.svelte @@ -1,7 +1,7 @@