Skip to content

feat(fe): permissionless app metadata for the authorize flow - #4221

Merged
aterga merged 15 commits into
mainfrom
arshavir/charming-knuth-32obpn
Aug 19, 2026
Merged

feat(fe): permissionless app metadata for the authorize flow#4221
aterga merged 15 commits into
mainfrom
arshavir/charming-knuth-32obpn

Conversation

@aterga

@aterga aterga commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

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.json list 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

  • New well-known resource: apps serve /.well-known/ii-app-metadata (JSON with optional name, description, logo) on their own origin, mirroring the existing /.well-known/ii-alternative-origins pattern. Works for any web origin, not just ICP canisters, since the file has no principal-derivation security semantics.
  • Fetched from the origin the identity is derived for — the validated derivationOrigin when 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.ts fetches and validates the document. Hardened like the alternative-origins fetch (no credentials, no redirects, 10 s budget spanning connection and body) plus:
    • All-or-nothing validation. A field that is present but doesn't meet the requirements rejects the whole document rather than being dropped on its own, and the reason is logged naming the field — a half-applied file looks like a working integration while quietly missing a name or logo on a screen the app can't inspect.
    • name ≤ 40 and description ≤ 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.
    • logo must resolve to the same origin as the document (which also rules out data:/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.
    • The logo is never rendered as served. The response streams through a counting transform into a Blob (so the payload stays in the browser's blob store, never a JS buffer), is decoded with createImageBitmap — so Content-Type is 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 a blob: URL. What renders is a still image II produced itself; img-src gains blob: accordingly. image/svg+xml is not accepted, because a vector image can't go through that step across the browsers this repo supports.
    • Every failure mode (missing file, CORS, timeout, invalid JSON, undecodable logo, …) yields undefined instead 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.
    • The document itself is capped at 8 KiB.
  • $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.
  • UI wiring: AuthorizeHeader (which now also renders the app-provided description, with dir="auto" so its direction comes from its own content and stays isolated from the screen), AuthWizardView, ContinueView, RedirectAnimationView, the upgrade panel in authorize/+page.svelte, and CliHeader all read the store instead of getDapps(). The origin badge stays visible everywhere the metadata is shown, and keeps any scheme or port that distinguishes one origin from another (originLabel).
  • Spec: new "App metadata" section in docs/ii-spec.mdx with 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

  • New unit 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) and app-metadata.store.test.ts (9 cases — fallback behaviour, wholesale replacement, per-origin caching, derivation-vs-displayed origin, subscriber updates), plus originLabel cases in urlUtils.test.ts. 60 tests across the three files.
  • The published JSON Schema was validated against the documented behaviour (accepted and rejected samples, including RTL, Thai and emoji names).
  • npm run check (tsc + svelte-check): 0 errors, warnings unchanged from main. npm run lint:eslint, npm run format-check and cargo 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 on main without this change.
  • Existing playwright e2e assertions (e.g. "My Test Dapp account") remain valid via the curated-list fallback: the metadata fetch 404s against the test app and falls back exactly as before.

🤖 Generated with Claude Code

https://claude.ai/code/session_01ApQTUxtZxjzpFsoJqKCXxo


Generated by Claude Code

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/frontend/src/lib/utils/appMetadata.ts Outdated
Comment thread src/frontend/src/lib/utils/appMetadata.ts Outdated
Comment thread src/frontend/src/lib/utils/appMetadata.ts Outdated
Comment thread src/frontend/src/lib/components/ui/AuthorizeHeader.svelte Outdated
Comment thread docs/ii-spec.mdx Outdated
Comment thread src/frontend/src/lib/utils/appMetadata.ts Outdated
- 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 AttributeConsentView intentionally calls this store through AuthorizeHeader with its displayed, validated effectiveOrigin. 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • TextDecoder uses 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));

Comment thread src/frontend/src/lib/utils/appMetadata.ts
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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, EditAccount keeps its initial local name but its reactive account prop 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 to EditAccount for 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_Control set 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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 around SsoNormalLoginRequired and UpgradeSuccessView, neither of which displays an origin; during attribute consent, the adjacent header may instead display effectiveOrigin. 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();
});

aterga commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator Author

Note on the red Canister tests check: the failures are e2e-playwright (mobile, 6_6) / (mobile, 3_6) — the legacy-domain popup test (legacy.spec.ts:52) and the gated-SSO 1-click test (sso.spec.ts:1129) timing out on mobile emulation. These are pre-existing flakes, not caused by this PR: the main-branch run for 21ab317 — this PR's exact base commit, without any of these changes — failed with the same two jobs, and both tests pass on desktop and passed on this branch's earlier waves (fd6faf9, c3108bf) with identical relevant code. Everything else (Rust, frontend checks, all canister integration tests, the other 11 e2e shards) is green on every commit of this PR. I'm re-running the affected jobs until the flake clears.


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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 to Acme 👩💻; 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

aterga commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

@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 (4ee96e9). You were right, and this was a genuine bug rather than a nit. - swallowed U+200C and U+200D, neither of which is a control or bidi character: ZWNJ drives correct shaping in scripts such as Persian, and ZWJ holds emoji sequences together, so Acme 👩‍💻 silently decayed into Acme 👩💻. The range is now `` — zero-width space plus LRM/RLM — and there's a regression case for both joiners. I checked it actually guards: it fails against the old range and passes against the new one.

Round 5, item 1 — app name shown without its trust anchor (dcf8827). Confirmed by reading the wrappers: UpgradeSuccessView renders no origin at all and SsoNormalLoginRequired shows only the SSO org name, so on those screens a permissionless name had nothing vouching for it, and during attribute consent the header shows the effective/derivation origin instead. I took your second suggested option: upgradePanel 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); upgrade-success, the SSO fail-safe and attribute consent keep the generic "This app has moved…" copy.

Round 5, item 2 — untested abort path (dcf8827). Added two fake-timer cases: a hanging origin has its signal aborted and falls back once the budget elapses, and the signal is still live one millisecond before it — so the timeout can't be removed or mis-scoped without a failure. The budget is now exported as APP_METADATA_FETCH_TIMEOUT_MILLIS so the tests assert against the real value.

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 onerror fallback plus the content-type allowlist and 256 KiB cap cover the user-visible failure.

On the red Canister tests check: the failure is sso.spec.ts:1129 (gated non-sub SSO), which fails identically on main at this PR's base commit 21ab317 — see that run's shard 3. Pre-existing flake, re-running.


Generated by Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: hostname drops both the scheme and any non-default port. For example, metadata fetched from https://example.com:8443 is displayed next to example.com, even though that is a different origin (and principal) from https://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.com and https://example.com:8443 therefore 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);

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

@aterga
aterga marked this pull request as ready for review August 18, 2026 11:15
@aterga
aterga requested a review from a team as a code owner August 18, 2026 11:15
@aterga
aterga requested a review from sea-snake August 18, 2026 11:15
@zeropath-ai

zeropath-ai Bot commented Aug 18, 2026

Copy link
Copy Markdown

No security or compliance issues detected. Reviewed everything up to c6812c1.

Security Overview
Detected Code Changes
Change Type Relevant files
Enhancement ► src/frontend/src/lib/components/ui/AuthorizeHeader.svelte
    Modify to use app-metadata store and display app metadata (logo, name, description) with improved rendering and fallback behavior
► src/frontend/src/lib/stores/app-metadata.store.ts
    Add per-origin app metadata store with fallback to curated dapps and fetch logic
► src/frontend/src/lib/utils/appMetadata.ts
    Add app metadata utilities (fetch metadata, constants, types)
Enhancement ► src/frontend/src/lib/stores/app-metadata.store.test.ts
    Add tests for app metadata store behavior (fallback, fetch, caching, reset)
Enhancement ► src/frontend/src/lib/utils/appMetadata.test.ts
    Add extensive tests for app metadata utilities (fetching, decoding logos, size/type checks, normalization, error handling)

Comment thread docs/ii-spec.mdx Outdated
Comment thread docs/ii-spec.mdx Outdated
Comment thread docs/ii-spec.mdx Outdated
claude added 2 commits August 18, 2026 15:31
… 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
Comment thread src/frontend/src/lib/utils/appMetadata.ts Outdated
Comment thread src/frontend/src/lib/utils/appMetadata.ts Outdated
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
Comment thread src/frontend/src/lib/utils/appMetadata.ts Outdated
claude added 2 commits August 18, 2026 15:51
…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
@aterga
aterga requested a review from sea-snake August 18, 2026 16:21
@aterga
aterga merged commit f5b84b2 into main Aug 19, 2026
74 of 75 checks passed
@aterga
aterga deleted the arshavir/charming-knuth-32obpn branch August 19, 2026 08:15
aterga pushed a commit to dfinity/developer-docs that referenced this pull request Aug 24, 2026
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.
marc0olo pushed a commit to dfinity/developer-docs that referenced this pull request Aug 24, 2026
## 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>
aterga added a commit that referenced this pull request Aug 25, 2026
…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>
aterga pushed a commit that referenced this pull request Aug 28, 2026
…#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants