feat(fe): permissionless app metadata for the authorize flow - #4221
Conversation
Apps that integrate II sign-in can now provide their own display name, description and logo by serving a /.well-known/ii-app-metadata JSON file on their origin. This replaces the curated dapps list shipped with II as the source of display metadata on the authorize (and CLI) flow screens: any app can publish the file without being added to any list. The curated list remains only as a fallback while apps migrate, and the origin's hostname stays visible as the trust anchor throughout. The file is fetched from the app origin with the same hardening as the alternative-origins fetch (no credentials, no redirects, timeout) plus display-specific validation: length limits, control/bidi character stripping, and a same-origin logo that is downloaded (never hotlinked), checked against an image content-type allowlist and a 256 KiB cap, and rendered as a data: URL so the strict img-src CSP stays untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
There was a problem hiding this comment.
Pull request overview
Adds permissionless application metadata to authorization flows while retaining curated metadata as fallback.
Changes:
- Fetches, validates, and caches app-provided names, descriptions, and logos.
- Integrates metadata across authorization and CLI interfaces.
- Documents the metadata specification and adds unit tests.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
CliHeader.svelte |
Uses cached app metadata in CLI authorization. |
RedirectAnimationView.svelte |
Displays app-provided logos during redirect. |
ContinueView.svelte |
Uses app-provided names. |
AuthWizardView.svelte |
Uses metadata in sign-in messaging. |
authorize/+page.svelte |
Uses metadata in the upgrade panel. |
appMetadata.ts |
Fetches and validates metadata and logos. |
appMetadata.test.ts |
Tests metadata validation and fetching. |
app-metadata.store.ts |
Adds per-origin metadata caching and fallback. |
app-metadata.store.test.ts |
Tests store caching and updates. |
AuthorizeHeader.svelte |
Displays metadata and descriptions. |
ii-spec.mdx |
Documents the metadata resource and schema. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- Read both the metadata document and the logo with hard byte caps, rejected up front via Content-Length and enforced chunk-by-chunk while streaming (mirroring the capped reader in authCallbacks.ts), instead of buffering the full attacker-controlled response and measuring afterwards. Caps are now counted in bytes, matching the spec. - Replace AbortSignal.timeout with AbortController + setTimeout (the house pattern, see doh.ts/ssoDiscovery.ts) so the timeout also holds on browsers without AbortSignal.timeout, and keep the timer armed through the body read so a slow origin can't stall the fetch forever. - Count name/description limits in Unicode code points (JSON Schema maxLength semantics) rather than UTF-16 units. - Show the app's hostname badge on the redirect animation next to the (permissionless) app logo, keeping the origin visible as the trust anchor on every screen that renders app-provided identity. - Spec: document the transitional curated-list fallback and that the metadata is always fetched from the origin displayed alongside it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/frontend/src/lib/utils/appMetadata.ts:158
- Both early-return branches clear the abort timer without cancelling the response body. A hostile origin can therefore send an unbounded non-200 response, or claim an oversized
Content-Length, and keep downloading after this function returns, bypassing the documented hard cap. Cancel the stream before returning.
if (response.status !== 200) {
return undefined;
}
const declaredLength = response.headers.get("content-length");
if (declaredLength !== null && Number(declaredLength) > maxBytes) {
src/frontend/src/lib/stores/app-metadata.store.ts:38
- This parameter documentation excludes derivation origins, but
AttributeConsentViewintentionally calls this store throughAuthorizeHeaderwith its displayed, validatedeffectiveOrigin. That also contradicts the new spec’s displayed-origin rule. Describe the displayed origin rather than requiring the channel origin.
* @param origin Origin as shown to the user (e.g. the postMessage channel
* origin), not the derivation origin (which may be remapped).
docs/ii-spec.mdx:352
- The spec says the exact origin is displayed, but the wired components render
new URL(origin).hostname(for example,AuthorizeHeader.svelte:14), which omits the scheme and port even though those distinguish origins. Either display the full origin or describe the actual hostname badge so the security guarantee is accurate.
Internet Identity fetches this document when the authorization flow starts, always from the same origin it displays to the user alongside the metadata — for the authorization screens this is the origin of the client application (`event.origin` of the authorization request); screens that display a validated `derivationOrigin` instead fetch the document from that displayed origin. When the document is missing or invalid, Internet Identity falls back to the curated metadata it ships for a small list of known applications (a transitional mechanism that this document supersedes), and otherwise to displaying the origin's hostname only.
Since the file is under the sole control of the application origin, the metadata is exactly as trustworthy as that origin itself: it does not certify or verify the application's identity in any way. Internet Identity therefore always displays the application's origin alongside this metadata, as the value users can actually verify.
Cancel the response body on the early returns in fetchCapped (non-200 status, oversize declared Content-Length) so a hostile origin can't keep the download running after the cap check; align the store's origin param doc and the spec text with the displayed-origin rule (the badge shows the origin's hostname). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
src/frontend/src/lib/utils/appMetadata.ts:281
TextDecoderuses replacement mode by default, so malformed UTF-8 is silently converted to U+FFFD and may be accepted as valid JSON metadata. JSON exchanged between systems must be UTF-8; use fatal decoding so invalid byte sequences follow the documented malformed-file fallback.
const parsed: unknown = JSON.parse(new TextDecoder().decode(result.body));
Decode the metadata document with a fatal TextDecoder so invalid UTF-8 follows the documented malformed-file fallback instead of smuggling U+FFFD replacement characters onto the sign-in screen, and swap a logo that fails to decode at render time for the default icon (per-value, so a corrected logo still renders later) rather than showing a broken image. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte:190
- This asynchronously fetched value also makes the account-edit baseline mutable. If metadata resolves while an unnamed account's dialog is open,
EditAccountkeeps its initial localnamebut its reactiveaccountprop changes, so the untouched form becomes “changed”; submitting or toggling the default then persists the stale fallback as an explicit account name. Snapshot the fallback used when opening the dialog, or compare against that original value rather than the live metadata-derived value.
const application = $derived($metadataStore.name);
src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte:190
- A valid permissionless name can make account editing invalid: metadata allows 40 code points, but this value is expanded into
My ${application} account, passed toEditAccountfor unnamed accounts, and that form rejects names over 32 UTF-16 units (src/frontend/src/lib/components/views/EditAccount.svelte:81-104). Users then cannot save a default-sign-in-only change without first renaming the account. Keep the account fallback within its 32-character limit or keep remote display metadata out of account labels.
This issue also appears on line 190 of the same file.
const application = $derived($metadataStore.name);
src/frontend/src/lib/utils/appMetadata.ts:190
- The sanitizer misses U+061C ARABIC LETTER MARK, which is part of Unicode's
Bidi_Controlset and can alter the visual ordering of app-provided text. This leaves the stated bidi-spoofing hardening incomplete; strip it alongside the other bidi controls.
/[\u0000-\u0008\u000e-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g,
The "My <app> account" fallback label now only embeds the app name when the resulting label fits EditAccount's 32-character cap — a longer (e.g. metadata-provided) name previously pre-filled the edit dialog with a name the form refuses to save, blocking a default-sign-in-only change. The edit dialog also snapshots the fallback label when it opens, so metadata resolving mid-edit can't shift an unnamed account's baseline and persist the stale fallback as an explicit name. Also strip U+061C (Arabic letter mark), the one bidi control the sanitizer missed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/frontend/src/routes/(new-styling)/authorize/+page.svelte:514
- The permissionless name can be shown here without its matching trust anchor.
upgradePanel()is also rendered aroundSsoNormalLoginRequiredandUpgradeSuccessView, neither of which displays an origin; during attribute consent, the adjacent header may instead displayeffectiveOrigin. Thus a channel origin can supply this name while no matching hostname—or a different hostname—is visible, contrary to the new metadata invariant. Add the channel-origin badge inside this panel whenever the name is used, or keep this copy generic in wrappers that do not display that same origin.
{#if dapp.name !== undefined}
{@const application = dapp.name}
{$t`${application} has moved to the new Internet Identity`}
src/frontend/src/lib/utils/appMetadata.test.ts:133
- The 10-second abort path is a core hardening behavior, but this suite never advances timers or verifies that the request's signal is aborted; the network-error test only covers an immediate rejection. Add a hanging-fetch test so removal or mis-scoping of the timeout cannot silently leave metadata requests pending indefinitely.
test("should return undefined when the fetch fails (e.g. missing CORS headers)", async () => {
setupFetchMock(new TypeError("Failed to fetch"));
expect(await fetchAppMetadata(ORIGIN)).toBeUndefined();
});
|
Note on the red Canister tests check: the failures are Generated by Claude Code |
The upgrade panel is rendered around views that show no origin at all (the post-upgrade success screen, the SSO normal-login fail-safe) or a different one (attribute consent shows the effective/derivation origin), so an app-provided name there could appear with no hostname vouching for it. The panel now takes `showsAppOrigin` and only names the app in the wrappers whose header shows that same channel origin — the sign-in wizard and the continue screen; the others keep the generic copy. Also cover the fetch timeout, which had no test: one case asserts a hanging origin gets its signal aborted and falls back once the budget elapses, the other that the signal is still live just before it, so the timeout can't be removed or mis-scoped unnoticed. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/frontend/src/lib/utils/appMetadata.ts:193
- This range also removes U+200C ZERO WIDTH NON-JOINER and U+200D ZERO WIDTH JOINER. Those are required for correct shaping in languages such as Persian and for common emoji sequences, so valid names like
Acme 👩💻are silently changed toAcme 👩💻; neither character is a control or bidi-override character covered by the documented sanitization. Preserve these two joiners while continuing to remove zero-width space and bidi marks, and add a regression case for them.
/[\u0000-\u0008\u000e-\u001f\u007f-\u009f\u061c\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g,
The sanitizer's U+200B-U+200F range also swallowed U+200C (ZWNJ) and U+200D (ZWJ), which are not control or bidi characters and do carry meaning: ZWNJ drives correct shaping in scripts such as Persian, and ZWJ holds emoji sequences together, so a name like "Acme <woman><ZWJ><laptop>" silently decayed into two separate emoji. Narrow the range to the zero-width space and the LRM/RLM marks, and add a regression case that fails against the old range. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
|
@copilot thanks — the last two rounds landed three findings that were all worth acting on, including one real bug. Responses below, all pushed. Round 6 — ZWNJ/ZWJ stripped by the sanitizer ( Round 5, item 1 — app name shown without its trust anchor ( Round 5, item 2 — untested abort path ( Still standing by the one thing I declined earlier (here): fetch-time logo decode probing and intrinsic-dimension caps. A pathological logo only degrades the sign-in popup of the origin that served it, and probing would penalise SVG, the most common legitimate logo format. The render-time On the red Canister tests check: the failure is Generated by Claude Code |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/frontend/src/lib/components/ui/AuthorizeHeader.svelte:17
- The badge does not identify the origin that supplied this permissionless metadata:
hostnamedrops both the scheme and any non-default port. For example, metadata fetched fromhttps://example.com:8443is displayed next toexample.com, even though that is a different origin (and principal) fromhttps://example.com. Display the serialized origin, or at minimum all security-relevant origin components, as the trust anchor and cover a non-default-port case in tests.
const metadataStore = $derived(getAppMetadataStore(origin));
docs/ii-spec.mdx:352
- Calling only the hostname “the application's origin” hides security-relevant scheme and port differences. Since the feature supports arbitrary web origins and fetches metadata from the exact origin, the specification should require displaying the serialized origin (for example,
https://example.com:8443) and the UI should follow that requirement.
Since the file is under the sole control of the application origin, the metadata is exactly as trustworthy as that origin itself: it does not certify or verify the application's identity in any way. Internet Identity therefore always displays the application's origin (as its hostname) alongside this metadata, as the value users can actually verify.
src/frontend/src/routes/(new-styling)/authorize/views/RedirectAnimationView.svelte:24
- This redirect view has the same origin-confusion issue as the authorization header: the logo comes from the exact channel origin, but the badge omits its scheme and port. Distinct origins such as
https://example.comandhttps://example.com:8443therefore get the same visible trust anchor. Render the serialized origin (consistently with the other metadata consumers) instead of only.hostname.
const hostname = $derived(new URL($establishedChannelStore.origin).hostname);
|
✅ No security or compliance issues detected. Reviewed everything up to c6812c1. Security Overview
Detected Code Changes
|
… strictly Two changes from review, both about where the permissionless metadata comes from and when it counts. Source of truth. The document is now fetched from the origin the app's identity is derived for -- its validated derivation origin when the request carries one, and the requesting origin otherwise -- instead of from whichever origin a screen happened to display. That is the origin the delegation and the user's II accounts are bound to, so an app publishes the file once and every alternative frontend origin it has certified as its own presents identically, with nothing to keep in sync. It also makes the account labels on the continue screen stable across an app's origins, since accounts live under the same origin. Screens keep displaying the origin the user is signing in from: where the two differ, the metadata's origin has explicitly listed the displayed one in a certified alternative-origins document, so no origin can present itself with metadata of an origin that has not vouched for it. The curated list is still matched on the displayed origin too, since a curated entry names the origins an app signs in from and needn't include the origin it derives from -- otherwise known dapps using a derivation origin would lose their fallback branding during the transition. Strict validation. A field that is present but does not meet the requirements now rejects the whole document rather than being dropped on its own, and the reason is logged naming the field. Half-applied metadata looks like a working integration while quietly missing a name or a logo on a screen the app can't inspect; losing the file outright is visible and points at the fix. Limits are applied to the value as served (in code points) and characters that could make rendered text read differently from its content are rejected rather than stripped, so the published JSON Schema is now exactly what II enforces -- the schema gained the corresponding constraints and lost its "stricter than II" caveat. Failures of the logo *asset* stay non-fatal: a second network request can fail transiently, and losing the name over that would be worse than rendering the app without its logo. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
A `data:` URL puts the whole payload in the JS heap as a base64 string and again in the DOM as an attribute value, which is not where external data belongs. The logo now goes to an `<img>` as a `blob:` URL, so the bytes stay in the browser's blob store, and `img-src` gains `blob:` (same-origin script-minted URLs only, resolved inside the browser). Those bytes are also no longer the app's file. `createImageBitmap` decodes what was downloaded -- so the content-type header is a claim that gets checked, not trusted -- and the bitmap is drawn once into a canvas and encoded again. What reaches the page is therefore an image Internet Identity produced itself: still (an animation is flattened to its first frame), free of whatever else rode along in the original container, capped at 4096 pixels per axis on the way in (a small file can declare enormous dimensions) and scaled to at most 512 on its longest side, which is what the screens render at high density. That costs SVG, which cannot be put through `createImageBitmap` across the browsers this repository supports, so `image/svg+xml` is no longer an accepted content type and apps with a vector logo serve a rasterized copy. Inline SVG was never on the table. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
256 KiB was picked against the logos bundled in this repository, which are a poor reference: they were hand-optimized down to a median of 2.4 KiB (largest 5.5 KiB). An app that simply exports its logo is in another league -- a 1024x1024 PNG with transparency is routinely a few hundred KiB, and larger square exports run past a megabyte. The cap also means less than it did now that the asset is decoded and re-encoded before rendering: it bounds the download, not what the page holds or displays, since what reaches the `<img>` is II's own copy at no more than 512 px on its longest side. The per-axis dimension limit is what guards decode cost, and it stays at 4096. So being generous with bytes is cheap, and the cap shouldn't be why an unoptimized logo is dropped. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
…text Two review points on `appMetadata.ts`. The capped reader accumulated chunks into a `Uint8Array`, so a logo of up to the cap was materialized as a JS buffer on its way to being re-encoded. It now pipes the response through a counting transform into a `Blob` instead: the bytes accumulate in the browser's blob store, only one chunk at a time is a JS buffer, and the blob goes straight to `createImageBitmap`, so the encoded image is never a JS value at all. The cap still bounds the transfer rather than just the result -- erroring the transform aborts the source stream. The metadata document still becomes a string, since 8 KiB of JSON has to be parsed. Refusing every bidi and zero-width character broke exactly the names that need them. The marks U+200E/U+200F/U+061C are how an Arabic name ending in "!" or a Hebrew name with an embedded Latin word gets its neutral characters on the right side; the isolates U+2066-U+2069 are Unicode's recommended way to embed a run of unknown direction; U+200B is a line-break opportunity in Thai and Khmer. None of them can reorder text, so all are now accepted. What is refused is the set that can: the embeddings and overrides U+202A-U+202E (plus control characters and U+FEFF as before). Isolates additionally have to be balanced, so an app's string cannot run past itself into the sentence II renders around it, and a field has to contain at least one visible character, so an all-invisible name is treated as absent instead of displayed as a blank. The app-provided description also renders with `dir="auto"` now, which resolves its direction from its own content and isolates it from the screen. The published JSON Schema follows, and notes the one rule it cannot express (isolate balance). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
Rewrapping the capped stream with the whole original header set attaches a `Content-Length` and `Content-Encoding` that no longer describe it -- harmless for `.blob()`, but only by accident. The content type is the one header the blob actually needs, so that's all that comes along. Also assert what the decoder is handed: `createImageBitmap` must receive a `Blob`, which is what keeps the encoded image out of the JS heap. That property was easy to regress silently. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
Bumps .sources/internetidentity from release-2026-08-07 (c78d1b99) to release-2026-08-21 (4c934d1f) and reruns npm run sync:ii-spec, so the mirrored specification carries the App metadata section this PR's guide links to. Regenerated content: - docs/references/internet-identity-spec.md gains the App metadata section and its JSON Schema, and the alternative origins limit goes from 10 to 100 in all three places it is stated (dfinity/internet-identity#4221 and #4261). Nothing else in the 18 upstream commits touches ii-spec.mdx. - public/references/internet-identity.did picks up IdentityInfo.mcp_config and the reworded mcp_get_config comment. No docs page documents that surface, so no page needed updating alongside it. - docs/references/verifiable-credentials-spec.md is unchanged between the two tags. With the section now mirrored, the guide's spec link gains the #app-metadata anchor it was missing.
## Summary - Adds an **App metadata** section to `docs/guides/authentication/internet-identity.mdx`, between *Alternative origins* and *Common mistakes*, covering the `/.well-known/ii-app-metadata` document that lets an app supply its own name, description, and logo for the Internet Identity sign-in screens. Nothing in the guide covered it: until now the only way to get branded screens was to be in the curated list shipped inside II, which this mechanism replaces. - Placement is deliberate. The section reuses the derivation origin the reader has just configured in *Alternative origins* (the document is fetched from the origin identities are derived for, so an app publishes it once and all of its alternative origins present the same way), and it extends the same `.ic-assets.json5` with CORS entries for the document and the logo. - Content covers what an integrator has to get right: the field limits (40 / 120 code points), the raster-only logo rules (same origin, no SVG, 1 MiB, 4096 px per side, re-encoded by II at up to 512 px), the 8 KiB document cap, `200` with no redirect, and the 10 second budget. It also states that a single invalid field drops the whole document and that II names the offending field in the browser console, since that is the first thing someone debugging missing metadata needs to know. - Ends with a note that the metadata is exactly as trustworthy as the origin serving it and verifies nothing about the app, which is why II keeps the origin on screen next to it. - Links the specification by anchor (`#app-metadata`) rather than at the page root. ## Scope This PR is now a documentation change only: `docs/guides/authentication/internet-identity.mdx`, +56 / -1. Earlier revisions of this branch also carried a bump of `.sources/internetidentity` to `release-2026-08-21` and the regenerated spec files, because the mirrored specification did not yet have the section this guide links to. #350 has since landed the identical sync on main, so rebasing dropped those changes as already applied. The anchor works against main as it stands. `docs/references/internet-identity-spec.md` on main now carries the App metadata section and its JSON Schema, and states the alternative origins limit as 100 (upstream dfinity/internet-identity#4221 and #4261, both in `release-2026-08-21`). ## Relationship to #349 #349 (merged) raised the same limit in the guide prose, on line 572. This branch inserts a section further down, so the two do not overlap. With #350 also merged, the guide and the specification now agree on 100. ## Structural decisions - **Heading level.** Added as an `##` section so it sits as a sibling of *Alternative origins* and *Common mistakes* rather than nesting under either. The mechanism is independent of alternative origins: apps that never set a `derivationOrigin` use it too. ## Verification - Rebased onto main (`b21a283`); the branch is two commits and merges cleanly. - `npm run build` passes from a clean `dist/` (exit 0, 210 pages), and the built `dist/references/internet-identity-spec/index.html` contains `id="app-metadata"`, so the guide's anchor resolves against main's mirrored spec. - `node scripts/validate.js --all`: no errors in the changed file. The 4 reported errors are pre-existing, in `docs/guides/digital-assets/chain-key-tokens.mdx` and `docs/guides/backends/data-persistence.mdx`, both untouched here. - Limits and behaviour in the guide text were taken from the merged implementation (`src/frontend/src/lib/utils/appMetadata.ts` in dfinity/internet-identity), not from memory, and match the mirrored spec section now on main. - The `:::note` in the new section renders as a plain `div` rather than a styled callout. So does the page's pre-existing note under *Alternative origins*: the built page contains no `starlight-aside` elements at all, so this is existing site behaviour and not something this change introduces. Left as is to match the page; happy to open a separate issue if the directive handling should be fixed. --------- Co-authored-by: Claude <noreply@anthropic.com>
…4281) # Motivation App metadata (#4221) is fetched from the origin the app's identity is derived for. When that is a canister gateway origin, `remapToLegacyDomain` has already collapsed it onto `ic0.app`, so a canister derives one principal whether the user arrived via `ic0.app`, `icp0.io` or `icp.net`. A canister need not be served on all three. The staging test app answers `400 client_domain_canister_mismatch` at `.ic0.app` while serving the document at `.icp0.io`. The fetch failed, the failure was silent by design, and the screens fell back to the curated entry, so an app that had published the file correctly still showed as unbranded with nothing to indicate why. Principal derivation needs that remap. The metadata document does not: it has no bearing on derivation, and the spec is explicit that it carries no principal-derivation security semantics. # Changes - **`appMetadataOrigins(origin)`** inverts the remap: for `https://<sub>.ic0.app` it yields that origin followed by the `icp0.io` and `icp.net` twins, preserving a `.raw` label. Any other origin (a custom domain, localhost) yields just itself. All three twins resolve to the same canister, so this widens **where** the document may be served, never **whose** document is used. - **`fetchAppMetadata`** tries those origins in turn and stops at the first that answers. - **The same-origin rule for the logo now follows the document**: it is validated against whichever origin actually served it, so a twin's document may reference a logo on that twin, and one pointing back at the remapped origin is rejected exactly as any other cross-origin reference is. - **`fetchCapped` reports the status it saw** rather than collapsing every failure to `undefined`, which is what lets the caller distinguish the two kinds of failure below. - **Both directions of the gateway-origin mapping now share one regex** (`GATEWAY_ORIGIN_REGEX` in `urlUtils`): `remapToLegacyDomain` normalizes onto `ic0.app` with it and `gatewayOriginTwins` inverts it, so the set of gateway domains and the shape of a canister subdomain are stated once. `remapToLegacyDomain`'s behaviour is unchanged. - **The spec documents the new discovery contract** (`docs/ii-spec.mdx`, App metadata): which origins are asked and in what order, which statuses settle the question and which move on, and that the logo's same-origin requirement is relative to the domain that served the document rather than the one that was requested. ## When a twin is tried Only when the origin did not answer for the app: | Outcome at an origin | Next | | --- | --- | | `200`, document valid | done, metadata applied | | `200`, document invalid or unparseable | done, no metadata: the origin answered | | `404` | done, no metadata: the canister says it has no such file | | `400`, `5xx`, other non-`200` | try the next twin | | no response (network error, CORS rejection, redirect, timeout) | try the next twin | The `404` case is what keeps this cheap. All three domains resolve to the same canister, so a canister that says "no such file" has answered for every one of them, and the ordinary case of an app that publishes nothing still costs exactly one request. Only an app in the broken position above pays for extra attempts. # Tests 11 new cases in `appMetadata.test.ts` (48 total in the file): - `appMetadataOrigins`: twins for a remapped origin, `.raw` preserved, no twins invented for a custom domain. - Fallback to `icp0.io` on the exact reported failure (`400 client_domain_canister_mismatch` at `ic0.app`, document at `icp0.io`), fallback on to `icp.net`, and fallback when the first origin cannot be reached at all. - No twin tried on `404`, and none tried when a document is served but fails validation, each asserting the request count. - Every origin failing yields no metadata after three attempts. - A twin's logo resolves against that twin; a twin document whose logo points at the remapped origin is rejected. Verification: - `npx vitest run src/frontend/src/lib/utils/appMetadata.test.ts`: 47 passed, 1 failed. The failure is `should fetch a same-origin logo and render it from a blob url`, which asserts `expect.any(Blob)` against a Blob from a different realm. It reproduces identically on `main` with this change stashed (36 passed, 1 failed), so it is a pre-existing sandbox issue and not a regression here. - `npx vitest run src/frontend/src/lib/utils/validateDerivationOrigin.test.ts`: 25 passed, covering the `remapToLegacyDomain` rewrite. - `eslint --max-warnings 0`, `prettier --check` and `tsc --project tsconfig.all.json --noEmit` are clean on the changed source files. - The e2e suite was not run locally (it needs a replica and the built canisters); CI covers it. # Note on sequencing I understand the plan was to ship fuller support for this in about a week. This is the narrow fix for the reported symptom only, so it should be easy to drop or fold in if it overlaps with that work. --- _Generated by [Claude Code](https://claude.ai/code/session_01Xb4gLgdkcBhNJdByGQhiLc)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
…#4302) # Motivation The MCP connect screen ("Connect _mcp.example.com_") asks the user to hand an AI agent a standing delegation to their identity. What it says about the server it is connecting is the origin in its heading and nothing else: the server has no way to point the user at its privacy policy or its terms of service on the screen where the decision is actually made. Apps already publish their own presentation permissionlessly, in the `/.well-known/ii-app-metadata` document introduced by #4221. This extends that document with the two legal links and renders them on the connect screen, which until now consumed no app metadata at all. # Changes - **Two new optional fields**, `privacyPolicyUrl` and `termsOfServiceUrl`, validated in `$lib/utils/appMetadata.ts` like the rest of the document: absent is fine, present-and-invalid rejects the whole document with a console warning naming the field, and a document carrying nothing but a policy URL is still usable. - **They may point at any origin.** Unlike the logo, II never fetches these documents — it renders a link the user opens themselves — so the same-origin rule that keeps the sign-in private from third parties does not apply, and requiring it would only lock out the many apps whose policies live on a separate domain. What is pinned instead is the scheme: `https`, which rules out `javascript:`, `data:` and anything else a link must never carry, along with plain `http` — II's production CSP (`connect-src 'self' https:`) means it would never have read the document over `http` to begin with, and the only screen rendering these links takes its origin from `parseMcpServerUrl`, which is https-only. An app served over `http` in local development publishes neither field. - **The fine print above "Allow access"** becomes the server's own legal links, replacing the "Revoke access anytime in your settings" note: `Review <name>'s Privacy Policy and Terms of Service.`, in three translatable variants so a server publishing one link reads correctly. A server that publishes neither gets no fine print, and the spacing goes with it. Both links open in a new tab, so the connect request in this one isn't lost. - **The name comes from the same document**, falling back to the host when the server publishes none — it only ever says whose documents these are. The metadata is exactly as trustworthy as the origin serving it, which is why the verified host stays in the heading above, unchanged. - **Spec**: `docs/ii-spec.mdx` documents the fields, the scheme rule and its local-development exception, and extends the published JSON Schema. The schema preamble now names two requirements it cannot express (isolate balance, and the policy-URL scheme) rather than one. # Tests - Unit tests in `appMetadata.test.ts` for any-origin acceptance (with nothing fetched for it), relative resolution against the origin that served the document, a policy-URL-only document, and the scheme rejections: `http` both cross-origin and on the app's own origin, `javascript:`, `data:`, `mailto:`, unparseable, and empty. - E2E: the MCP fixture gains `serveAppMetadata`, and `mcp.spec.ts` covers the links rendering with the published hrefs and staying pinned above the CTA when the panel scrolls, plus a server publishing nothing showing no fine print. The "keeps the consent in view" spec no longer asserts on the revoke link this removes; that sticky-footer coverage moved to the new spec. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Motivation
The app name, description and logo shown on the authorize-flow screens ("Continue to App", the sign-in header, the redirect animation, the CLI header) are currently sourced from a curated
dapps.jsonlist shipped inside II itself. Apps not on that list fall back to a bare hostname, and getting on the list requires a change to this repository. This PR replaces that with a permissionless mechanism: any app can provide its own display metadata, with no client-library changes and no involvement from the II team.Changes
/.well-known/ii-app-metadata(JSON with optionalname,description,logo) on their own origin, mirroring the existing/.well-known/ii-alternative-originspattern. Works for any web origin, not just ICP canisters, since the file has no principal-derivation security semantics.derivationOriginwhen the authorization request carries one, the requesting origin otherwise. An app publishes once, on the origin its principals and its II accounts are bound to, and every alternative frontend origin it has certified as its own presents identically, with nothing to keep in sync. The screens keep displaying the origin the user signed in from as the trust anchor: where the two differ, the metadata's origin has listed the displayed one in its certified alternative-origins document, so no origin can present itself using metadata of an origin that hasn't vouched for it.$lib/utils/appMetadata.tsfetches and validates the document. Hardened like the alternative-origins fetch (no credentials, no redirects, 10 s budget spanning connection and body) plus:name≤ 40 anddescription≤ 120 Unicode code points, measured on the value as served, so the published JSON Schema is exactly what II enforces. Characters that can reorder text are refused (control characters, the bidi embeddings and overrides U+202A–U+202E, U+FEFF); the bidi marks U+200E/U+200F/U+061C, the isolates U+2066–U+2069 and the zero-width characters U+200B–U+200D are accepted, since mixed-direction and non-Latin names need them (RTL text ending in punctuation, Thai/Khmer line-break opportunities, Persian shaping, emoji sequences). Isolates must be balanced, so an app's string can never run past itself into II's own text, and a field must contain at least one visible character. Whitespace normalization is presentation only, applied after validation.logomust resolve to the same origin as the document (which also rules outdata:/javascript:schemes and keeps the sign-in private from third parties), be a raster image of an allowed content type, at most 1 MiB and 4096 px per axis.Blob(so the payload stays in the browser's blob store, never a JS buffer), is decoded withcreateImageBitmap— soContent-Typeis a claim that gets checked, not trusted — then drawn once into a canvas, re-encoded at ≤ 512 px on its longest side and handed to the<img>as ablob:URL. What renders is a still image II produced itself;img-srcgainsblob:accordingly.image/svg+xmlis not accepted, because a vector image can't go through that step across the browsers this repo supports.undefinedinstead of an error, so metadata can never break sign-in. Failures of the logo asset cost only the logo, since a second network request can fail transiently.$lib/stores/app-metadata.store.ts: per-origin cached store. Resolves synchronously to the curated-list entry (so known dapps never flash an unbranded screen) and updates in place once the fetched document validates; a valid document replaces the curated entry wholesale (the app owns its presentation). The curated fallback is matched against the displayed origin as well as the derivation origin, since a curated entry names the origins an app signs in from and needn't include the one it derives from. It is a fallback only, never a metadata source, and can be removed once major dapps have migrated.AuthorizeHeader(which now also renders the app-provided description, withdir="auto"so its direction comes from its own content and stays isolated from the screen),AuthWizardView,ContinueView,RedirectAnimationView, the upgrade panel inauthorize/+page.svelte, andCliHeaderall read the store instead ofgetDapps(). The origin badge stays visible everywhere the metadata is shown, and keeps any scheme or port that distinguishes one origin from another (originLabel).docs/ii-spec.mdxwith the requirements, a JSON Schema that mirrors them (noting the one rule it can't express, isolate balance), CORS requirements and security considerations.Not changed: the certified-attributes opt-in gate in
channelHandlers/attributes.ts(a security decision, not display metadata), the legacy VC flow, and the dapps-explorer/manage screens, which still use the curated list.Also note: no lingui message IDs were added or changed, so the translation catalogs are untouched.
Tests
appMetadata.test.ts(37 cases — hardened fetch options, byte caps enforced up front and while streaming, per-field validation and whole-document rejection, the bidi rules in both directions, same-origin logo enforcement including protocol-relative and scheme-downgrade attempts, content-type allowlist, decode failure, dimension cap, downscaling, timeout via fake timers) andapp-metadata.store.test.ts(9 cases — fallback behaviour, wholesale replacement, per-origin caching, derivation-vs-displayed origin, subscriber updates), plusoriginLabelcases inurlUtils.test.ts. 60 tests across the three files.npm run check(tsc + svelte-check): 0 errors, warnings unchanged frommain.npm run lint:eslint,npm run format-checkandcargo fmt --check: clean.npm test: the new tests pass; the only failures in the full suite (iiConnection.test.ts,findWebAuthnFlows.test.ts, chrome-extension spec) are pre-existing in this environment and reproduce identically onmainwithout this change.🤖 Generated with Claude Code
https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo
Generated by Claude Code