Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions docs/ii-spec.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,64 @@ 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 on the URL `/.well-known/ii-app-metadata` of its origin:
Comment thread
aterga marked this conversation as resolved.
Outdated

```json
{
"name": "Example App",
"description": "A short tagline shown on the sign-in screen",
"logo": "/logo.png"
}
```

Internet Identity fetches this document from the origin of the client application (the origin shown to the user, i.e. `event.origin` of the authorization request, not the `derivationOrigin`) when the authorization flow starts. When the document is missing or invalid, Internet Identity falls back to displaying the origin's hostname.
Comment thread
aterga marked this conversation as resolved.
Outdated

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.

Requirements:

- All fields are optional, and unknown fields are ignored. Fields that fail validation are ignored individually; a document without any valid field is ignored entirely.
- `name` must not exceed 40 characters and `description` must not exceed 120 characters (after whitespace normalization). Control characters and bidirectional-override characters are stripped.
- `logo` must be a URL (relative URLs are resolved against the application origin) pointing to an image _on the same origin_. 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 must be served with one of the content types `image/png`, `image/jpeg`, `image/webp`, `image/gif`, `image/avif` or `image/svg+xml`, and must not exceed 256 KiB.
Comment thread
aterga marked this conversation as resolved.
Outdated
- 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}

```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",
"maxLength": 40
},
"description": {
"description": "Short description or tagline of the application",
"type": "string",
"maxLength": 120
},
"logo": {
"description": "URL of the application logo, on the same origin as this document",
"type": "string"
}
}
}
```

:::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.
Expand Down
31 changes: 21 additions & 10 deletions src/frontend/src/lib/components/ui/AuthorizeHeader.svelte
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script lang="ts">
import Badge from "$lib/components/ui/Badge.svelte";
import Ellipsis from "$lib/components/utils/Ellipsis.svelte";
import { getDapps } from "$lib/legacy/flows/dappsExplorer/dapps.js";
import { getAppMetadataStore } from "$lib/stores/app-metadata.store";
import type { HTMLAttributes } from "svelte/elements";
import { GlobeIcon } from "@lucide/svelte";

Expand All @@ -12,8 +12,10 @@
const { class: className, origin, ...props }: Props = $props();

const hostname = $derived(new URL(origin).hostname);
const dapps = getDapps();
const dapp = $derived(dapps.find((dapp) => dapp.hasOrigin(origin)));
// App-provided (permissionless) display metadata; the hostname badge below
// stays visible regardless, as the trust anchor the user can verify.
const metadataStore = $derived(getAppMetadataStore(origin));
Comment thread
aterga marked this conversation as resolved.
Outdated
const metadata = $derived($metadataStore);
</script>

<div
Expand All @@ -26,14 +28,14 @@
<div
class={[
"flex shrink-0 items-center justify-center overflow-hidden rounded-2xl",
dapp?.logoSrc === undefined &&
metadata.logo === undefined &&
"border-border-tertiary text-fg-primary bg-bg-primary border",
]}
>
{#if dapp?.logoSrc !== undefined}
{#if metadata.logo !== undefined}
<img
src={dapp.logoSrc}
alt={`${dapp.name} logo`}
src={metadata.logo}
alt={`${metadata.name ?? hostname} logo`}
class={["h-20 max-w-50 object-contain"]}
/>
{:else}
Expand All @@ -42,7 +44,16 @@
</div>
{/if}
</div>
<Badge size="sm" class="max-w-[75%]">
<Ellipsis text={hostname} position="middle" />
</Badge>
<div class="flex max-w-full flex-col items-center gap-2">
<Badge size="sm" class="max-w-[75%]">
<Ellipsis text={hostname} position="middle" />
</Badge>
{#if metadata.description !== undefined}
<p
class="text-text-tertiary line-clamp-2 max-w-[85%] text-center text-sm text-balance"
>
{metadata.description}
</p>
{/if}
</div>
</div>
123 changes: 123 additions & 0 deletions src/frontend/src/lib/stores/app-metadata.store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
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<AppMetadata | undefined> => 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 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);
});
59 changes: 59 additions & 0 deletions src/frontend/src/lib/stores/app-metadata.store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* 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: every origin
* can serve a `/.well-known/ii-app-metadata` file (see
* {@link fetchAppMetadata}). 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<string, Readable<AppMetadata>>();

/** Fallback display metadata from the curated dapps list shipped with II. */
const knownDappMetadata = (origin: string): AppMetadata => {
const dapp = getDapps().find((dapp) => dapp.hasOrigin(origin));
return dapp === undefined
? {}
: { name: dapp.name, description: dapp.oneLiner, logo: dapp.logoSrc };
};

/**
* Reactive display metadata for the given origin.
*
* Resolves synchronously to the curated-list fallback (so known dapps never
* flash an unbranded screen) and updates in place once the origin'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 Origin as shown to the user (e.g. the postMessage channel
* origin), not the derivation origin (which may be remapped).
*/
export const getAppMetadataStore = (origin: string): Readable<AppMetadata> => {
const existing = storeByOrigin.get(origin);
if (existing !== undefined) {
return existing;
}
const { subscribe, set } = writable<AppMetadata>(knownDappMetadata(origin));
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();
};
Loading
Loading