diff --git a/docs/ii-spec.mdx b/docs/ii-spec.mdx
index 5e79a8837e..b50eb0013c 100644
--- a/docs/ii-spec.mdx
+++ b/docs/ii-spec.mdx
@@ -335,6 +335,81 @@ To prevent misuse of this feature, the number of alternative origins _must not_
In order to allow Internet Identity to read the path `/.well-known/ii-alternative-origins`, the CORS response header [`Access-Control-Allow-Origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) must be set and allow the Internet Identity origin being used, for example `https://id.ai`, `https://identity.internetcomputer.org`, or `https://identity.ic0.app`.
:::
+## App metadata
+
+Internet Identity displays the name and logo of the client application on its authorization screens (e.g. "Continue to _App_"). Any application can provide this metadata itself — permissionlessly, without being included in any curated list — by serving a JSON document at the path `/.well-known/ii-app-metadata` (on the origin identified below):
+
+```json
+{
+ "name": "Example App",
+ "description": "A short tagline shown on the sign-in screen",
+ "logo": "/logo.png"
+}
+```
+
+Internet Identity fetches this document when the authorization flow starts, from the origin the application's identity is derived for: the `derivationOrigin` of the authorization request once it has been validated (see [Alternative frontend origins](#alternative-frontend-origins)), and the origin the request came from otherwise. An application therefore publishes the document once, on the origin its principals and its Internet Identity accounts are bound to, and all of its alternative frontend origins — which that origin has certified as its own — present the same name, description and logo, with nothing to keep in sync between them. 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 only.
+
+Since the file is under the sole control of the origin serving it, 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 origin the user is signing in from alongside this metadata, as the value users can actually verify. Ordinary `https` origins on the default port are shown as their hostname (`https://example.com` as `example.com`); an origin whose scheme or port would otherwise be hidden is shown in full (for example `https://example.com:8443`), since those components distinguish origins that derive different principals.
+
+When the document comes from a `derivationOrigin`, the displayed origin is one that origin has itself listed as an alternative frontend origin, in a certified document, and it is that origin's principals the user receives — so the presentation still comes from the origin the sign-in is bound to, and no origin can present itself using metadata of an origin that has not vouched for it.
+
+Requirements:
+
+- All fields are optional, and unknown fields are ignored, so that fields added in the future do not invalidate documents for older versions of Internet Identity. A document that carries no field Internet Identity knows is ignored; a valid document replaces any curated fallback entry wholesale.
+- A field that is present but does not meet the requirements below invalidates **the whole document**, which is then ignored — the offending field is not simply dropped. An application whose file is wrong therefore sees none of its metadata applied, instead of shipping a file that is silently half-applied on a screen it does not control; Internet Identity also logs which field is at fault to the browser console.
+- `name` must not exceed 40 characters and `description` must not exceed 120 characters, counted in Unicode code points on the value as served. Neither may contain control characters (other than the ASCII whitespace characters `\t`, `\n`, `\v`, `\f` and `\r`), the bidirectional embeddings and overrides U+202A–U+202E, or U+FEFF. Only these reordering controls are refused: an override makes text render in an order other than the one it is written in, which is what would let a name read as something it does not contain, and the embeddings are deprecated in favour of the isolates for the same reason.
+- The characters that mixed-direction and non-Latin names legitimately need are accepted: the bidirectional marks U+200E, U+200F and U+061C (zero-width hints that only affect where neutral characters such as punctuation and digits land at a direction boundary), the bidirectional isolates U+2066–U+2069, and the zero-width characters U+200B–U+200D (line-break opportunities in scripts such as Thai and Khmer, shaping in scripts such as Persian, and emoji sequences). Isolates must be **balanced**, however: a field must close every isolate it opens and close none it did not open, since an unbalanced isolate would extend past the application's own text and reorder what Internet Identity renders around it.
+- A field must contain at least one visible character — one that is neither whitespace nor one of the invisible characters above — so that a field which renders as nothing is treated as absent rather than displayed as a blank name.
+- Before being displayed, `name` and `description` have runs of whitespace collapsed to single spaces and are trimmed. This is presentation only, applied after the requirements above are checked: it never rescues a value that violates them.
+- `logo` must be a URL (relative URLs are resolved against the document's origin) pointing to a raster image _on that same origin_. It must be served with one of the content types `image/png`, `image/jpeg`, `image/webp`, `image/gif` or `image/avif`, must not exceed 1 MiB, and must decode to an image of at most 4096 pixels per axis. Internet Identity downloads the logo (it is never hotlinked), so both the metadata document and the logo asset must be readable cross-origin (see the CORS note below).
+- The logo is not rendered as served: Internet Identity decodes it, draws it once into a canvas scaled to at most 512 pixels on its longest side, and renders that re-encoding from a `blob:` URL. What is displayed is therefore an image Internet Identity produced itself — still (an animated image is flattened to its first frame), bounded in size, and held in the browser's blob store rather than in the page's DOM or its JavaScript heap. `image/svg+xml` is not accepted, because a vector image cannot be put through that step across the browsers Internet Identity supports; applications with a vector logo serve a rasterized copy of it here.
+- Unlike the fields of the document, a logo that cannot be fetched, decoded, or does not meet the asset requirements above costs the application only its logo: fetching a second resource can fail transiently, and the name and description are still used.
+- The metadata document must not exceed 8 KiB, must be answered with a `200` HTTP status code and must not redirect (Internet Identity _will not_ follow redirects for either the document or the logo).
+
+### JSON Schema {#app-metadata-schema}
+
+The schema below expresses the requirements above, with one exception it cannot state: that bidirectional isolates must be balanced. Whitespace normalization happens after validation and is not part of it either. Validating in CI is the easiest way to catch a mistake before it costs the application its metadata.
+
+```json
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "title": "II App Metadata",
+ "description": "Display metadata (name, description, logo) shown by Internet Identity on authorization screens for the origin serving this document.",
+ "type": "object",
+ "properties": {
+ "name": {
+ "description": "Display name of the application",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 40,
+ "pattern": "[^\\s\\u061c\\u200b-\\u200f\\u2066-\\u2069]",
+ "not": { "pattern": "[\\u0000-\\u0008\\u000e-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\ufeff]" }
+ },
+ "description": {
+ "description": "Short description or tagline of the application",
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 120,
+ "pattern": "[^\\s\\u061c\\u200b-\\u200f\\u2066-\\u2069]",
+ "not": { "pattern": "[\\u0000-\\u0008\\u000e-\\u001f\\u007f-\\u009f\\u202a-\\u202e\\ufeff]" }
+ },
+ "logo": {
+ "description": "URL of the raster application logo, on the same origin as this document",
+ "type": "string",
+ "minLength": 1
+ }
+ }
+}
+```
+
+:::note
+In order to allow Internet Identity to read the path `/.well-known/ii-app-metadata` as well as the logo asset it references, the CORS response header [`Access-Control-Allow-Origin`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin) must be set on both responses and allow the Internet Identity origin being used, for example `https://id.ai`, `https://identity.internetcomputer.org`, or `https://identity.ic0.app`.
+:::
+
+:::note
+Unlike `/.well-known/ii-alternative-origins`, this document has no security semantics for principal derivation, so it may be served by any web server — the application does not need to be hosted on ICP. For applications hosted in an ICP asset canister, the file is served like any other asset (and must be certified when served via a non-raw domain).
+:::
+
## The Internet Identity Service Backend interface
This section describes the interface that the backend canister provides.
diff --git a/src/frontend/src/lib/components/ui/AuthorizeHeader.svelte b/src/frontend/src/lib/components/ui/AuthorizeHeader.svelte
index 92c8905332..1b42b92e2e 100644
--- a/src/frontend/src/lib/components/ui/AuthorizeHeader.svelte
+++ b/src/frontend/src/lib/components/ui/AuthorizeHeader.svelte
@@ -1,19 +1,45 @@
diff --git a/src/frontend/src/lib/stores/app-metadata.store.test.ts b/src/frontend/src/lib/stores/app-metadata.store.test.ts
new file mode 100644
index 0000000000..17142290de
--- /dev/null
+++ b/src/frontend/src/lib/stores/app-metadata.store.test.ts
@@ -0,0 +1,156 @@
+import { get } from "svelte/store";
+import { beforeEach, expect, test, vi } from "vitest";
+import {
+ getAppMetadataStore,
+ resetAppMetadataStores,
+} from "$lib/stores/app-metadata.store";
+import { fetchAppMetadata, type AppMetadata } from "$lib/utils/appMetadata";
+
+vi.mock("$lib/utils/appMetadata", () => ({
+ fetchAppMetadata: vi.fn(),
+}));
+
+// The curated dapps list reads canister config that is only available in the
+// browser; stub it with a single known dapp.
+vi.mock("$lib/legacy/flows/dappsExplorer/dapps", () => ({
+ getDapps: () => [
+ {
+ hasOrigin: (origin: string) => origin === "https://known.example.com",
+ name: "Known App",
+ oneLiner: "A curated app",
+ logoSrc: "/known-logo.png",
+ },
+ ],
+}));
+
+const fetchAppMetadataMock = vi.mocked(fetchAppMetadata);
+
+const pending = (): Promise => new Promise(() => {});
+
+beforeEach(() => {
+ resetAppMetadataStores();
+ fetchAppMetadataMock.mockReset();
+});
+
+test("should fall back to the curated dapps list while fetching", () => {
+ fetchAppMetadataMock.mockReturnValue(pending());
+
+ const store = getAppMetadataStore("https://known.example.com");
+
+ expect(get(store)).toEqual({
+ name: "Known App",
+ description: "A curated app",
+ logo: "/known-logo.png",
+ });
+});
+
+test("should fetch from the derivation origin, not the displayed one", () => {
+ fetchAppMetadataMock.mockReturnValue(pending());
+
+ getAppMetadataStore(
+ "https://derivation.example.com",
+ "https://displayed.example.com",
+ );
+
+ // The app publishes once, on the origin its identity is derived for; its
+ // alternative origins are presented from that same document.
+ expect(fetchAppMetadataMock).toHaveBeenCalledExactlyOnceWith(
+ "https://derivation.example.com",
+ );
+});
+
+test("should match the curated fallback on the displayed origin too", () => {
+ // A curated entry lists the origins an app signs in from, which needn't
+ // include the origin it derives from -- so while apps migrate to the
+ // well-known file, the displayed origin still resolves the entry.
+ fetchAppMetadataMock.mockReturnValue(pending());
+
+ const store = getAppMetadataStore(
+ "https://derivation.example.com",
+ "https://known.example.com",
+ );
+
+ expect(get(store)).toEqual({
+ name: "Known App",
+ description: "A curated app",
+ logo: "/known-logo.png",
+ });
+});
+
+test("should fall back to empty metadata for unknown origins", () => {
+ fetchAppMetadataMock.mockReturnValue(pending());
+
+ const store = getAppMetadataStore("https://unknown.example.com");
+
+ expect(get(store)).toEqual({});
+});
+
+test("should replace the fallback wholesale once metadata is fetched", async () => {
+ fetchAppMetadataMock.mockResolvedValue({ name: "Self-Published App" });
+
+ const store = getAppMetadataStore("https://known.example.com");
+
+ await vi.waitFor(() =>
+ expect(get(store)).toEqual({ name: "Self-Published App" }),
+ );
+ expect(fetchAppMetadataMock).toHaveBeenCalledExactlyOnceWith(
+ "https://known.example.com",
+ );
+});
+
+test("should keep the fallback when the origin serves no metadata", async () => {
+ fetchAppMetadataMock.mockResolvedValue(undefined);
+
+ const store = getAppMetadataStore("https://known.example.com");
+
+ // Give the resolved promise a chance to (incorrectly) overwrite the value.
+ await new Promise((resolve) => setTimeout(resolve));
+ expect(get(store)).toEqual({
+ name: "Known App",
+ description: "A curated app",
+ logo: "/known-logo.png",
+ });
+});
+
+test("should fetch once per origin and share the store", () => {
+ fetchAppMetadataMock.mockReturnValue(pending());
+
+ const first = getAppMetadataStore("https://app.example.com");
+ const second = getAppMetadataStore("https://app.example.com");
+ const other = getAppMetadataStore("https://other.example.com");
+
+ expect(first).toBe(second);
+ expect(other).not.toBe(first);
+ expect(fetchAppMetadataMock).toHaveBeenCalledTimes(2);
+ expect(fetchAppMetadataMock).toHaveBeenCalledWith("https://app.example.com");
+ expect(fetchAppMetadataMock).toHaveBeenCalledWith(
+ "https://other.example.com",
+ );
+});
+
+test("should update subscribers that subscribed before the fetch resolved", async () => {
+ let resolveFetch: (metadata: AppMetadata | undefined) => void = () => {};
+ fetchAppMetadataMock.mockReturnValue(
+ new Promise((resolve) => (resolveFetch = resolve)),
+ );
+
+ const store = getAppMetadataStore("https://app.example.com");
+ const seen: AppMetadata[] = [];
+ const unsubscribe = store.subscribe((value) => seen.push(value));
+
+ resolveFetch({ name: "Late App" });
+ await vi.waitFor(() => expect(seen).toHaveLength(2));
+ expect(seen[0]).toEqual({});
+ expect(seen[1]).toEqual({ name: "Late App" });
+ unsubscribe();
+});
+
+test("should fetch again after the cache is reset", () => {
+ fetchAppMetadataMock.mockResolvedValue({ name: "App" });
+
+ getAppMetadataStore("https://app.example.com");
+ resetAppMetadataStores();
+ getAppMetadataStore("https://app.example.com");
+
+ expect(fetchAppMetadataMock).toHaveBeenCalledTimes(2);
+});
diff --git a/src/frontend/src/lib/stores/app-metadata.store.ts b/src/frontend/src/lib/stores/app-metadata.store.ts
new file mode 100644
index 0000000000..d73006b942
--- /dev/null
+++ b/src/frontend/src/lib/stores/app-metadata.store.ts
@@ -0,0 +1,86 @@
+/**
+ * Per-origin display metadata (name, description, logo) for apps that sign in
+ * with Internet Identity, e.g. shown on the authorize flow screens.
+ *
+ * The metadata is sourced permissionlessly from the app itself, which serves a
+ * `/.well-known/ii-app-metadata` file (see {@link fetchAppMetadata}) on the
+ * origin its identity is derived for — its derivation origin when it uses one,
+ * and the origin it signs in from otherwise. That is the origin the delegation
+ * and the user's accounts are bound to, and it is the single place an app
+ * publishes its presentation: its alternative origins, which it has certified
+ * as its own, are then presented identically without duplicating the file.
+ *
+ * The curated dapps list shipped with II is only used as a fallback while apps
+ * migrate to the well-known file, and a valid file always replaces the curated
+ * entry wholesale — the app owns its own presentation. When neither source has
+ * data, consumers fall back to the origin's hostname.
+ */
+import { writable, type Readable } from "svelte/store";
+import { fetchAppMetadata, type AppMetadata } from "$lib/utils/appMetadata";
+import { getDapps } from "$lib/legacy/flows/dappsExplorer/dapps";
+
+export type { AppMetadata } from "$lib/utils/appMetadata";
+
+const storeByOrigin = new Map>();
+
+/** Fallback display metadata from the curated dapps list shipped with II.
+ * Tried for each of the given origins in turn: a curated entry lists the
+ * origins an app is known to sign in from, which needn't include the origin
+ * it derives from, so the displayed origin still resolves an app that
+ * hasn't published the well-known file yet. */
+const knownDappMetadata = (...origins: string[]): AppMetadata => {
+ const dapps = getDapps();
+ for (const origin of origins) {
+ const dapp = dapps.find((dapp) => dapp.hasOrigin(origin));
+ if (dapp !== undefined) {
+ return {
+ name: dapp.name,
+ description: dapp.oneLiner,
+ logo: dapp.logoSrc,
+ };
+ }
+ }
+ return {};
+};
+
+/**
+ * Reactive display metadata for the app identified by the given origin.
+ *
+ * Resolves synchronously to the curated-list fallback (so known dapps never
+ * flash an unbranded screen) and updates in place once the app's own
+ * `/.well-known/ii-app-metadata` file has been fetched and validated. The
+ * fetch runs once per origin per page load; all subscribers share the result.
+ *
+ * @param origin The origin the app's identity is derived for — its validated
+ * derivation origin when the authorization request carries one, and the
+ * origin it signs in from otherwise. This is where the metadata is fetched
+ * from, and what the store is cached under.
+ * @param displayOrigin The origin the calling screen shows to the user, when
+ * that differs from `origin` (i.e. the app uses a derivation origin). Used
+ * only to widen the curated-list fallback, never as a metadata source.
+ */
+export const getAppMetadataStore = (
+ origin: string,
+ displayOrigin: string = origin,
+): Readable => {
+ const existing = storeByOrigin.get(origin);
+ if (existing !== undefined) {
+ return existing;
+ }
+ const { subscribe, set } = writable(
+ knownDappMetadata(origin, displayOrigin),
+ );
+ const store = { subscribe };
+ storeByOrigin.set(origin, store);
+ void fetchAppMetadata(origin).then((metadata) => {
+ if (metadata !== undefined) {
+ set(metadata);
+ }
+ });
+ return store;
+};
+
+/** Test-only: drop all cached per-origin stores so fetches run again. */
+export const resetAppMetadataStores = (): void => {
+ storeByOrigin.clear();
+};
diff --git a/src/frontend/src/lib/utils/appMetadata.test.ts b/src/frontend/src/lib/utils/appMetadata.test.ts
new file mode 100644
index 0000000000..1e40dbd0a6
--- /dev/null
+++ b/src/frontend/src/lib/utils/appMetadata.test.ts
@@ -0,0 +1,620 @@
+import {
+ APP_LOGO_RENDER_SIZE,
+ APP_METADATA_FETCH_TIMEOUT_MILLIS,
+ APP_METADATA_PATH,
+ MAX_APP_DESCRIPTION_LENGTH,
+ MAX_APP_LOGO_DIMENSION,
+ MAX_APP_LOGO_SIZE,
+ MAX_APP_METADATA_SIZE,
+ MAX_APP_NAME_LENGTH,
+ fetchAppMetadata,
+} from "$lib/utils/appMetadata";
+import { beforeEach, expect, test, vi } from "vitest";
+
+beforeEach(() => {
+ // Drop the previous test's canvas/decoder stubs, so a test that needs them
+ // has to install them itself rather than inheriting them by accident.
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ // A rejected document logs a warning naming the offending field, which is
+ // how an app's developers find out. Silence it by default; the test below
+ // asserts on it explicitly.
+ vi.spyOn(console, "warn").mockImplementation(() => undefined);
+});
+
+const ORIGIN = "https://app.example.com";
+const METADATA_URL = `${ORIGIN}${APP_METADATA_PATH}`;
+
+const JSON_FETCH_OPTS = expect.objectContaining({
+ redirect: "error",
+ headers: {
+ Accept: "application/json",
+ },
+ credentials: "omit",
+});
+const IMAGE_FETCH_OPTS = expect.objectContaining({
+ redirect: "error",
+ headers: {
+ Accept: "image/*",
+ },
+ credentials: "omit",
+});
+
+const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]);
+const LOGO_OBJECT_URL =
+ "blob:https://id.ai/00000000-0000-0000-0000-000000000000";
+
+/**
+ * jsdom implements neither image decoding nor canvas, so the re-encoding step
+ * is stubbed: `createImageBitmap` reports whatever dimensions (or failure) the
+ * test wants, and the canvas hands back a fixed blob. Returns the stubs so a
+ * test can assert on what II drew and how large it drew it.
+ */
+const setupImageMock = ({
+ width = 64,
+ height = 64,
+ decodable = true,
+}: { width?: number; height?: number; decodable?: boolean } = {}) => {
+ const close = vi.fn();
+ const createImageBitmap = vi.fn(() =>
+ decodable
+ ? Promise.resolve({ width, height, close } as unknown as ImageBitmap)
+ : Promise.reject(new Error("The source image could not be decoded.")),
+ );
+ vi.stubGlobal("createImageBitmap", createImageBitmap);
+ const drawImage = vi.fn();
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
+ drawImage,
+ } as unknown as CanvasRenderingContext2D);
+ const toBlob = vi
+ .spyOn(HTMLCanvasElement.prototype, "toBlob")
+ .mockImplementation((callback, type) =>
+ callback(new Blob(["re-encoded"], { type: type ?? "image/png" })),
+ );
+ const createObjectURL = vi.fn(() => LOGO_OBJECT_URL);
+ URL.createObjectURL = createObjectURL;
+ return { createImageBitmap, drawImage, toBlob, createObjectURL, close };
+};
+
+const imageResponse = (
+ bytes: Uint8Array = PNG_BYTES,
+ contentType = "image/png",
+): Response =>
+ new Response(bytes, {
+ status: 200,
+ headers: { "Content-Type": contentType },
+ });
+
+const setupFetchMock = (...responses: (Response | Error)[]) => {
+ const fetchMock = vi.fn();
+ global.fetch = fetchMock;
+ responses.forEach((response) => {
+ if (response instanceof Error) {
+ fetchMock.mockRejectedValueOnce(response);
+ } else {
+ fetchMock.mockResolvedValueOnce(response);
+ }
+ });
+ return fetchMock;
+};
+
+test("should fetch metadata from the well-known path with hardened options", async () => {
+ const fetchMock = setupFetchMock(
+ Response.json({ name: "Example App", description: "An example app" }),
+ );
+
+ const result = await fetchAppMetadata(ORIGIN);
+
+ expect(result).toEqual({
+ name: "Example App",
+ description: "An example app",
+ });
+ expect(fetchMock).toHaveBeenCalledExactlyOnceWith(
+ METADATA_URL,
+ JSON_FETCH_OPTS,
+ );
+});
+
+test("should fetch a same-origin logo and render it from a blob url", async () => {
+ const fetchMock = setupFetchMock(
+ Response.json({ name: "Example App", logo: "/assets/logo.png" }),
+ imageResponse(),
+ );
+ const { createImageBitmap, createObjectURL, toBlob } = setupImageMock();
+
+ const result = await fetchAppMetadata(ORIGIN);
+
+ expect(result).toEqual({
+ name: "Example App",
+ logo: LOGO_OBJECT_URL,
+ });
+ // The downloaded bytes reach the decoder as a blob, never as a JS buffer.
+ expect(createImageBitmap).toHaveBeenCalledWith(expect.any(Blob));
+ // The rendered bytes are II's own re-encoding, held in the browser's blob
+ // store: no attacker-controlled payload reaches the DOM or the JS heap, as a
+ // `data:` URL would.
+ expect(toBlob).toHaveBeenCalledOnce();
+ expect(createObjectURL).toHaveBeenCalledOnce();
+ expect(fetchMock).toHaveBeenNthCalledWith(1, METADATA_URL, JSON_FETCH_OPTS);
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 2,
+ `${ORIGIN}/assets/logo.png`,
+ IMAGE_FETCH_OPTS,
+ );
+});
+
+test("should resolve relative logo paths against the app origin", async () => {
+ const fetchMock = setupFetchMock(
+ Response.json({ logo: "assets/logo.webp" }),
+ imageResponse(PNG_BYTES, "image/webp"),
+ );
+ setupImageMock();
+
+ const result = await fetchAppMetadata(ORIGIN);
+
+ expect(result?.logo).toBe(LOGO_OBJECT_URL);
+ expect(fetchMock).toHaveBeenNthCalledWith(
+ 2,
+ `${ORIGIN}/assets/logo.webp`,
+ IMAGE_FETCH_OPTS,
+ );
+});
+
+test("should work for http origins with a port (local development)", async () => {
+ const fetchMock = setupFetchMock(Response.json({ name: "Local App" }));
+
+ const result = await fetchAppMetadata("http://localhost:5173");
+
+ expect(result).toEqual({ name: "Local App" });
+ expect(fetchMock).toHaveBeenCalledExactlyOnceWith(
+ `http://localhost:5173${APP_METADATA_PATH}`,
+ JSON_FETCH_OPTS,
+ );
+});
+
+test("should return undefined when the file is missing", async () => {
+ setupFetchMock(new Response(undefined, { status: 404 }));
+
+ expect(await fetchAppMetadata(ORIGIN)).toBeUndefined();
+});
+
+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();
+});
+
+test("should abort and give up when the origin never responds", async () => {
+ // A hanging origin must not leave the request pending forever: the timeout
+ // aborts the signal and the metadata falls back. Guards against the timeout
+ // being removed or scoped so it can't fire.
+ vi.useFakeTimers();
+ try {
+ const fetchMock = vi.fn(
+ (_url: string, init?: RequestInit) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener("abort", () =>
+ reject(
+ new DOMException("The operation was aborted.", "AbortError"),
+ ),
+ );
+ }),
+ );
+ global.fetch = fetchMock as unknown as typeof fetch;
+
+ const metadata = fetchAppMetadata(ORIGIN);
+ await vi.advanceTimersByTimeAsync(APP_METADATA_FETCH_TIMEOUT_MILLIS);
+
+ await expect(metadata).resolves.toBeUndefined();
+ expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(true);
+ } finally {
+ vi.useRealTimers();
+ }
+});
+
+test("should keep waiting until the timeout elapses", async () => {
+ // Sanity check on the guard above: the signal must not be aborted early,
+ // otherwise the test would pass even with a near-zero timeout.
+ vi.useFakeTimers();
+ try {
+ const fetchMock = vi.fn(
+ (_url: string, _init?: RequestInit) => new Promise(() => {}),
+ );
+ global.fetch = fetchMock as unknown as typeof fetch;
+
+ void fetchAppMetadata(ORIGIN);
+ await vi.advanceTimersByTimeAsync(APP_METADATA_FETCH_TIMEOUT_MILLIS - 1);
+
+ expect(fetchMock.mock.calls[0][1]?.signal?.aborted).toBe(false);
+ } finally {
+ vi.useRealTimers();
+ }
+});
+
+test("should return undefined on redirects", async () => {
+ setupFetchMock(Response.redirect("https://evil.com/metadata"));
+
+ expect(await fetchAppMetadata(ORIGIN)).toBeUndefined();
+});
+
+test("should return undefined for malformed bodies", async () => {
+ for (const body of [
+ "not json",
+ JSON.stringify(["name"]),
+ JSON.stringify("name"),
+ JSON.stringify(null),
+ JSON.stringify(42),
+ ]) {
+ setupFetchMock(new Response(body, { status: 200 }));
+ expect(await fetchAppMetadata(ORIGIN), body).toBeUndefined();
+ }
+});
+
+test("should return undefined for malformed UTF-8", async () => {
+ // 0xff is never valid in UTF-8; fatal decoding must reject the file
+ // rather than smuggling U+FFFD replacement characters into the metadata.
+ setupFetchMock(
+ new Response(new Uint8Array([0x7b, 0xff, 0x7d]), { status: 200 }),
+ );
+
+ expect(await fetchAppMetadata(ORIGIN)).toBeUndefined();
+});
+
+test("should return undefined when no usable field is present", async () => {
+ for (const body of [{}, { unrelated: "field" }]) {
+ setupFetchMock(Response.json(body));
+ expect(
+ await fetchAppMetadata(ORIGIN),
+ JSON.stringify(body),
+ ).toBeUndefined();
+ }
+});
+
+test("should return undefined when the file exceeds the size limit", async () => {
+ setupFetchMock(
+ Response.json({
+ name: "Example App",
+ padding: "a".repeat(MAX_APP_METADATA_SIZE),
+ }),
+ );
+
+ expect(await fetchAppMetadata(ORIGIN)).toBeUndefined();
+});
+
+test("should return undefined when content-length exceeds the size limit", async () => {
+ // Header-only guard: the body itself is small but the declared length is not.
+ setupFetchMock(
+ new Response(JSON.stringify({ name: "Example App" }), {
+ status: 200,
+ headers: { "Content-Length": `${MAX_APP_METADATA_SIZE + 1}` },
+ }),
+ );
+
+ expect(await fetchAppMetadata(ORIGIN)).toBeUndefined();
+});
+
+test("should reject the whole document when a text field exceeds its limit", async () => {
+ // Not just the offending field: an app that ships a too-long name sees its
+ // metadata not applied at all, which is noticeable and points at the fix,
+ // rather than silently losing one field on a screen it doesn't control.
+ setupFetchMock(
+ Response.json({
+ name: "a".repeat(MAX_APP_NAME_LENGTH + 1),
+ description: "b".repeat(MAX_APP_DESCRIPTION_LENGTH),
+ }),
+ );
+
+ expect(await fetchAppMetadata(ORIGIN)).toBeUndefined();
+});
+
+test("should reject the whole document when a field has the wrong type", async () => {
+ for (const body of [
+ { name: 42, description: "An example app" },
+ { name: "Example App", description: true },
+ { name: "Example App", description: null },
+ { name: "Example App", logo: [] },
+ { name: "Example App", logo: "" },
+ ]) {
+ setupFetchMock(Response.json(body));
+
+ expect(
+ await fetchAppMetadata(ORIGIN),
+ JSON.stringify(body),
+ ).toBeUndefined();
+ }
+});
+
+test("should name the offending field when rejecting a document", async () => {
+ // The console warning is the only signal an app's developers get, so it has
+ // to say which field is at fault.
+ setupFetchMock(Response.json({ name: "a".repeat(MAX_APP_NAME_LENGTH + 1) }));
+
+ expect(await fetchAppMetadata(ORIGIN)).toBeUndefined();
+ expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("`name`"));
+ expect(console.warn).toHaveBeenCalledWith(
+ expect.stringContaining(APP_METADATA_PATH),
+ );
+});
+
+test("should normalize whitespace in text fields", async () => {
+ // Whitespace is legitimate in a JSON document but would render as gaps, so
+ // runs of it collapse to single spaces and the value is trimmed. This is
+ // presentation only: it never rescues a value that breaks a requirement.
+ setupFetchMock(
+ Response.json({
+ name: " Example \t App\n",
+ description: "Multi\r\nline description ",
+ }),
+ );
+
+ const result = await fetchAppMetadata(ORIGIN);
+
+ expect(result).toEqual({
+ name: "Example App",
+ description: "Multi line description",
+ });
+});
+
+test("should reject documents whose text fields carry reordering controls", async () => {
+ // Control characters, and the bidi embeddings and overrides: the latter make
+ // text render in an order other than the one it is written in, which is how a
+ // name could read as something it doesn't contain.
+ for (const char of [
+ "\u0000", // NUL
+ "\u0007", // BEL
+ "\u001b", // ESC
+ "\u202a", // left-to-right embedding
+ "\u202b", // right-to-left embedding
+ "\u202c", // pop directional formatting
+ "\u202d", // left-to-right override
+ "\u202e", // right-to-left override
+ "\ufeff", // zero-width no-break space
+ ]) {
+ setupFetchMock(
+ Response.json({ name: `Example${char}App`, description: "An example" }),
+ );
+
+ expect(
+ await fetchAppMetadata(ORIGIN),
+ char.codePointAt(0)?.toString(16),
+ ).toBeUndefined();
+ }
+});
+
+test("should accept the bidi characters mixed-direction names need", async () => {
+ // These only hint at where neutral characters land, or isolate a run; none of
+ // them can reorder text. Refusing them would break exactly the names that
+ // need them: RTL text with an embedded Latin word, or ending in punctuation
+ // whose side would otherwise follow the paragraph direction.
+ for (const name of [
+ `\u200fשלום Example!`, // RTL mark
+ `Example \u200eעברית`, // LTR mark
+ `\u061cالعربية Example`, // arabic letter mark
+ `\u2068Example\u2069 في المتجر`, // first-strong isolate, balanced
+ `\u2066Example\u2069 و\u2067עברית\u2069`, // nested, balanced
+ `ราคา\u200bถูก`, // zero-width space as a Thai line-break opportunity
+ ]) {
+ setupFetchMock(Response.json({ name }));
+
+ expect(await fetchAppMetadata(ORIGIN), name).toEqual({ name });
+ }
+});
+
+test("should reject unbalanced bidi isolates", async () => {
+ // An isolate only contains its contents while it is closed. Left open, it
+ // runs to the end of the paragraph -- past the app's own name and into the
+ // sentence II renders around it.
+ for (const name of [
+ `Example\u2066`, // opened, never closed
+ `\u2069Example`, // closed without being opened
+ `\u2066Example\u2069\u2069`, // one close too many
+ `\u2068\u2067Example\u2069`, // one close too few
+ ]) {
+ setupFetchMock(Response.json({ name, description: "An example" }));
+
+ expect(await fetchAppMetadata(ORIGIN), name).toBeUndefined();
+ }
+});
+
+test("should reject text fields with nothing visible in them", async () => {
+ // Whitespace, bidi marks, isolate controls and zero-width characters all
+ // render as nothing, so a field made only of those is an absent field.
+ for (const name of [
+ " ",
+ "\u200b\u200b",
+ "\u200e\u200f",
+ ` \u2066\u2069 `,
+ ]) {
+ setupFetchMock(Response.json({ name, description: "An example" }));
+
+ expect(
+ await fetchAppMetadata(ORIGIN),
+ JSON.stringify(name),
+ ).toBeUndefined();
+ }
+});
+
+test("should preserve the zero-width joiners scripts and emoji need", async () => {
+ // ZWNJ (U+200C) drives correct shaping in scripts such as Persian, and ZWJ
+ // (U+200D) holds emoji sequences together. Neither is a control or bidi
+ // character, so stripping them would silently mangle legitimate names --
+ // a single joined emoji would decay into two.
+ const name = "Acme \u{1f469}\u200d\u{1f4bb}";
+ const description = "Zero\u200cwidth non-joiner survives too";
+ setupFetchMock(Response.json({ name, description }));
+
+ expect(await fetchAppMetadata(ORIGIN)).toEqual({ name, description });
+});
+
+test("should count text limits in code points, not UTF-16 units", async () => {
+ // Each emoji is one code point but two UTF-16 units: a name of exactly
+ // MAX_APP_NAME_LENGTH emoji is within the documented limit.
+ const emojiName = "🌍".repeat(MAX_APP_NAME_LENGTH);
+ setupFetchMock(Response.json({ name: emojiName }));
+ expect(await fetchAppMetadata(ORIGIN)).toEqual({ name: emojiName });
+
+ setupFetchMock(Response.json({ name: "🌍".repeat(MAX_APP_NAME_LENGTH + 1) }));
+ expect(await fetchAppMetadata(ORIGIN)).toBeUndefined();
+});
+
+test("should reject the whole document when the logo is not same-origin", async () => {
+ for (const logo of [
+ "https://evil.com/logo.png",
+ "//evil.com/logo.png",
+ "https://sub.app.example.com/logo.png",
+ "http://app.example.com/logo.png", // scheme downgrade
+ "data:image/png;base64,AAAA",
+ "javascript:alert(1)",
+ "https://", // unparseable
+ ]) {
+ const fetchMock = setupFetchMock(
+ Response.json({ name: "Example App", logo }),
+ );
+
+ // A logo pointing somewhere else is an authoring mistake in the document
+ // (and a privacy leak if honored), so it invalidates the document rather
+ // than quietly dropping just the logo.
+ expect(await fetchAppMetadata(ORIGIN), logo).toBeUndefined();
+ // The logo must not even be fetched.
+ expect(fetchMock, logo).toHaveBeenCalledTimes(1);
+ }
+});
+
+// Failures of the logo *asset* stay non-fatal, unlike invalid fields in the
+// document above: fetching a second resource can fail transiently (a 500, a
+// dropped connection), and losing the name and description over that would be
+// worse than rendering the app without its logo.
+test("should drop logos served with a non-image content type", async () => {
+ setupFetchMock(
+ Response.json({ name: "Example App", logo: "/logo.png" }),
+ imageResponse(PNG_BYTES, "text/html"),
+ );
+ setupImageMock();
+
+ expect(await fetchAppMetadata(ORIGIN)).toEqual({ name: "Example App" });
+});
+
+test("should accept content types regardless of casing and parameters", async () => {
+ setupFetchMock(
+ Response.json({ logo: "/logo.png" }),
+ imageResponse(PNG_BYTES, "IMAGE/PNG; charset=binary"),
+ );
+ setupImageMock();
+
+ expect((await fetchAppMetadata(ORIGIN))?.logo).toBe(LOGO_OBJECT_URL);
+});
+
+test("should drop svg logos, which cannot be re-encoded", async () => {
+ // Every logo is rendered from II's own re-encoding of the decoded pixels,
+ // which is what bounds it and guarantees it is a still image; SVG can't go
+ // through that across browsers, so it is not an accepted content type.
+ setupFetchMock(
+ Response.json({ name: "Example App", logo: "/logo.svg" }),
+ imageResponse(PNG_BYTES, "image/svg+xml"),
+ );
+ setupImageMock();
+
+ expect(await fetchAppMetadata(ORIGIN)).toEqual({ name: "Example App" });
+});
+
+test("should drop logos that do not decode as an image", async () => {
+ // The content-type header is the app's claim; decoding is the check. HTML or
+ // a corrupt file served as `image/png` never reaches an ``.
+ setupFetchMock(
+ Response.json({ name: "Example App", logo: "/logo.png" }),
+ imageResponse(),
+ );
+ setupImageMock({ decodable: false });
+
+ expect(await fetchAppMetadata(ORIGIN)).toEqual({ name: "Example App" });
+});
+
+test("should drop logos whose decoded dimensions exceed the cap", async () => {
+ // The byte cap doesn't bound this: a small file can declare enormous
+ // dimensions, and the decoded bitmap costs about four bytes per pixel.
+ setupFetchMock(
+ Response.json({ name: "Example App", logo: "/logo.png" }),
+ imageResponse(),
+ );
+ const { drawImage } = setupImageMock({
+ width: MAX_APP_LOGO_DIMENSION + 1,
+ height: 8,
+ });
+
+ expect(await fetchAppMetadata(ORIGIN)).toEqual({ name: "Example App" });
+ expect(drawImage).not.toHaveBeenCalled();
+});
+
+test("should scale logos down to the size the screens render", async () => {
+ setupFetchMock(Response.json({ logo: "/logo.png" }), imageResponse());
+ const { drawImage, close } = setupImageMock({
+ width: APP_LOGO_RENDER_SIZE * 4,
+ height: APP_LOGO_RENDER_SIZE * 2,
+ });
+
+ expect((await fetchAppMetadata(ORIGIN))?.logo).toBe(LOGO_OBJECT_URL);
+ // Longest side capped, aspect ratio kept.
+ expect(drawImage).toHaveBeenCalledWith(
+ expect.anything(),
+ 0,
+ 0,
+ APP_LOGO_RENDER_SIZE,
+ APP_LOGO_RENDER_SIZE / 2,
+ );
+ // The decoded bitmap is released rather than left to the collector.
+ expect(close).toHaveBeenCalledOnce();
+});
+
+test("should keep a logo that is already smaller than the render size", async () => {
+ setupFetchMock(Response.json({ logo: "/logo.png" }), imageResponse());
+ const { drawImage } = setupImageMock({ width: 48, height: 32 });
+
+ expect((await fetchAppMetadata(ORIGIN))?.logo).toBe(LOGO_OBJECT_URL);
+ expect(drawImage).toHaveBeenCalledWith(expect.anything(), 0, 0, 48, 32);
+});
+
+test("should drop logos exceeding the size limit", async () => {
+ setupFetchMock(
+ Response.json({ name: "Example App", logo: "/logo.png" }),
+ imageResponse(new Uint8Array(MAX_APP_LOGO_SIZE + 1)),
+ );
+ setupImageMock();
+
+ expect(await fetchAppMetadata(ORIGIN)).toEqual({ name: "Example App" });
+});
+
+test("should drop empty logos", async () => {
+ setupFetchMock(
+ Response.json({ name: "Example App", logo: "/logo.png" }),
+ imageResponse(new Uint8Array(0)),
+ );
+ setupImageMock();
+
+ expect(await fetchAppMetadata(ORIGIN)).toEqual({ name: "Example App" });
+});
+
+test("should keep name and description when the logo fetch fails", async () => {
+ setupFetchMock(
+ Response.json({ name: "Example App", logo: "/logo.png" }),
+ new TypeError("Failed to fetch"),
+ );
+
+ expect(await fetchAppMetadata(ORIGIN)).toEqual({ name: "Example App" });
+});
+
+test("should drop logos responding with a redirect", async () => {
+ setupFetchMock(
+ Response.json({ name: "Example App", logo: "/logo.png" }),
+ Response.redirect("https://evil.com/logo.png"),
+ );
+
+ expect(await fetchAppMetadata(ORIGIN)).toEqual({ name: "Example App" });
+});
+
+test("should ignore unknown fields", async () => {
+ setupFetchMock(
+ Response.json({ name: "Example App", futureField: { nested: true } }),
+ );
+
+ expect(await fetchAppMetadata(ORIGIN)).toEqual({ name: "Example App" });
+});
diff --git a/src/frontend/src/lib/utils/appMetadata.ts b/src/frontend/src/lib/utils/appMetadata.ts
new file mode 100644
index 0000000000..c4916e33af
--- /dev/null
+++ b/src/frontend/src/lib/utils/appMetadata.ts
@@ -0,0 +1,513 @@
+/**
+ * Permissionless app metadata.
+ *
+ * Apps that integrate Internet Identity sign-in can provide their own display
+ * name, description and logo for the authorize flow by serving a JSON file at
+ * `/.well-known/ii-app-metadata` on their origin:
+ *
+ * ```json
+ * {
+ * "name": "Example App",
+ * "description": "A short tagline shown on the sign-in screen",
+ * "logo": "/logo.png"
+ * }
+ * ```
+ *
+ * This replaces the curated dapps list previously shipped with Internet
+ * Identity as the source of display metadata: any app can publish the file
+ * without asking anyone, so the metadata is exactly as trustworthy as the
+ * origin serving it. The origin (hostname) therefore remains the trust
+ * anchor and is always displayed alongside this metadata.
+ *
+ * Hardening, since the file is attacker-controlled by construction:
+ * - The file is fetched without credentials and redirects are rejected,
+ * mirroring the `/.well-known/ii-alternative-origins` fetch, with a
+ * timeout spanning both the connection and the body read.
+ * - Bodies are read with hard byte caps, enforced up front via
+ * `Content-Length` and chunk-by-chunk while streaming, so an origin can't
+ * make the authorization page buffer an arbitrarily large response. They
+ * stream into a `Blob`, so the payload lives in the browser's blob store
+ * with only one chunk at a time in the JS heap.
+ * - A document is applied only if every field it carries meets the
+ * requirements: one bad field rejects the whole document, with a console
+ * warning naming it, so the app's developers can see and fix the mistake
+ * instead of shipping a half-applied file that looks subtly wrong.
+ * - The logo must live on the app's own origin. It is downloaded via `fetch`
+ * (II's CSP `connect-src` allows any https origin, unlike `img-src`),
+ * checked against an image content-type allowlist and a size cap, and then
+ * decoded and re-encoded by II before it is rendered from a `blob:` URL. So
+ * the bytes reaching the `` are a still raster II produced itself: the
+ * app's file is never hotlinked, nothing about it has to be trusted to be an
+ * image, and no attacker-controlled payload ends up in the DOM or the JS
+ * heap (which a `data:` URL would put in both).
+ * - Every failure mode (missing file, CORS, timeout, invalid JSON, …) yields
+ * `undefined` rather than an error: metadata is a display nicety and must
+ * never break the sign-in flow.
+ */
+
+export interface AppMetadata {
+ /** Display name of the app. */
+ name?: string;
+ /** Short description of the app. */
+ description?: string;
+ /** Ready-to-render `` value (a `blob:` URL for fetched logos). */
+ logo?: string;
+}
+
+/** Well-known path (relative to the app's origin) the metadata is served on. */
+export const APP_METADATA_PATH = "/.well-known/ii-app-metadata";
+
+/** Maximum size of the metadata file in bytes (8 KiB). */
+export const MAX_APP_METADATA_SIZE = 8_192;
+
+/** Maximum length of the app name in Unicode code points, after whitespace
+ * normalization. */
+export const MAX_APP_NAME_LENGTH = 40;
+
+/** Maximum length of the app description in Unicode code points, after
+ * whitespace normalization. */
+export const MAX_APP_DESCRIPTION_LENGTH = 120;
+
+/** Maximum size of the logo asset in bytes (1 MiB). Deliberately generous:
+ * since the asset is re-encoded before it is rendered (see
+ * {@link transcodeToObjectUrl}), this bounds only the download, not what ends
+ * up held or displayed — so the cap should not be the reason an app that
+ * simply exported its logo without optimizing it loses it. */
+export const MAX_APP_LOGO_SIZE = 1_048_576;
+
+/** Content types the logo asset may be served with. `image/svg+xml` is
+ * deliberately absent: every logo is re-encoded from its decoded pixels (see
+ * {@link transcodeToObjectUrl}), which is what lets II guarantee that what it
+ * renders is a still image of bounded size, and SVG cannot be decoded that
+ * way across the browsers II supports. Apps serve a raster logo instead. */
+export const APP_LOGO_CONTENT_TYPES = [
+ "image/png",
+ "image/jpeg",
+ "image/webp",
+ "image/gif",
+ "image/avif",
+];
+
+/** Largest source image II will re-encode, per axis. The size cap alone does
+ * not bound this: a small file can declare enormous dimensions, and the
+ * decoded bitmap costs about four bytes per pixel. */
+export const MAX_APP_LOGO_DIMENSION = 4_096;
+
+/** Longest side of the re-encoded logo. The screens render it at roughly
+ * 80–200 CSS pixels, so this is headroom for high-density displays and
+ * nothing more. */
+export const APP_LOGO_RENDER_SIZE = 512;
+
+/** Time budget per resource, spanning the connection and the body read; the
+ * UI renders a fallback in the meantime, so a slow origin only delays its
+ * own polish, never the sign-in flow. */
+export const APP_METADATA_FETCH_TIMEOUT_MILLIS = 10_000;
+
+/**
+ * Read the body into a `Blob` under a hard byte cap, stopping the download as
+ * soon as the cap is crossed rather than buffering an arbitrarily large body
+ * first and measuring it afterwards.
+ *
+ * Piping through a counting transform is what keeps this off the JS heap: the
+ * bytes accumulate in the browser's blob store and only one chunk at a time is
+ * a JS buffer, so an attacker-controlled payload is never materialized as one.
+ * Erroring the transform aborts the source stream, which is what makes the cap
+ * bound the transfer and not just the result.
+ */
+const readBodyCapped = async (
+ response: Response,
+ maxBytes: number,
+): Promise => {
+ if (response.body === null) {
+ // No streamable body (older environments): read, then enforce the cap.
+ const blob = await response.blob();
+ return blob.size > maxBytes ? undefined : blob;
+ }
+ let received = 0;
+ const capped = response.body.pipeThrough(
+ new TransformStream({
+ transform: (chunk, controller) => {
+ received += chunk.byteLength;
+ if (received > maxBytes) {
+ controller.error(new Error(`Response exceeds ${maxBytes} bytes`));
+ return;
+ }
+ controller.enqueue(chunk);
+ },
+ }),
+ );
+ try {
+ // Only the content type is carried over, so the blob keeps it; copying the
+ // rest would attach a `Content-Length` and `Content-Encoding` that no
+ // longer describe this stream.
+ return await new Response(capped, {
+ headers: { "content-type": response.headers.get("content-type") ?? "" },
+ }).blob();
+ } catch {
+ return undefined;
+ }
+};
+
+/**
+ * Fetch `url` with the hardened options shared by both resources and read the
+ * body under `maxBytes` (rejected up front when `Content-Length` declares an
+ * oversize response, enforced while streaming otherwise). Returns `undefined`
+ * on a non-200 status or when the cap is exceeded; network errors propagate
+ * to the caller.
+ */
+const fetchCapped = async (
+ url: URL,
+ accept: string,
+ maxBytes: number,
+): Promise<{ response: Response; blob: Blob } | undefined> => {
+ // AbortController + setTimeout matches the rest of the FE (e.g.
+ // `lib/utils/dnssec/doh.ts`, `lib/utils/ssoDiscovery.ts`); we avoid
+ // `AbortSignal.timeout` because the project still supports browsers
+ // without it. The timer stays armed until the body has been read, so a
+ // slow origin can't keep the request pending indefinitely.
+ const controller = new AbortController();
+ const timeoutId = setTimeout(
+ () => controller.abort(),
+ APP_METADATA_FETCH_TIMEOUT_MILLIS,
+ );
+ try {
+ const response = await fetch(url.href, {
+ // fail on redirects
+ redirect: "error",
+ headers: {
+ Accept: accept,
+ },
+ // do not send cookies or other credentials
+ credentials: "omit",
+ signal: controller.signal,
+ });
+ if (response.status !== 200) {
+ // Cancel the stream so the download stops now, not at GC time.
+ await response.body?.cancel().catch(() => undefined);
+ return undefined;
+ }
+ const declaredLength = response.headers.get("content-length");
+ if (declaredLength !== null && Number(declaredLength) > maxBytes) {
+ await response.body?.cancel().catch(() => undefined);
+ return undefined;
+ }
+ const blob = await readBodyCapped(response, maxBytes);
+ return blob === undefined ? undefined : { response, blob };
+ } finally {
+ clearTimeout(timeoutId);
+ }
+};
+
+/** Marker for a field that is present but violates the requirements. It
+ * rejects the whole document rather than just itself — see
+ * {@link fetchAppMetadata}. */
+const INVALID = Symbol("invalid");
+
+/** A validated field: the value to use, `undefined` when the field is absent,
+ * or {@link INVALID} when it is present and unusable. */
+type Validated = T | undefined | typeof INVALID;
+
+/**
+ * Reject the document, logging which field is at fault and why. Without this,
+ * a mistake in the file would be invisible to the app's developers: II simply
+ * falls back to the previous presentation, on a screen they don't control.
+ */
+const reject = (field: string, problem: string): typeof INVALID => {
+ console.warn(`Ignoring ${APP_METADATA_PATH}: \`${field}\` ${problem}.`);
+ return INVALID;
+};
+
+/** Characters an app-provided text field must not contain: control characters
+ * (except the ASCII whitespace ones \t \n \v \f \r, which are normalized to
+ * spaces below), the bidi embeddings and overrides U+202A-U+202E, and the
+ * deprecated U+FEFF.
+ *
+ * Only the reordering controls are refused. An override makes text render in
+ * an order other than the one it is written in, which is what would let a name
+ * read as something it doesn't contain; the embeddings are deprecated in
+ * favour of the isolates for the same reason.
+ *
+ * Deliberately allowed, because mixed-direction and non-Latin names need them:
+ * the bidi marks U+200E/U+200F/U+061C, which are zero-width hints that only
+ * affect where neutral characters (punctuation, digits) land at a direction
+ * boundary -- exactly what an Arabic name ending in "!" or a Hebrew name with
+ * an embedded Latin word requires; the bidi isolates U+2066-U+2069, the
+ * mechanism Unicode recommends for embedding a run of unknown direction (kept
+ * contained by {@link hasBalancedIsolates}); and the zero-width characters
+ * U+200B-U+200D, which carry meaning in several scripts -- line-break
+ * opportunities in Thai and Khmer, shaping in Persian, and holding emoji
+ * sequences together. */
+const FORBIDDEN_CHARACTERS =
+ // eslint-disable-next-line no-control-regex
+ /[\u0000-\u0008\u000e-\u001f\u007f-\u009f\u202a-\u202e\ufeff]/;
+
+/** Bidi isolate initiators (LRI, RLI, FSI) and the terminator (PDI). */
+const ISOLATE_INITIATORS = "\u2066\u2067\u2068";
+const ISOLATE_TERMINATOR = "\u2069";
+
+/**
+ * Whether the value closes every bidi isolate it opens, and closes none it
+ * didn't open.
+ *
+ * An isolate contains its contents, so it cannot reorder the text around it --
+ * but only while it is balanced. An unbalanced one runs to the end of the
+ * paragraph, which for a name interpolated into one of II's own sentences means
+ * past the app's string and into II's text. Requiring balance is what makes
+ * "an app can style its own name, never the screen around it" true.
+ */
+const hasBalancedIsolates = (value: string): boolean => {
+ let depth = 0;
+ for (const char of value) {
+ if (ISOLATE_INITIATORS.includes(char)) {
+ depth += 1;
+ } else if (char === ISOLATE_TERMINATOR) {
+ depth -= 1;
+ if (depth < 0) {
+ return false;
+ }
+ }
+ }
+ return depth === 0;
+};
+
+/** Characters that render as nothing: whitespace, the bidi marks and isolate
+ * controls, and the zero-width characters. A field made only of these reads as
+ * absent, so it is refused rather than displayed as a blank name. */
+const INVISIBLE_CHARACTERS = /[\s\u061c\u200b-\u200f\u2066-\u2069]/g;
+
+/**
+ * Validate an app-provided text field, returning the value to display (with
+ * whitespace collapsed and trimmed), `undefined` when the field is absent, or
+ * {@link INVALID} when it is present but does not meet the requirements.
+ *
+ * The length limit is applied to the value as served, counted in Unicode code
+ * points, so it is what the published JSON Schema expresses (which can capture
+ * every rule here except the isolate balance). Whitespace normalization is
+ * presentation only and never rescues a value that breaks a requirement.
+ */
+const validateTextField = (
+ value: unknown,
+ field: string,
+ maxLength: number,
+): Validated => {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (typeof value !== "string") {
+ return reject(field, "must be a string");
+ }
+ if ([...value].length > maxLength) {
+ return reject(field, `must not exceed ${maxLength} characters`);
+ }
+ if (FORBIDDEN_CHARACTERS.test(value)) {
+ return reject(
+ field,
+ "must not contain control characters, or bidirectional embeddings and overrides",
+ );
+ }
+ if (!hasBalancedIsolates(value)) {
+ return reject(field, "must close every bidirectional isolate it opens");
+ }
+ const normalized = value.replace(/\s+/g, " ").trim();
+ if (normalized.replace(INVISIBLE_CHARACTERS, "").length === 0) {
+ return reject(field, "must contain at least one visible character");
+ }
+ return normalized;
+};
+
+/**
+ * Validate the `logo` field: it must resolve (relative to the app origin) to a
+ * URL on that same origin. Same-origin keeps the sign-in private (no third
+ * party learns about it through an image load) and rules out `data:`,
+ * `javascript:` and other non-http(s) schemes, whose origin never matches.
+ */
+const validateLogoUrl = (value: unknown, origin: string): Validated => {
+ if (value === undefined) {
+ return undefined;
+ }
+ if (typeof value !== "string" || value.length === 0) {
+ return reject("logo", "must be a non-empty string");
+ }
+ let url: URL;
+ try {
+ url = new URL(value, origin);
+ } catch {
+ return reject("logo", "must be a valid URL");
+ }
+ if (url.origin !== new URL(origin).origin) {
+ return reject("logo", "must be on the same origin as the document");
+ }
+ return url;
+};
+
+/**
+ * Re-encode the downloaded bytes as an image II has produced itself, and hand
+ * back a `blob:` URL for it.
+ *
+ * `createImageBitmap` rejects anything that isn't an image the browser can
+ * decode, so the content-type header is never taken on trust. The bitmap is
+ * then drawn once into a canvas and encoded again, which flattens an animation
+ * to its first frame, drops whatever else rode along in the original container,
+ * and scales the result down to what the screens actually render.
+ *
+ * The result is handed over as a `blob:` URL rather than a `data:` URL so the
+ * bytes stay in the browser's blob store instead of being copied into the DOM
+ * and the JS heap as a base64 string. The URL lives as long as the page: the
+ * metadata store fetches once per origin and nothing supersedes the value, so
+ * there is no point at which it could be revoked while still in use.
+ */
+const transcodeToObjectUrl = async (
+ blob: Blob,
+): Promise => {
+ // The blob goes to the decoder as it came off the network, so the encoded
+ // image is never a JS buffer either.
+ const bitmap = await createImageBitmap(blob);
+ try {
+ if (
+ bitmap.width === 0 ||
+ bitmap.height === 0 ||
+ bitmap.width > MAX_APP_LOGO_DIMENSION ||
+ bitmap.height > MAX_APP_LOGO_DIMENSION
+ ) {
+ return undefined;
+ }
+ const scale = Math.min(
+ 1,
+ APP_LOGO_RENDER_SIZE / Math.max(bitmap.width, bitmap.height),
+ );
+ const canvas = document.createElement("canvas");
+ canvas.width = Math.max(1, Math.round(bitmap.width * scale));
+ canvas.height = Math.max(1, Math.round(bitmap.height * scale));
+ const context = canvas.getContext("2d");
+ if (context === null) {
+ return undefined;
+ }
+ context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
+ const blob = await new Promise((resolve) =>
+ // WebP keeps logos small and preserves transparency; browsers that
+ // can't encode it fall back to PNG on their own, which does too.
+ canvas.toBlob(resolve, "image/webp"),
+ );
+ return blob === null ? undefined : URL.createObjectURL(blob);
+ } finally {
+ bitmap.close();
+ }
+};
+
+/**
+ * Download the logo asset, check it against the content-type allowlist and the
+ * size cap, and return a `blob:` URL for II's own re-encoding of it (see
+ * {@link transcodeToObjectUrl}).
+ */
+const fetchLogoObjectUrl = async (url: URL): Promise => {
+ const result = await fetchCapped(url, "image/*", MAX_APP_LOGO_SIZE);
+ if (result === undefined) {
+ return undefined;
+ }
+ const contentType = (result.response.headers.get("content-type") ?? "")
+ .split(";")[0]
+ .trim()
+ .toLowerCase();
+ if (!APP_LOGO_CONTENT_TYPES.includes(contentType)) {
+ return undefined;
+ }
+ if (result.blob.size === 0) {
+ return undefined;
+ }
+ return await transcodeToObjectUrl(result.blob);
+};
+
+/**
+ * Fetch and validate the app metadata served by `origin` on
+ * {@link APP_METADATA_PATH}.
+ *
+ * Validation is all-or-nothing: a field that does not meet the requirements
+ * rejects the whole document, so an app never ends up displayed with half of
+ * its metadata applied and nothing to indicate why. The result is `undefined`
+ * when the file is absent, cannot be fetched (e.g. missing CORS headers), is
+ * malformed, fails validation, or carries no usable field — callers should
+ * then fall back to other sources (e.g. the hostname).
+ *
+ * @param origin Origin the app's identity is derived for: the validated
+ * derivation origin when the authorization request carries one, and the
+ * requesting origin otherwise. That origin is the single source of truth for
+ * an app's presentation, so sibling origins sharing it (its alternative
+ * origins, which it has certified as its own) present identically without
+ * having to duplicate the document.
+ */
+export const fetchAppMetadata = async (
+ origin: string,
+): Promise => {
+ try {
+ const url = new URL(APP_METADATA_PATH, origin);
+ const result = await fetchCapped(
+ url,
+ "application/json",
+ MAX_APP_METADATA_SIZE,
+ );
+ if (result === undefined) {
+ return undefined;
+ }
+ // Fatal decoding: malformed UTF-8 rejects the file (via the catch below)
+ // instead of silently turning into U+FFFD on the sign-in screen.
+ // The document is at most 8 KiB and has to be parsed, so this one does
+ // become a JS string — unlike the logo, which never leaves the blob store.
+ const parsed: unknown = JSON.parse(
+ new TextDecoder("utf-8", { fatal: true }).decode(
+ await result.blob.arrayBuffer(),
+ ),
+ );
+ if (
+ parsed === null ||
+ typeof parsed !== "object" ||
+ Array.isArray(parsed)
+ ) {
+ return undefined;
+ }
+ const { name, description, logo } = parsed as Record;
+ // Validate every field before bailing out, so a document with more than
+ // one problem reports all of them in one go.
+ const validName = validateTextField(name, "name", MAX_APP_NAME_LENGTH);
+ const validDescription = validateTextField(
+ description,
+ "description",
+ MAX_APP_DESCRIPTION_LENGTH,
+ );
+ const logoUrl = validateLogoUrl(logo, origin);
+ if (
+ validName === INVALID ||
+ validDescription === INVALID ||
+ logoUrl === INVALID
+ ) {
+ return undefined;
+ }
+ const metadata: AppMetadata = {
+ name: validName,
+ description: validDescription,
+ };
+ if (logoUrl !== undefined) {
+ // The asset is a second network resource, so unlike the fields of the
+ // document its failures aren't necessarily authoring mistakes (a 500 or
+ // a dropped connection is transient). Losing just the logo is the better
+ // outcome here: the name and description still render.
+ metadata.logo = await fetchLogoObjectUrl(logoUrl).catch(() => undefined);
+ if (metadata.logo === undefined) {
+ console.warn(
+ `Ignoring the \`logo\` in ${APP_METADATA_PATH}: it could not be decoded as a still image of an allowed type, within ${MAX_APP_LOGO_SIZE} bytes and ${MAX_APP_LOGO_DIMENSION} pixels per axis.`,
+ );
+ }
+ }
+ if (
+ metadata.name === undefined &&
+ metadata.description === undefined &&
+ metadata.logo === undefined
+ ) {
+ return undefined;
+ }
+ return metadata;
+ } catch {
+ // Missing file, missing CORS headers, redirect, timeout, invalid JSON, …:
+ // the app simply gets the default (hostname-based) presentation.
+ return undefined;
+ }
+};
diff --git a/src/frontend/src/lib/utils/urlUtils.test.ts b/src/frontend/src/lib/utils/urlUtils.test.ts
index 4d69d42abc..fe9ab9cfd6 100644
--- a/src/frontend/src/lib/utils/urlUtils.test.ts
+++ b/src/frontend/src/lib/utils/urlUtils.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
-import { isSameOrigin } from "./urlUtils";
+import { isSameOrigin, originLabel } from "./urlUtils";
describe("urlUtils", () => {
describe("isSameOrigin", () => {
@@ -71,4 +71,36 @@ describe("urlUtils", () => {
expect(isSameOrigin("invalid-url", "another-invalid-url")).toBe(false);
});
});
+
+ describe("originLabel", () => {
+ it("should collapse ordinary https origins to their hostname", () => {
+ expect(originLabel("https://example.com")).toBe("example.com");
+ expect(originLabel("https://example.com:443")).toBe("example.com");
+ expect(originLabel("https://sub.example.com/path")).toBe(
+ "sub.example.com",
+ );
+ });
+
+ it("should keep a non-default port, which distinguishes the origin", () => {
+ // https://example.com and https://example.com:8443 are different
+ // origins deriving different principals, so they must not share a label.
+ expect(originLabel("https://example.com:8443")).toBe(
+ "https://example.com:8443",
+ );
+ expect(originLabel("https://example.com:8443")).not.toBe(
+ originLabel("https://example.com"),
+ );
+ });
+
+ it("should keep a non-https scheme rather than hide it", () => {
+ expect(originLabel("http://example.com")).toBe("http://example.com");
+ expect(originLabel("http://localhost:5173")).toBe(
+ "http://localhost:5173",
+ );
+ });
+
+ it("should show unparseable values verbatim", () => {
+ expect(originLabel("not-an-origin")).toBe("not-an-origin");
+ });
+ });
});
diff --git a/src/frontend/src/lib/utils/urlUtils.ts b/src/frontend/src/lib/utils/urlUtils.ts
index aafa4517e1..474572fa0e 100644
--- a/src/frontend/src/lib/utils/urlUtils.ts
+++ b/src/frontend/src/lib/utils/urlUtils.ts
@@ -20,3 +20,28 @@ export const isSameOrigin = (urlA: string, urlB: string): boolean => {
return urlA === urlB;
}
};
+
+/**
+ * The label shown to the user for an origin that acts as a trust anchor — the
+ * hostname badge next to app-provided (permissionless) metadata.
+ *
+ * Ordinary https origins on the default port collapse to their hostname
+ * (`https://example.com` becomes `example.com`), which is the form users
+ * recognise. Any component that would otherwise be hidden is kept: a
+ * non-https scheme, or a non-default port. Those distinguish origins that
+ * derive different principals, so `https://example.com` and
+ * `https://example.com:8443` must never present the same anchor.
+ *
+ * @param origin Origin to label, e.g. a postMessage channel origin.
+ */
+export const originLabel = (origin: string): string => {
+ try {
+ const url = new URL(origin);
+ return url.protocol === "https:" && url.port === ""
+ ? url.hostname
+ : url.origin;
+ } catch {
+ // Not parseable as a URL: show it verbatim rather than hiding it.
+ return origin;
+ }
+};
diff --git a/src/frontend/src/routes/(new-styling)/authorize/+page.svelte b/src/frontend/src/routes/(new-styling)/authorize/+page.svelte
index 61996ca8c3..bd4c93e028 100644
--- a/src/frontend/src/routes/(new-styling)/authorize/+page.svelte
+++ b/src/frontend/src/routes/(new-styling)/authorize/+page.svelte
@@ -9,7 +9,7 @@
channelStore,
establishedChannelStore,
} from "$lib/stores/channelStore";
- import { getDapps } from "$lib/legacy/flows/dappsExplorer/dapps";
+ import { getAppMetadataStore } from "$lib/stores/app-metadata.store";
import { handleError } from "$lib/components/utils/error";
import { toaster } from "$lib/components/utils/toaster";
import { t } from "$lib/stores/locale.store";
@@ -107,10 +107,17 @@
);
});
- const dapps = getDapps();
- const dapp = $derived(
- dapps.find((dapp) => dapp.hasOrigin($establishedChannelStore.origin)),
+ // The app's display metadata is published on the origin its identity is
+ // derived for; screens fall back to the channel origin until the
+ // authorization context is established (and for apps without a derivation
+ // origin the two are the same origin anyway).
+ const metadataStore = $derived(
+ getAppMetadataStore(
+ $authorizationStore?.effectiveOrigin ?? $establishedChannelStore.origin,
+ $establishedChannelStore.origin,
+ ),
);
+ const dapp = $derived($metadataStore);
// --- View selection ---
const selectedIdentity = $derived($lastUsedIdentitiesStore.selected);
@@ -480,7 +487,13 @@
});
-{#snippet upgradePanel()}
+
+{#snippet upgradePanel(showsAppOrigin: boolean)}
- {#if dapp?.name !== undefined}
+ {#if showsAppOrigin && dapp.name !== undefined}
{@const application = dapp.name}
{$t`${application} has moved to the new Internet Identity`}
{:else}
@@ -567,13 +580,13 @@
{/if}
{/snippet}
-{#snippet panelWrapper(content: Snippet)}
+{#snippet panelWrapper(content: Snippet, showsAppOrigin = false)}
{#if $GUIDED_UPGRADE || $MIN_GUIDED_UPGRADE}
- {@render upgradePanel()}
+ {@render upgradePanel(showsAppOrigin)}
{/if}
{@render panelWrapper(ssoNormalLoginContent)}
{:else if selectedIdentity !== undefined}
-
- {@render panelWrapper(continueContent)}
+
+ {@render panelWrapper(continueContent, true)}
{:else}
-
- {@render panelWrapper(authWizardContent)}
+
+ {@render panelWrapper(authWizardContent, true)}
{/if}
{#snippet attributeConsentContent()}
diff --git a/src/frontend/src/routes/(new-styling)/authorize/views/AuthWizardView.svelte b/src/frontend/src/routes/(new-styling)/authorize/views/AuthWizardView.svelte
index 6531597cee..a10a19dca1 100644
--- a/src/frontend/src/routes/(new-styling)/authorize/views/AuthWizardView.svelte
+++ b/src/frontend/src/routes/(new-styling)/authorize/views/AuthWizardView.svelte
@@ -2,7 +2,7 @@
import { establishedChannelStore } from "$lib/stores/channelStore";
import { authorizationStore } from "$lib/stores/authorization.store";
import AuthorizeHeader from "$lib/components/ui/AuthorizeHeader.svelte";
- import { getDapps } from "$lib/legacy/flows/dappsExplorer/dapps";
+ import { getAppMetadataStore } from "$lib/stores/app-metadata.store";
import { AuthWizard } from "$lib/components/wizards/auth";
import { t } from "$lib/stores/locale.store";
import type { AuthMode } from "$lib/flows/authFlow.svelte";
@@ -21,12 +21,17 @@
mode = $bindable("both"),
}: Props = $props();
- const dapps = getDapps();
- const dapp = $derived(
- dapps.find((dapp) => dapp.hasOrigin($establishedChannelStore.origin)),
+ // Metadata comes from the origin the identity is derived for (the app's
+ // derivation origin when it uses one); the badge in the header keeps showing
+ // the origin the user is signing in from.
+ const metadataOrigin = $derived(
+ $authorizationStore?.effectiveOrigin ?? $establishedChannelStore.origin,
+ );
+ const metadataStore = $derived(
+ getAppMetadataStore(metadataOrigin, $establishedChannelStore.origin),
);
const dappName = $derived(
- dapp?.name ?? new URL($establishedChannelStore.origin).hostname,
+ $metadataStore.name ?? new URL($establishedChannelStore.origin).hostname,
);
@@ -37,7 +42,7 @@
bind:mode
ssoOrigin={$authorizationStore?.effectiveOrigin}
>
-
+
{mode === "signup"
? $t`Create an Identity`
diff --git a/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte b/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte
index 7897982890..dd2d152f64 100644
--- a/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte
+++ b/src/frontend/src/routes/(new-styling)/authorize/views/ContinueView.svelte
@@ -9,7 +9,7 @@
AuthenticationV2Events,
authenticationV2Funnel,
} from "$lib/utils/analytics/authenticationV2Funnel";
- import { getDapps } from "$lib/legacy/flows/dappsExplorer/dapps";
+ import { getAppMetadataStore } from "$lib/stores/app-metadata.store";
import { AuthLastUsedFlow } from "$lib/flows/authLastUsedFlow.svelte";
import { plural, t } from "$lib/stores/locale.store";
import Toggle from "$lib/components/ui/Toggle.svelte";
@@ -176,6 +176,11 @@
let isEditAccountDialogVisibleForNumber = $state<
AccountNumber | PRIMARY_ACCOUNT_NUMBER | null
>(null);
+ // The fallback label the edit dialog opened with. The live value can change
+ // while the dialog is open (the app's metadata resolves asynchronously),
+ // which would shift an unnamed account's baseline under an untouched form
+ // and persist the stale fallback as an explicit name on save.
+ let editDialogFallbackName = $state();
const isEditAccountDialogVisibleFor = $derived(
accounts?.find(
@@ -186,14 +191,28 @@
const isAccountLimitReached = $derived(
accounts !== undefined && accounts.length >= 5,
);
- const dapps = getDapps();
- const application = $derived(
- dapps.find((dapp) => dapp.hasOrigin(displayOrigin))?.name,
+ // The app's metadata is published on the origin its identity is derived for
+ // (`effectiveOrigin`), which is also where its accounts live — so the account
+ // labels below stay stable no matter which of its origins the user signs in
+ // from. `displayOrigin` remains what the header shows.
+ const metadataStore = $derived(
+ getAppMetadataStore(effectiveOrigin, displayOrigin),
);
+ const application = $derived($metadataStore.name);
const dappName = $derived(application ?? new URL(displayOrigin).hostname);
- const primaryAccountName = $derived(
- application !== undefined ? $t`My ${application} account` : $t`My account`,
- );
+ // Account names are capped at 32 characters in `EditAccount`; a longer
+ // (e.g. app-provided) name would make the fallback label unsaveable there,
+ // so it falls back to the generic label instead.
+ const MAX_ACCOUNT_NAME_LENGTH = 32;
+ const primaryAccountName = $derived.by(() => {
+ if (application !== undefined) {
+ const label = $t`My ${application} account`;
+ if (label.length <= MAX_ACCOUNT_NAME_LENGTH) {
+ return label;
+ }
+ }
+ return $t`My account`;
+ });
const existingNames = $derived(
accounts?.map((account) => account.name[0] ?? primaryAccountName) ?? [],
);
@@ -390,6 +409,12 @@
isCreateAccountDialogVisible = false;
}
};
+ const openEditAccountDialog = (
+ accountNumber: AccountNumber | PRIMARY_ACCOUNT_NUMBER,
+ ): void => {
+ editDialogFallbackName = primaryAccountName;
+ isEditAccountDialogVisibleForNumber = accountNumber;
+ };
const handleEditAccount = async (account: {
name: string;
isDefaultSignIn: boolean;
@@ -405,7 +430,10 @@
return;
}
const nameChanged =
- account.name !== (accounts[index].name[0] ?? primaryAccountName);
+ account.name !==
+ (accounts[index].name[0] ??
+ editDialogFallbackName ??
+ primaryAccountName);
const defaultChanged =
account.isDefaultSignIn &&
defaultAccountNumber !== accounts[index].account_number[0];
@@ -544,8 +572,7 @@