From 190e704d6499496508ae9ddab356fe5df51d328e Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:07:03 +0200 Subject: [PATCH 01/20] feat(internet-identity): target @icp-sdk/auth v9 Co-Authored-By: Claude Opus 5 (1M context) --- evaluations/internet-identity.json | 11 ++- .../references/frontend-template.md | 2 +- skills/internet-identity/SKILL.md | 96 +++++++++++-------- 3 files changed, 65 insertions(+), 44 deletions(-) diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json index 21697707..34adeb1d 100644 --- a/evaluations/internet-identity.json +++ b/evaluations/internet-identity.json @@ -9,14 +9,17 @@ "All await calls are inside async functions — no bare top-level await at module scope", "Does NOT recommend changing build.target to 'esnext' or 'es2022' in Vite config", "Uses AuthClient from '@icp-sdk/auth/client' (submodule import, not root '@icp-sdk/auth')", - "Shows an initialization pattern like async function init() or equivalent lifecycle hook" + "Shows an initialization pattern like async function init() or equivalent lifecycle hook", + "Constructs the client with no identityProvider, or with the { authorizeUrl, canisterId } pair — never a bare URL string" ] }, { "name": "Local II URL", - "prompt": "My Internet Identity login works on mainnet but fails locally — the popup never opens. What URL should I use for the local identityProvider? Just the URL and a one-line explanation.", + "prompt": "My Internet Identity login works on mainnet but fails locally — the popup never opens. What do I pass as identityProvider for a local Internet Identity? Just the value and a one-line explanation.", "expected_behaviors": [ - "Local URL uses the well-known II canister ID: http://id.ai.localhost:8000", + "identityProvider is an object with both authorizeUrl and canisterId, not a bare URL string (9.x throws a TypeError on a string)", + "Local authorizeUrl uses the well-known II frontend alias: http://id.ai.localhost:8000/authorize", + "canisterId is the well-known II backend canister, rdmx6-jaaaa-aaaaa-aaadq-cai, which is the same locally as on mainnet", "Does NOT suggest dynamic lookup via ic_env cookie, environment variables, or dfx commands for the II canister ID — it is a well-known, hardcoded ID" ] }, @@ -46,7 +49,7 @@ "expected_behaviors": [ "Identifies 2vxsx-fae as the anonymous principal", "Explains that authentication silently failed or the identity was not properly extracted", - "Suggests checking the identityProvider URL passed to the AuthClient constructor", + "Suggests checking the authorizeUrl passed to the AuthClient constructor (including the /authorize path)", "Suggests checking that authClient.signIn() did not reject (use try/catch around the awaited promise) and that the identity from await authClient.getIdentity() is passed to HttpAgent" ] }, diff --git a/skills/caffeine-app/references/frontend-template.md b/skills/caffeine-app/references/frontend-template.md index 73250e31..cfd7eebd 100644 --- a/skills/caffeine-app/references/frontend-template.md +++ b/skills/caffeine-app/references/frontend-template.md @@ -65,7 +65,7 @@ deps once the app works). Verified to install with pnpm and build with Vite. }, "dependencies": { "@caffeineai/core-infrastructure": "^1.0.0", - "@icp-sdk/auth": "^7.1.0", + "@icp-sdk/auth": "^9.0.0", "@icp-sdk/core": "^5.3.0", "@react-three/cannon": "~6.6.0", "@react-three/drei": "~10.0.8", diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 4e9e474d..f7d142af 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -16,7 +16,7 @@ Internet Identity (II) is the Internet Computer's native authentication system. ## Prerequisites -- `@icp-sdk/auth` (>= 7.0.0), `@icp-sdk/core` (>= 5.3.0) (`AttributesIdentity` was added in core v5.3.0) +- `@icp-sdk/auth` (>= 9.0.0), `@icp-sdk/core` (>= 5.3.0) (`AttributesIdentity` was added in core v5.3.0) - For the Motoko backend example: `mo:identity-attributes` >= 0.4.0 (mops) — the mixin that injects the two sign-in methods and verifies the bundle for you. It pulls in `mo:core` >= 2.5.0 and requires `moc` >= 1.6.0 for the `include` mixin. ## Canister IDs @@ -28,17 +28,17 @@ Internet Identity (II) is the Internet Computer's native authentication system. ## Mistakes That Break Your Build -1. **Using the wrong II URL for the environment.** The identity provider URL must point to the **frontend** canister (`uqzsh-gqaaa-aaaaq-qaada-cai`), not the backend. Mainnet uses `https://id.ai/authorize`. Local-only II (when `ii: true` is set in `icp.yaml`) uses `http://id.ai.localhost:8000/authorize`. Both canister IDs are well-known and identical on mainnet and local replicas — hardcode them rather than doing a dynamic lookup. +1. **Using the wrong II URL for the environment.** `authorizeUrl` must point to the **frontend** canister (`uqzsh-gqaaa-aaaaq-qaada-cai`), not the backend. Mainnet uses `https://id.ai/authorize`. Local-only II (when `ii: true` is set in `icp.yaml`) uses `http://id.ai.localhost:8000/authorize`. Both canister IDs are well-known and identical on mainnet and local replicas — hardcode them rather than doing a dynamic lookup. -2. **Forgetting `/authorize` in the `identityProvider` URL.** In `@icp-sdk/auth` 7.x the URL is used verbatim; the client does **not** append `/authorize` for you (it did in 5.x). Passing `https://id.ai` opens the II home page in the popup and never returns a delegation — the login button appears to do nothing. Always include the `/authorize` path. +2. **Passing `identityProvider` as a URL string, or naming only half of it.** In 9.x it is an object — `{ authorizeUrl, canisterId }` — and both fields are required together: the page a ceremony renders at and the canister that mints delegations are separate facts, and neither is derived from the other. A string or a `URL` throws a `TypeError`. Omit the option entirely to get mainnet Internet Identity, which is what most apps want. The URL is used verbatim, so include the `/authorize` path: `https://id.ai` opens the II home page and never returns a delegation. -3. **Setting delegation expiry too long.** Maximum delegation expiry is 30 days (2_592_000_000_000_000 nanoseconds). Longer values are silently clamped, which causes confusing session behavior. Use 8 hours for normal apps, 30 days maximum for "remember me" flows. +3. **Treating `maxTimeToLive` as the lifetime of the key the frontend signs with.** In 9.x it bounds the **session** at Internet Identity, and `maxTimeToIdle` ends a session nobody has used; the delegation your calls are signed with is short-lived and replaced for you. Leave both unset unless the app has a policy of its own — the provider applies seven days of idleness and thirty days in total. Bound them where the data is sensitive, not to keep key material fresh. 4. **Not awaiting `signIn()` or skipping the `try`/`catch`.** `authClient.signIn()` returns a promise that rejects when the user closes the popup or authentication fails. Without `await` and a `catch`, those failures are silently swallowed. 5. **Using `shouldFetchRootKey` or `fetchRootKey()` instead of the `ic_env` cookie.** The `ic_env` cookie (set by the frontend canister or the Vite dev server) already contains the root key as `IC_ROOT_KEY`. Pass it via the `rootKey` option to `HttpAgent.create()` — this works in both local and production environments without environment branching. See the icp-cli skill's `references/binding-generation.md` for the pattern. Never call `fetchRootKey()` — it fetches the root key from the replica at runtime, which lets a man-in-the-middle substitute a fake key on mainnet. -6. **Getting `2vxsx-fae` as the principal after sign-in.** That is the anonymous principal -- it means authentication silently failed. Common causes: wrong `identityProvider` URL passed to the `AuthClient` constructor (especially missing `/authorize`), an unhandled rejection from `signIn()`, or reading `getIdentity()` before `signIn()` resolved. +6. **Getting `2vxsx-fae` as the principal after sign-in.** That is the anonymous principal -- it means authentication silently failed. Common causes: a wrong `authorizeUrl` on the `AuthClient` constructor (especially missing `/authorize`), an unhandled rejection from `signIn()`, or reading `getIdentity()` before `signIn()` resolved. Note that `getIdentity()` throws `SessionNotHeldError` rather than handing back an anonymous identity when a sign-in exists that this origin holds no credential for. 7. **Passing principal as string to backend.** The `AuthClient` gives you an `Identity` object. Backend canister methods receive the caller principal automatically via the IC protocol -- you do not pass it as a function argument. The caller principal is available on the backend via `shared(msg) { msg.caller }` in Motoko or `ic_cdk::api::msg_caller()` in Rust. For backend access control patterns, see the **canister-security** skill. @@ -65,7 +65,7 @@ Internet Identity (II) is the Internet Computer's native authentication system. **Default: use mainnet II from your local network.** Starting with `icp-cli >= 0.2.4`, the local network (pocket-ic, launched by `icp-cli-network-launcher`) is configured to trust the mainnet subnet's BLS signatures. Delegations signed by `https://id.ai` are accepted by your local replica, so both the sign-in flow *and* authenticated calls to a locally-deployed backend just work — no extra config in `icp.yaml`, no local II canister to manage, and the UI is the real one your users will see. -Point your frontend at `https://id.ai/authorize` unconditionally and you're done. +Construct the client with no `identityProvider` at all: mainnet Internet Identity is what it defaults to, and you're done. ### Fallback: deploy II locally @@ -78,7 +78,7 @@ networks: ii: true ``` -This deploys the II canisters automatically when the local network is started. The II frontend will be available at `http://id.ai.localhost:8000`, and the `identityProvider` URL becomes `http://id.ai.localhost:8000/authorize`. No canister entry is needed in your project — II is not part of your project's canisters. For the full `icp.yaml` canister configuration, see the **icp-cli** and **static-site** skills. +This deploys the II canisters automatically when the local network is started. The II frontend will be available at `http://id.ai.localhost:8000`, so the client is constructed with `identityProvider: { authorizeUrl: 'http://id.ai.localhost:8000/authorize', canisterId: 'rdmx6-jaaaa-aaaaa-aaadq-cai' }` — the canister id is the same locally, since system canisters keep their mainnet ids on the local network. No canister entry is needed in your project — II is not part of your project's canisters. For the full `icp.yaml` canister configuration, see the **icp-cli** and **static-site** skills. ### Frontend: Vanilla JavaScript/TypeScript Sign-In Flow @@ -93,25 +93,26 @@ import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; // Contains the root key and canister IDs — works in both local and production. const canisterEnv = safeGetCanisterEnv(); -// Construct once — identityProvider (and optionally derivationOrigin or -// openIdProvider for one-click sign-in: 'google' | 'apple' | 'microsoft') -// are configured at construction time, not per sign-in. Always include the -// `/authorize` path — the client uses the URL verbatim in 7.x. +// Mainnet Internet Identity is the default, so no identityProvider is needed: +// pocket-ic (icp-cli >= 0.2.4) trusts mainnet subnet signatures, so this works +// from local dev too. Pass { authorizeUrl, canisterId } only for a local II +// (`ii: true` in icp.yaml) or another deployment; both halves are required +// together, and a bare URL string throws. // -// Use mainnet II even from local dev: pocket-ic (icp-cli >= 0.2.4) trusts -// mainnet subnet signatures. Override to http://id.ai.localhost:8000/authorize -// only if you have `ii: true` in icp.yaml and want fully-offline dev. -const authClient = new AuthClient({ - identityProvider: "https://id.ai/authorize", -}); +// derivationOrigin, and openIdProvider for one-click sign-in +// ('google' | 'apple' | 'microsoft'), are also constructor options. +// +// Several clients may share an origin and read the same sign-in, so construct +// one where you need it and dispose of it when that view goes away. +const authClient = new AuthClient(); // Sign in: signIn() returns the new Identity directly and rejects if the user -// closes the popup or authentication fails. +// closes the popup or authentication fails. The session's bounds +// (maxTimeToIdle, maxTimeToLive) are optional; unset means Internet Identity's +// own, currently seven days idle and thirty days in total. async function signIn() { try { - const identity = await authClient.signIn({ - maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), // 8 hours in nanoseconds - }); + const identity = await authClient.signIn(); console.log("Signed in as:", identity.getPrincipal().toText()); return identity; } catch (error) { @@ -120,7 +121,8 @@ async function signIn() { } } -// Sign out +// Sign out, which ends the session at Internet Identity: every tab of this +// origin is signed out and the session cannot be resumed. async function signOut() { await authClient.signOut(); // Optionally reload or reset UI state @@ -147,9 +149,18 @@ async function init() { const actor = await createAuthenticatedActor(identity, canisterId, idlFactory); // Use actor to call backend methods } + + // Re-render when who is signed in here changes, including in another tab: + // getStatus() is 'signed-in' | 'signed-in-elsewhere' | 'expired' | + // 'signed-out', and the last three each want a different screen. + authClient.subscribe(() => render(authClient.getStatus())); } init(); + +// Release the browser listeners and the scheduled refresh when this view goes +// away. In a framework, call it from the unmount hook. +window.addEventListener("pagehide", () => authClient.dispose()); ``` ### Serving an app at more than one origin @@ -162,7 +173,6 @@ Pick the **canister address** as the derivation origin. Custom domains can be ch ```js const authClient = new AuthClient({ - identityProvider: "https://id.ai/authorize", derivationOrigin: "https://.icp.net", }); ``` @@ -276,17 +286,15 @@ async function signInWithAttributes(authClient, canisterId, idl) { const anonymousAgent = await HttpAgent.create(); const anonymousActor = Actor.createActor(idl, { agent: anonymousAgent, canisterId }); - // Mint the nonce, sign in, and request attributes in parallel. Passing the - // nonce as a promise lets requestAttributes start before it resolves, so the - // user still sees a single Internet Identity interaction. A frontend-generated - // nonce would defeat replay protection — see Mistake #9. - const noncePromise = anonymousActor._internet_identity_sign_in_start(); - const signInPromise = authClient.signIn({ - maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), // 8 hours in nanoseconds - }); + // Mint the nonce, sign in, and request attributes in parallel. `nonce` is the + // function that fetches it, called when the client needs the value, so the + // request is in flight while the Internet Identity window opens and the user + // still sees a single interaction. A frontend-generated nonce would defeat + // replay protection — see Mistake #9. + const signInPromise = authClient.signIn(); const attributesPromise = authClient.requestAttributes({ keys: ["name", "verified_email"], // library reads verified_email for its email field - nonce: noncePromise, + nonce: () => anonymousActor._internet_identity_sign_in_start(), }); const identity = await signInPromise; @@ -324,7 +332,6 @@ For OpenID one-click sign-in, scope the attributes to the provider with the `sco import { AuthClient, scopedKeys } from "@icp-sdk/auth/client"; const authClient = new AuthClient({ - identityProvider: "https://id.ai/authorize", openIdProvider: "google", }); @@ -333,7 +340,7 @@ const authClient = new AuthClient({ // and the mo:identity-attributes library maps them onto the same name/email fields. const attributesPromise = authClient.requestAttributes({ keys: scopedKeys({ openIdProvider: "google", keys: ["name", "verified_email"] }), - nonce: noncePromise, + nonce: () => anonymousActor._internet_identity_sign_in_start(), }); ``` @@ -541,9 +548,20 @@ fn _internet_identity_sign_in_finish() -> SignInResult { Backend access control (anonymous principal rejection, role guards, caller binding in async functions) is not II-specific — the same patterns apply regardless of authentication method. See the **canister-security** skill for complete Motoko and Rust examples. -## 5.x API notes +## Older API notes + +Everything above targets `@icp-sdk/auth` 9.x. On an older major the same flow differs: + +**8.x** — what 9.x changed: + +- `identityProvider` was a URL string; it is now `{ authorizeUrl, canisterId }`, and a string throws. +- `storage` and its `IdbStorage` / `LocalStorage` classes became `credentialStorage` with `IdbCredentialStorage` (the default), `LocalCredentialStorage`, `MemoryCredentialStorage` and `SharedMemoryCredentialStorage`. `stateStorage` is new and holds the record of who is signed in, which is what makes tabs converge; `CookieStateStorage` extends that to sibling subdomains. +- `IdleManager` and its options (`idleOptions`, `onIdle`, `idleTimeout`, `disableIdle`) were removed. The session is bounded at Internet Identity instead, via `maxTimeToIdle` and `maxTimeToLive` on `signIn()`. +- `maxTimeToLive` bounded a delegation and defaulted to 8 hours; it now bounds the session and defaults to the provider's 30 days. +- `getStatus()`, `subscribe()`, `getPrincipal()` and `dispose()` are new; the `identity`, `keyType` and `targets` options are gone. +- See the [v9 upgrade guide](https://js.icp.build/auth/latest/upgrading/v9/) for the full list. -If you are pinned to `@icp-sdk/auth` 5.x, the same flow uses a different (callback-based) API: +**5.x** — a callback-based API: - `await AuthClient.create({...})` instead of `new AuthClient({...})` - `identityProvider` passed per-call to `login({...})` rather than at construction @@ -551,7 +569,7 @@ If you are pinned to `@icp-sdk/auth` 5.x, the same flow uses a different (callba - `authClient.logout()` instead of `authClient.signOut()` - `await authClient.isAuthenticated()` (async) instead of sync - `authClient.getIdentity()` (sync) instead of async -- 5.x auto-appends `/authorize` to the `identityProvider` URL, so you can pass just `https://id.ai`. In 7.x the path is required. -- No `requestAttributes` / `AttributesIdentity` support — the identity-attributes flow above requires 7.x. +- 5.x auto-appends `/authorize` to the `identityProvider` URL, so you can pass just `https://id.ai`. +- No `requestAttributes` / `AttributesIdentity` support — the identity-attributes flow above requires 7.x or later. -Upgrade to 7.x when you can — the promise-based API is harder to misuse and the callback variant has been removed. +Upgrade when you can: the promise-based API is harder to misuse, the callback variant has been removed, and 9.x re-mints the delegation your calls are signed with instead of leaving one key alive for the whole session. From 05fb42c208d614a8af942cf8bab13ba14920a560 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:15:52 +0200 Subject: [PATCH 02/20] docs(internet-identity): tie the client's lifecycle to what renders Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 46 +++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index f7d142af..f232c63a 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -157,10 +157,50 @@ async function init() { } init(); +``` + +The client's lifecycle belongs to whatever renders: construct one when a view +mounts, dispose of it when that view unmounts. Several clients may exist at once +and read the same sign-in, so this costs nothing. In React: + +```jsx +import { useEffect, useState } from "react"; +import { AuthClient } from "@icp-sdk/auth/client"; + +function useAuth() { + const [client] = useState(() => new AuthClient()); + const [status, setStatus] = useState(() => client.getStatus()); + + useEffect(() => { + const unsubscribe = client.subscribe(() => setStatus(client.getStatus())); + return () => { + unsubscribe(); + // Releases the browser listeners and the scheduled re-mint. Without it + // every mount leaves another client running. + client.dispose(); + }; + }, [client]); + + return { status, signIn: () => client.signIn(), signOut: () => client.signOut() }; +} -// Release the browser listeners and the scheduled refresh when this view goes -// away. In a framework, call it from the unmount hook. -window.addEventListener("pagehide", () => authClient.dispose()); +function App() { + const { status, signIn, signOut } = useAuth(); + + switch (status.state) { + case "signed-in": + return ; + case "expired": + // Names the account whose session ended, so this is "your session ended" + // rather than a bare signed-out screen. + return ; + case "signed-in-elsewhere": + // Only when the sign-in is shared across sibling subdomains. + return ; + case "signed-out": + return ; + } +} ``` ### Serving an app at more than one origin From 10e6adbf31c3fe9c5b0b80b2175166e59a8f9b65 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:16:34 +0200 Subject: [PATCH 03/20] docs(internet-identity): let the subscription re-render after a sign-out Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index f232c63a..068c597f 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -122,10 +122,10 @@ async function signIn() { } // Sign out, which ends the session at Internet Identity: every tab of this -// origin is signed out and the session cannot be resumed. +// origin is signed out and the session cannot be resumed. Nothing to reset or +// reload here — the state changes, so the subscription below re-renders. async function signOut() { await authClient.signOut(); - // Optionally reload or reset UI state } // Create an authenticated agent and actor. From de726d612bda7946063912beb52e17235ab97cca Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:17:07 +0200 Subject: [PATCH 04/20] docs(internet-identity): show the lifecycle in the component, not a wrapper Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 068c597f..7e0811dc 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -167,7 +167,7 @@ and read the same sign-in, so this costs nothing. In React: import { useEffect, useState } from "react"; import { AuthClient } from "@icp-sdk/auth/client"; -function useAuth() { +function App() { const [client] = useState(() => new AuthClient()); const [status, setStatus] = useState(() => client.getStatus()); @@ -181,24 +181,18 @@ function useAuth() { }; }, [client]); - return { status, signIn: () => client.signIn(), signOut: () => client.signOut() }; -} - -function App() { - const { status, signIn, signOut } = useAuth(); - switch (status.state) { case "signed-in": - return ; + return client.signOut()} />; case "expired": // Names the account whose session ended, so this is "your session ended" // rather than a bare signed-out screen. - return ; + return client.signIn()} />; case "signed-in-elsewhere": // Only when the sign-in is shared across sibling subdomains. - return ; + return client.signIn()} />; case "signed-out": - return ; + return client.signIn()} />; } } ``` From 276ebfe05055eb0b73676baee6bffdca9d461f54 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:18:55 +0200 Subject: [PATCH 05/20] docs(internet-identity): render on the status with useSyncExternalStore Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 36 ++++++++++++++----------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 7e0811dc..30ca08c7 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -159,40 +159,36 @@ async function init() { init(); ``` -The client's lifecycle belongs to whatever renders: construct one when a view -mounts, dispose of it when that view unmounts. Several clients may exist at once -and read the same sign-in, so this costs nothing. In React: +The client is an external store: `getStatus()` hands back the same object until +something changes it, so `useSyncExternalStore` renders on it directly. A client +that lives as long as the page needs no teardown; one scoped to a view is +disposed when that view unmounts, which releases its browser listeners and the +re-mint it has scheduled. ```jsx -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; import { AuthClient } from "@icp-sdk/auth/client"; +const authClient = new AuthClient(); + function App() { - const [client] = useState(() => new AuthClient()); - const [status, setStatus] = useState(() => client.getStatus()); - - useEffect(() => { - const unsubscribe = client.subscribe(() => setStatus(client.getStatus())); - return () => { - unsubscribe(); - // Releases the browser listeners and the scheduled re-mint. Without it - // every mount leaves another client running. - client.dispose(); - }; - }, [client]); + const status = useSyncExternalStore( + onChange => authClient.subscribe(onChange), + () => authClient.getStatus(), + ); switch (status.state) { case "signed-in": - return client.signOut()} />; + return authClient.signOut()} />; case "expired": // Names the account whose session ended, so this is "your session ended" // rather than a bare signed-out screen. - return client.signIn()} />; + return authClient.signIn()} />; case "signed-in-elsewhere": // Only when the sign-in is shared across sibling subdomains. - return client.signIn()} />; + return authClient.signIn()} />; case "signed-out": - return client.signIn()} />; + return authClient.signIn()} />; } } ``` From f6796ccf9d8a6d20c630d34266d4bbeea94511cd Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:19:48 +0200 Subject: [PATCH 06/20] docs(internet-identity): dispose the client a view owns Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 30ca08c7..bb890ee7 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -193,6 +193,33 @@ function App() { } ``` +A client that belongs to one view is created where that view is and disposed +when it goes. `prompt: 'none'` is the case that calls for a second client: it +asks Internet Identity to answer from the session it already holds, which is a +different authorize intent from an interactive sign-in, and `hint` names the +account the record already reports. + +```jsx +import { useEffect } from "react"; +import { AuthClient } from "@icp-sdk/auth/client"; + +// Rendered for 'signed-in-elsewhere': a sibling subdomain signed in, and this +// origin holds no credential for that account yet. +function Resume({ principal }) { + useEffect(() => { + const client = new AuthClient({ prompt: "none", hint: principal }); + // Resolves silently where the provider can, and rejects with + // InteractionRequiredError where a real ceremony is needed. + client.signIn().catch(() => {}); + // Without this the client outlives the dialog, still listening and still + // re-minting. + return () => client.dispose(); + }, [principal]); + + return

Signing you in…

; +} +``` + ### Serving an app at more than one origin II derives a principal per **origin**, so `https://.icp.net` and `https://shop.example.com` are two different users to the same person. To keep one account per person, pick **one** origin as the derivation origin and list the others as alternative origins. From 335c032d9882db4eda83e581946723e50c62cb2d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:21:30 +0200 Subject: [PATCH 07/20] docs(internet-identity): dispose the client when the app unmounts Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 38 +++++-------------------------- 1 file changed, 6 insertions(+), 32 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index bb890ee7..58d4cae3 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -160,13 +160,12 @@ init(); ``` The client is an external store: `getStatus()` hands back the same object until -something changes it, so `useSyncExternalStore` renders on it directly. A client -that lives as long as the page needs no teardown; one scoped to a view is -disposed when that view unmounts, which releases its browser listeners and the -re-mint it has scheduled. +something changes it, so `useSyncExternalStore` renders on it directly. Dispose +of the client when the view that owns it unmounts, which releases its browser +listeners and the re-mint it has scheduled. ```jsx -import { useSyncExternalStore } from "react"; +import { useEffect, useSyncExternalStore } from "react"; import { AuthClient } from "@icp-sdk/auth/client"; const authClient = new AuthClient(); @@ -177,6 +176,8 @@ function App() { () => authClient.getStatus(), ); + useEffect(() => () => authClient.dispose(), []); + switch (status.state) { case "signed-in": return authClient.signOut()} />; @@ -193,33 +194,6 @@ function App() { } ``` -A client that belongs to one view is created where that view is and disposed -when it goes. `prompt: 'none'` is the case that calls for a second client: it -asks Internet Identity to answer from the session it already holds, which is a -different authorize intent from an interactive sign-in, and `hint` names the -account the record already reports. - -```jsx -import { useEffect } from "react"; -import { AuthClient } from "@icp-sdk/auth/client"; - -// Rendered for 'signed-in-elsewhere': a sibling subdomain signed in, and this -// origin holds no credential for that account yet. -function Resume({ principal }) { - useEffect(() => { - const client = new AuthClient({ prompt: "none", hint: principal }); - // Resolves silently where the provider can, and rejects with - // InteractionRequiredError where a real ceremony is needed. - client.signIn().catch(() => {}); - // Without this the client outlives the dialog, still listening and still - // re-minting. - return () => client.dispose(); - }, [principal]); - - return

Signing you in…

; -} -``` - ### Serving an app at more than one origin II derives a principal per **origin**, so `https://.icp.net` and `https://shop.example.com` are two different users to the same person. To keep one account per person, pick **one** origin as the derivation origin and list the others as alternative origins. From 4857c14787f3fec97cdce7bd571cda91ce726549 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:22:29 +0200 Subject: [PATCH 08/20] docs(internet-identity): the component owns its client Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 58d4cae3..5100738b 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -165,18 +165,21 @@ of the client when the view that owns it unmounts, which releases its browser listeners and the re-mint it has scheduled. ```jsx -import { useEffect, useSyncExternalStore } from "react"; +import { useEffect, useRef, useSyncExternalStore } from "react"; import { AuthClient } from "@icp-sdk/auth/client"; -const authClient = new AuthClient(); - function App() { + // One client for as long as this component is mounted, not one per render. + const ref = useRef(null); + ref.current ??= new AuthClient(); + const authClient = ref.current; + const status = useSyncExternalStore( onChange => authClient.subscribe(onChange), () => authClient.getStatus(), ); - useEffect(() => () => authClient.dispose(), []); + useEffect(() => () => authClient.dispose(), [authClient]); switch (status.state) { case "signed-in": From 5308840d0d2f79f8fecfa9230562ad6f42133fb3 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:23:13 +0200 Subject: [PATCH 09/20] docs(internet-identity): one line for the component's client Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 5100738b..e0ed3fdf 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -165,14 +165,11 @@ of the client when the view that owns it unmounts, which releases its browser listeners and the re-mint it has scheduled. ```jsx -import { useEffect, useRef, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useSyncExternalStore } from "react"; import { AuthClient } from "@icp-sdk/auth/client"; function App() { - // One client for as long as this component is mounted, not one per render. - const ref = useRef(null); - ref.current ??= new AuthClient(); - const authClient = ref.current; + const authClient = useMemo(() => new AuthClient(), []); const status = useSyncExternalStore( onChange => authClient.subscribe(onChange), From a82c4442a78eb1e785f5b47ea3285aa8c24d14cb Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:25:41 +0200 Subject: [PATCH 10/20] docs(internet-identity): build the client once per mount Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index e0ed3fdf..348fae75 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -165,11 +165,12 @@ of the client when the view that owns it unmounts, which releases its browser listeners and the re-mint it has scheduled. ```jsx -import { useEffect, useMemo, useSyncExternalStore } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; import { AuthClient } from "@icp-sdk/auth/client"; function App() { - const authClient = useMemo(() => new AuthClient(), []); + // The initialiser runs once per mount, so no render builds a second client. + const [authClient] = useState(() => new AuthClient()); const status = useSyncExternalStore( onChange => authClient.subscribe(onChange), From fe716d58c0adb761f0d2e0eea90e0ae38b4741a9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:31:23 +0200 Subject: [PATCH 11/20] docs(internet-identity): say what the library does, not how React works Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 348fae75..96540f17 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -159,35 +159,37 @@ async function init() { init(); ``` -The client is an external store: `getStatus()` hands back the same object until -something changes it, so `useSyncExternalStore` renders on it directly. Dispose -of the client when the view that owns it unmounts, which releases its browser -listeners and the re-mint it has scheduled. +The client is an external store, so a framework can render on it directly. In +React, for example: ```jsx import { useEffect, useState, useSyncExternalStore } from "react"; import { AuthClient } from "@icp-sdk/auth/client"; function App() { - // The initialiser runs once per mount, so no render builds a second client. + // A client per mounted view is fine: several read the same sign-in. const [authClient] = useState(() => new AuthClient()); + // subscribe() fires on every change, including one made in another tab, and + // getStatus() returns the same object until something changes it. const status = useSyncExternalStore( onChange => authClient.subscribe(onChange), () => authClient.getStatus(), ); - useEffect(() => () => authClient.dispose(), [authClient]); + useEffect(() => { + // Releases the browser listeners and the re-mint this client scheduled. + return () => authClient.dispose(); + }, [authClient]); switch (status.state) { case "signed-in": return authClient.signOut()} />; case "expired": - // Names the account whose session ended, so this is "your session ended" - // rather than a bare signed-out screen. + // The record outlives the session, so this screen can name whose ended. return authClient.signIn()} />; case "signed-in-elsewhere": - // Only when the sign-in is shared across sibling subdomains. + // A sibling subdomain signed in and this origin holds nothing yet. return authClient.signIn()} />; case "signed-out": return authClient.signIn()} />; From 19f1243591312fdf053319df351e53a92d067067 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:33:00 +0200 Subject: [PATCH 12/20] docs(internet-identity): name what dispose releases Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 96540f17..56c7df0e 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -178,7 +178,9 @@ function App() { ); useEffect(() => { - // Releases the browser listeners and the re-mint this client scheduled. + // dispose() drops this client's foreground and activity listeners, its + // state-store subscription, and the timer that re-mints the delegation. It + // is not a sign-out: the session stands and other clients keep using it. return () => authClient.dispose(); }, [authClient]); From 0968d7467575be56edd09e08e833093d15c944e5 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 16 Sep 2026 16:20:40 +0200 Subject: [PATCH 13/20] docs(internet-identity): teach the client's lifecycle, not React Replaces the React walkthrough with the two shapes that matter, one client for the page or one per component, and the call each needs. Adds eval cases for the session bounds and for disposing a client a component owns, and splits a compound expectation that could never score honestly. Co-Authored-By: Claude Opus 5 (1M context) --- evaluations/internet-identity.json | 22 ++++++++++++- skills/internet-identity/SKILL.md | 53 ++++++++++-------------------- 2 files changed, 38 insertions(+), 37 deletions(-) diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json index 34adeb1d..08551ef1 100644 --- a/evaluations/internet-identity.json +++ b/evaluations/internet-identity.json @@ -50,7 +50,8 @@ "Identifies 2vxsx-fae as the anonymous principal", "Explains that authentication silently failed or the identity was not properly extracted", "Suggests checking the authorizeUrl passed to the AuthClient constructor (including the /authorize path)", - "Suggests checking that authClient.signIn() did not reject (use try/catch around the awaited promise) and that the identity from await authClient.getIdentity() is passed to HttpAgent" + "Suggests checking that authClient.signIn() did not reject, with try/catch around the awaited promise", + "Suggests checking that the identity from await authClient.getIdentity() is the one passed to HttpAgent" ] }, { @@ -190,6 +191,25 @@ "Says exceeding the cap invalidates the ENTIRE list — every alternative origin stops authenticating, not just the entries past the limit", "Does NOT describe the over-cap behavior as truncation, ignoring the extras, or only the excess entries failing" ] + }, + { + "name": "Session bounds are the session's, not a delegation's", + "prompt": "How long does a sign-in last with @icp-sdk/auth, and how do I make it shorter for a banking app? Just the answer and one code snippet, no setup or deploy steps.", + "expected_behaviors": [ + "Says the bounds are passed to signIn() as maxTimeToIdle and/or maxTimeToLive", + "Does NOT present maxTimeToLive as the lifetime of the delegation the frontend signs calls with", + "Does NOT recommend the v8 default of 8 hours (BigInt(8) * BigInt(3_600_000_000_000)) as the value to set", + "States that leaving them unset means Internet Identity's own defaults rather than a library default" + ] + }, + { + "name": "Disposing a client a component owns", + "prompt": "I create an AuthClient inside a component that mounts and unmounts a lot. Is there anything I need to do when it unmounts? Answer in two sentences, no code.", + "expected_behaviors": [ + "Says to call dispose() on the client when the component unmounts", + "Does NOT claim the application must keep a single shared AuthClient instance", + "Does NOT describe dispose() as signing the user out or ending the session" + ] } ], "trigger_evals": { diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 56c7df0e..10146b6a 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -159,46 +159,27 @@ async function init() { init(); ``` -The client is an external store, so a framework can render on it directly. In -React, for example: +### The client's lifecycle -```jsx -import { useEffect, useState, useSyncExternalStore } from "react"; -import { AuthClient } from "@icp-sdk/auth/client"; +One client for the page, or one per component: both work, and they read and write +the same sign-in. -function App() { - // A client per mounted view is fine: several read the same sign-in. - const [authClient] = useState(() => new AuthClient()); - - // subscribe() fires on every change, including one made in another tab, and - // getStatus() returns the same object until something changes it. - const status = useSyncExternalStore( - onChange => authClient.subscribe(onChange), - () => authClient.getStatus(), - ); - - useEffect(() => { - // dispose() drops this client's foreground and activity listeners, its - // state-store subscription, and the timer that re-mints the delegation. It - // is not a sign-out: the session stands and other clients keep using it. - return () => authClient.dispose(); - }, [authClient]); - - switch (status.state) { - case "signed-in": - return authClient.signOut()} />; - case "expired": - // The record outlives the session, so this screen can name whose ended. - return authClient.signIn()} />; - case "signed-in-elsewhere": - // A sibling subdomain signed in and this origin holds nothing yet. - return authClient.signIn()} />; - case "signed-out": - return authClient.signIn()} />; - } -} +```javascript +// Page-lifetime: one client, nothing to dispose. A view stops listening when it +// stops rendering. +const authClient = new AuthClient(); +const unsubscribe = authClient.subscribe(() => render(authClient.getStatus())); +unsubscribe(); + +// Component-lifetime: dispose the client when the view that owns it goes. +// dispose() covers its subscription too, and does not sign the user out. +const silent = new AuthClient({ prompt: "none", hint: principal }); +silent.dispose(); ``` +The client is browser-only, so under a server-rendering framework whatever owns it +must be client-rendered. + ### Serving an app at more than one origin II derives a principal per **origin**, so `https://.icp.net` and `https://shop.example.com` are two different users to the same person. To keep one account per person, pick **one** origin as the derivation origin and list the others as alternative origins. From 4ebe463477619bed028e07450e1073989ae54d8d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 16 Sep 2026 16:21:17 +0200 Subject: [PATCH 14/20] docs(internet-identity): v8 capped the sign-in, it did not default it Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 10146b6a..1c642a77 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -574,7 +574,7 @@ Everything above targets `@icp-sdk/auth` 9.x. On an older major the same flow di - `identityProvider` was a URL string; it is now `{ authorizeUrl, canisterId }`, and a string throws. - `storage` and its `IdbStorage` / `LocalStorage` classes became `credentialStorage` with `IdbCredentialStorage` (the default), `LocalCredentialStorage`, `MemoryCredentialStorage` and `SharedMemoryCredentialStorage`. `stateStorage` is new and holds the record of who is signed in, which is what makes tabs converge; `CookieStateStorage` extends that to sibling subdomains. - `IdleManager` and its options (`idleOptions`, `onIdle`, `idleTimeout`, `disableIdle`) were removed. The session is bounded at Internet Identity instead, via `maxTimeToIdle` and `maxTimeToLive` on `signIn()`. -- `maxTimeToLive` bounded a delegation and defaulted to 8 hours; it now bounds the session and defaults to the provider's 30 days. +- `maxTimeToLive` bounded a delegation and capped every sign-in at 8 hours; it now bounds the session, and unset means the provider's own 30 days. - `getStatus()`, `subscribe()`, `getPrincipal()` and `dispose()` are new; the `identity`, `keyType` and `targets` options are gone. - See the [v9 upgrade guide](https://js.icp.build/auth/latest/upgrading/v9/) for the full list. From 5538d64893156423997f617ded8177f892704b2d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 16 Sep 2026 17:45:02 +0200 Subject: [PATCH 15/20] feat(internet-identity): share a sign-in across sibling subdomains The arrangement stakeholders are asking for: one derivation origin, one cookie domain, and every page picking up a sibling's sign-in through a /reauth route. Adds the two pitfalls that make it silently wrong, and three eval cases. Co-Authored-By: Claude Opus 5 (1M context) --- evaluations/internet-identity.json | 32 ++++++++++ skills/internet-identity/SKILL.md | 95 +++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json index 08551ef1..c1ea65cf 100644 --- a/evaluations/internet-identity.json +++ b/evaluations/internet-identity.json @@ -210,6 +210,38 @@ "Does NOT claim the application must keep a single shared AuthClient instance", "Does NOT describe dispose() as signing the user out or ending the session" ] + }, + { + "name": "Shared sign-in across sibling subdomains", + "prompt": "Two apps, chat.example.com and hr.example.com: a user who signs in on one should be signed in on the other with @icp-sdk/auth. Show four things and nothing else: the shared client options, what the derivation origin has to serve, the /reauth route, and the check every page runs on load. Code with short comments, no prose sections, no backend, no deploy steps.", + "expected_behaviors": [ + "Requires one shared derivationOrigin across both apps, listed in that origin's /.well-known/ii-alternative-origins, and does not present a shared cookie as sufficient on its own", + "Uses CookieStateStorage with the parent domain (example.com) as stateStorage on both apps", + "Acquires a credential on the origin that reads signed-in-elsewhere, with a second AuthClient built with prompt: 'none'", + "Passes hint set to the principal the shared record names on that silent request", + "Uses transport: 'redirect' for the silent request rather than the default window transport", + "Does NOT pass resumable explicitly, since CookieStateStorage already asks for a resumable session", + "Every page, not only protected ones, checks getStatus() on load and redirects signed-in-elsewhere to the re-auth route, passing the current path as a next parameter it returns to" + ] + }, + { + "name": "Adversarial: shared cookie but different principals per subdomain", + "prompt": "I set CookieStateStorage with domain 'example.com' on both my subdomains, but each one still signs the user in as a different principal, and my re-auth page keeps bouncing them back. What is wrong and what do I do? Three sentences, no code.", + "expected_behaviors": [ + "Identifies that principals are derived per origin and that one shared derivationOrigin across both subdomains is what makes them the same", + "Says that derivation origin has to authorize the subdomains by listing them in its alternative-origins document", + "Connects the bouncing to the shared record naming an account this origin can never hold a credential for", + "Does NOT blame cookie attributes, SameSite, Secure, or the cookie domain itself as the cause" + ] + }, + { + "name": "Adversarial: silent re-issue without hint", + "prompt": "Review this: on hr.example.com I detect signed-in-elsewhere and then run `await new AuthClient({ ...clientOptions, prompt: 'none' }).signIn()` on page load. Anything wrong? Two sentences, no rewrite.", + "expected_behaviors": [ + "Flags the missing hint and says the provider may answer for a different account than the shared record names", + "Flags that a silent request on page load has no user gesture, so it needs transport: 'redirect' rather than the default window transport", + "Does NOT claim prompt: 'none' is wrong or unsupported" + ] } ], "trigger_evals": { diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 1c642a77..4995a840 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -57,9 +57,13 @@ Internet Identity (II) is the Internet Computer's native authentication system. - `verified_email` is the same email as `email`, but only present when the source OpenID provider (e.g., Google) marked it as verified and II surfaced that signal through. Use `verified_email` for any access gating (admin allowlists, capability checks). Use `email` only for soft uses like contact info or mailing lists. Request both for fallback behaviour: both are returned with the same value when the source provider marked the email as verified, only `email` when it didn't. -13. **Serving `/.well-known/ii-app-metadata` on the wrong origin, or without CORS.** II reads app metadata from the origin identities are derived for — your validated `derivationOrigin` when the request sets one, the request's own origin otherwise. A document published only on the alternative origin the user visits is never fetched. The document *and* the logo it points at are both read cross-origin, and they fail differently: without `Access-Control-Allow-Origin` on the document none of your metadata is used (II falls back to its curated entry if it ships one for your app, and to your origin alone otherwise), while an unreadable logo costs you the logo alone — the name and description still render. See "Showing your app's name, description, and logo on the sign-in screen". +13. **Sharing a cookie domain without a shared `derivationOrigin`.** Sibling subdomains only share a sign-in when they share a principal, and principals are per origin: without one derivation origin authorized for all of them, the shared record names an account the reading origin can never hold, so `/reauth` bounces the user back forever. Set the derivation origin first, then the cookie domain. -14. **Assuming a bad field in `ii-app-metadata` is just dropped, or confusing a rejected logo with a rejected document.** One field that fails validation invalidates the **whole document**: none of your metadata is applied, not just the offending field (II then falls back to its curated entry if it ships one for your app, and to your origin alone otherwise). `name` is capped at 40 Unicode code points and `description` at 120, counted on the value as served. `logo` straddles the two failure modes — a URL that is not on the **same origin** as the document fails document validation and takes the whole document down with it, and that includes your own canister on a sibling gateway domain, since II may fetch the document from any of `ic0.app`, `icp0.io`, or `icp.net` (write the URL relative) — while an SVG (`image/svg+xml` is not accepted; serve a raster copy), an oversized image, or one that cannot be fetched or decoded costs you the logo alone. +14. **A silent re-issue without `hint`, or on the default transport.** `prompt: 'none'` asks the provider to answer from the session it holds; without `hint` it may answer for a different account and overwrite the shared record with it. And the re-issue runs on page load with no user gesture, so the default `window` transport is popup-blocked: use `transport: 'redirect'` on a route of its own. + +15. **Serving `/.well-known/ii-app-metadata` on the wrong origin, or without CORS.** II reads app metadata from the origin identities are derived for — your validated `derivationOrigin` when the request sets one, the request's own origin otherwise. A document published only on the alternative origin the user visits is never fetched. The document *and* the logo it points at are both read cross-origin, and they fail differently: without `Access-Control-Allow-Origin` on the document none of your metadata is used (II falls back to its curated entry if it ships one for your app, and to your origin alone otherwise), while an unreadable logo costs you the logo alone — the name and description still render. See "Showing your app's name, description, and logo on the sign-in screen". + +16. **Assuming a bad field in `ii-app-metadata` is just dropped, or confusing a rejected logo with a rejected document.** One field that fails validation invalidates the **whole document**: none of your metadata is applied, not just the offending field (II then falls back to its curated entry if it ships one for your app, and to your origin alone otherwise). `name` is capped at 40 Unicode code points and `description` at 120, counted on the value as served. `logo` straddles the two failure modes — a URL that is not on the **same origin** as the document fails document validation and takes the whole document down with it, and that includes your own canister on a sibling gateway domain, since II may fetch the document from any of `ic0.app`, `icp0.io`, or `icp.net` (write the URL relative) — while an SVG (`image/svg+xml` is not accepted; serve a raster copy), an oversized image, or one that cannot be fetched or decoded costs you the logo alone. ## Using II during local development @@ -216,6 +220,93 @@ Do **not** reach for `.ic-assets.json5` — that is the legacy asset canister's **Order matters.** Pin the derivation origin before an origin has users. Repointing an origin that has already collected sign-ins orphans every account made under it. +### Sharing a sign-in across sibling subdomains + +`chat.example.com` and `hr.example.com` can share one sign-in: sign in on one and +the others are signed in without a second visit to the provider, and signing out +on one signs the user out on all of them. + +This builds on the section above. Every app must derive from **one** derivation +origin, listed in that origin's `ii-alternative-origins`, or each subdomain gets +its own principal and there is nothing to share. A shared cookie does not change +that. On top of it, two things: + +**1. Share the record.** Every app builds its client with the same cookie domain, +so a sign-in on one writes a record the others read: + +```javascript +import { AuthClient, CookieStateStorage, InteractionRequiredError } from "@icp-sdk/auth/client"; + +const clientOptions = { + derivationOrigin: "https://auth.example.com", + stateStorage: new CookieStateStorage({ domain: "example.com" }), +}; +``` + +Choosing that domain means trusting every origin under it. Don't do it on a domain +whose subdomains you don't control. + +**2. Acquire the sign-in where a sibling made it.** An app that reads +`signed-in-elsewhere` asks the provider for its own credential for that account, +on a route of its own: + +```javascript +// /reauth — a route of its own, because this runs on page load with no user +// gesture, and a popup opened without one is blocked. +const status = new AuthClient(clientOptions).getStatus(); + +if (status.state === "signed-in-elsewhere") { + // A second client: prompt and hint are set when a client is built. + const authClient = new AuthClient({ + ...clientOptions, + transport: "redirect", + prompt: "none", + hint: status.principal, // answer for the account already signed in + }); + + try { + await authClient.signIn({ + returnTo: new URLSearchParams(location.search).get("next") ?? "/", + }); + } catch (error) { + if (error instanceof InteractionRequiredError) { + // The provider has nothing to resume, so the sign-in is stale: clear it, + // or every app on the domain keeps sending the user back here. + await authClient.signOut().catch(() => {}); + } + location.replace("/"); + } +} else { + location.replace("/"); +} +``` + +**3. Pick it up on load, on every page.** Not only the pages that require a +sign-in: a visitor who is already signed in on a sibling would otherwise land on +a public page here and see a signed-out header. Each page reads the status as it +loads and hands `signed-in-elsewhere` to `/reauth`, naming the page to come back +to, which is what makes the sharing automatic rather than something the user has +to click: + +```javascript +// On load, on every page of the app. +const status = new AuthClient(clientOptions).getStatus(); + +// This state only: a sibling is signed in and this app can pick that up without +// asking the user anything. signed-out and expired both mean a normal sign-in, +// and sending those to /reauth just bounces the user back. +if (status.state === "signed-in-elsewhere") { + location.replace(`/reauth?next=${encodeURIComponent(location.pathname + location.search)}`); +} +``` + +`/reauth` reads that `next` and passes it as `returnTo`, so the user lands back on +the page they asked for, signed in, having seen nothing. + +The full walkthrough, including what ends a session on its own and what a sign-out +does to the siblings, is in the library's [shared sessions +guide](https://js.icp.build/auth/latest/shared-sessions/). + ### Showing your app's name, description, and logo on the sign-in screen By default the II sign-in screens identify your app by its **origin** alone. To have II also show a name, a short tagline, and a logo, serve a JSON document at `/.well-known/ii-app-metadata`. This is permissionless — there is no list to join and no approval step — and it supersedes the curated entry II still ships for a small set of known apps. From d05aeaf465be19a46d420df9bd21726e7704a26d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 16 Sep 2026 17:50:24 +0200 Subject: [PATCH 16/20] docs(internet-identity): one named function per client lifetime Read straight through, the previous snippet subscribed and unsubscribed on adjacent lines, and constructed a client and disposed it on adjacent lines. Each lifetime is now a function that hands back its own teardown, principal is a parameter rather than undeclared, and prompt/hint points at the section that explains a silent re-issue. Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 4995a840..211b20f7 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -169,18 +169,26 @@ One client for the page, or one per component: both work, and they read and writ the same sign-in. ```javascript -// Page-lifetime: one client, nothing to dispose. A view stops listening when it -// stops rendering. +// Page-lifetime: one client for the app, nothing to dispose. Views come and go, +// so each hands back the teardown for its own listener. const authClient = new AuthClient(); -const unsubscribe = authClient.subscribe(() => render(authClient.getStatus())); -unsubscribe(); -// Component-lifetime: dispose the client when the view that owns it goes. -// dispose() covers its subscription too, and does not sign the user out. -const silent = new AuthClient({ prompt: "none", hint: principal }); -silent.dispose(); +function watchHeader() { + const unsubscribe = authClient.subscribe(() => render(authClient.getStatus())); + return unsubscribe; // when the header goes; the client carries on +} + +// Component-lifetime: the client belongs to the view, so it goes with the view. +function openReauthDialog(principal) { + const client = new AuthClient({ prompt: "none", hint: principal }); + return () => client.dispose(); // covers its subscription, and is not a sign-out +} ``` +`prompt: "none"` with `hint` is a silent re-issue, which an app wants when a +sibling subdomain is already signed in: see "Sharing a sign-in across sibling +subdomains" below. + The client is browser-only, so under a server-rendering framework whatever owns it must be client-rendered. From 7a1122463bc2b08f1abdbf69ef734ffac9f13e28 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 16 Sep 2026 18:07:42 +0200 Subject: [PATCH 17/20] feat(internet-identity): jump on load, ask once the page is open A sibling can sign in while a page is already up. Redirecting then would throw away whatever the user was doing, so the status change is offered behind a button instead. Adds an eval for the after-load path and one for getting it backwards. Co-Authored-By: Claude Opus 5 (1M context) --- evaluations/internet-identity.json | 14 ++++++++++++-- skills/internet-identity/SKILL.md | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json index c1ea65cf..ba4661a4 100644 --- a/evaluations/internet-identity.json +++ b/evaluations/internet-identity.json @@ -213,7 +213,7 @@ }, { "name": "Shared sign-in across sibling subdomains", - "prompt": "Two apps, chat.example.com and hr.example.com: a user who signs in on one should be signed in on the other with @icp-sdk/auth. Show four things and nothing else: the shared client options, what the derivation origin has to serve, the /reauth route, and the check every page runs on load. Code with short comments, no prose sections, no backend, no deploy steps.", + "prompt": "Two apps, chat.example.com and hr.example.com: a user who signs in on one should be signed in on the other with @icp-sdk/auth. Show five things and nothing else: the shared client options, what the derivation origin has to serve, the /reauth route, the check every page runs on load, and what happens if the status changes while a page is already open. Code with short comments, no prose sections, no backend, no deploy steps.", "expected_behaviors": [ "Requires one shared derivationOrigin across both apps, listed in that origin's /.well-known/ii-alternative-origins, and does not present a shared cookie as sufficient on its own", "Uses CookieStateStorage with the parent domain (example.com) as stateStorage on both apps", @@ -221,7 +221,8 @@ "Passes hint set to the principal the shared record names on that silent request", "Uses transport: 'redirect' for the silent request rather than the default window transport", "Does NOT pass resumable explicitly, since CookieStateStorage already asks for a resumable session", - "Every page, not only protected ones, checks getStatus() on load and redirects signed-in-elsewhere to the re-auth route, passing the current path as a next parameter it returns to" + "Every page, not only protected ones, checks getStatus() on load and redirects signed-in-elsewhere to the re-auth route, passing the current path as a next parameter it returns to", + "Handles the status turning signed-in-elsewhere after load through subscribe() by prompting the user with a button that runs the redirect, rather than redirecting the open page automatically" ] }, { @@ -242,6 +243,15 @@ "Flags that a silent request on page load has no user gesture, so it needs transport: 'redirect' rather than the default window transport", "Does NOT claim prompt: 'none' is wrong or unsupported" ] + }, + { + "name": "Adversarial: redirecting an already-open page to /reauth", + "prompt": "In my subdomain-shared setup I subscribe to the auth client and call location.replace('/reauth?next=...') on every change to signed-in-elsewhere, including while the user is filling in a form. Is that right? Three sentences, no code.", + "expected_behaviors": [ + "Says redirecting a page that is already open throws away what the user is doing, so it is wrong for the subscribe path", + "Says an already-open page should prompt the user, for example a banner or dialog whose button runs the redirect", + "Keeps the automatic redirect for the check a page runs on load, rather than calling the whole redirect approach wrong" + ] } ], "trigger_evals": { diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 211b20f7..056d89e3 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -311,6 +311,23 @@ if (status.state === "signed-in-elsewhere") { `/reauth` reads that `next` and passes it as `returnTo`, so the user lands back on the page they asked for, signed in, having seen nothing. +**4. Jump on load, ask afterwards.** Step 3 redirects because the page has only +just started. Once a page is open the status can still turn `signed-in-elsewhere`, +when someone signs in on a sibling in another tab, and redirecting a page the user +is working on would throw away what they are doing. So subscribe, and offer the +same redirect behind a button: + +```javascript +authClient.subscribe(() => { + if (authClient.getStatus().state === "signed-in-elsewhere") { + // A banner or dialog whose button runs the same redirect as step 3. + showResumeDialog(() => + location.replace(`/reauth?next=${encodeURIComponent(location.pathname + location.search)}`), + ); + } +}); +``` + The full walkthrough, including what ends a session on its own and what a sign-out does to the siblings, is in the library's [shared sessions guide](https://js.icp.build/auth/latest/shared-sessions/). From bd956ffd76b2fc87d6c24568da51c7f0e03c6cd9 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 16 Sep 2026 18:26:46 +0200 Subject: [PATCH 18/20] fix(internet-identity): declare the redirect callback, and say what hint costs A redirect sign-in is delivered only to a callback the returning origin declares at /.well-known/ii-auth-callbacks, one file per app origin, so a /reauth route without it never comes back. Omitting hint costs the resume rather than the user's identity: the provider refuses with account_selection_required, and a mint for an unexpected account is rejected as AccountMismatchError. Co-Authored-By: Claude Opus 5 (1M context) --- evaluations/internet-identity.json | 10 ++++++---- skills/internet-identity/SKILL.md | 27 ++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json index ba4661a4..47c016a9 100644 --- a/evaluations/internet-identity.json +++ b/evaluations/internet-identity.json @@ -213,7 +213,7 @@ }, { "name": "Shared sign-in across sibling subdomains", - "prompt": "Two apps, chat.example.com and hr.example.com: a user who signs in on one should be signed in on the other with @icp-sdk/auth. Show five things and nothing else: the shared client options, what the derivation origin has to serve, the /reauth route, the check every page runs on load, and what happens if the status changes while a page is already open. Code with short comments, no prose sections, no backend, no deploy steps.", + "prompt": "Two apps, chat.example.com and hr.example.com: a user who signs in on one should be signed in on the other with @icp-sdk/auth. Show five things and nothing else: the shared client options, the well-known documents this needs and which origin serves each, the /reauth route, the check every page runs on load, and what happens if the status changes while a page is already open. Code with short comments, no prose sections, no backend, no deploy steps.", "expected_behaviors": [ "Requires one shared derivationOrigin across both apps, listed in that origin's /.well-known/ii-alternative-origins, and does not present a shared cookie as sufficient on its own", "Uses CookieStateStorage with the parent domain (example.com) as stateStorage on both apps", @@ -222,7 +222,8 @@ "Uses transport: 'redirect' for the silent request rather than the default window transport", "Does NOT pass resumable explicitly, since CookieStateStorage already asks for a resumable session", "Every page, not only protected ones, checks getStatus() on load and redirects signed-in-elsewhere to the re-auth route, passing the current path as a next parameter it returns to", - "Handles the status turning signed-in-elsewhere after load through subscribe() by prompting the user with a button that runs the redirect, rather than redirecting the open page automatically" + "Handles the status turning signed-in-elsewhere after load through subscribe() by prompting the user with a button that runs the redirect, rather than redirecting the open page automatically", + "Serves /.well-known/ii-auth-callbacks on each app origin listing that origin's own /reauth URL, as the prerequisite for the redirect transport, rather than only the alternative-origins document on the derivation origin" ] }, { @@ -239,9 +240,10 @@ "name": "Adversarial: silent re-issue without hint", "prompt": "Review this: on hr.example.com I detect signed-in-elsewhere and then run `await new AuthClient({ ...clientOptions, prompt: 'none' }).signIn()` on page load. Anything wrong? Two sentences, no rewrite.", "expected_behaviors": [ - "Flags the missing hint and says the provider may answer for a different account than the shared record names", + "Flags the missing hint and says a provider holding more than one session refuses the silent request rather than answering for another account", "Flags that a silent request on page load has no user gesture, so it needs transport: 'redirect' rather than the default window transport", - "Does NOT claim prompt: 'none' is wrong or unsupported" + "Does NOT claim prompt: 'none' is wrong or unsupported", + "Does NOT claim the missing hint signs the user in as a different account" ] }, { diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 056d89e3..8cb79d91 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -59,7 +59,7 @@ Internet Identity (II) is the Internet Computer's native authentication system. 13. **Sharing a cookie domain without a shared `derivationOrigin`.** Sibling subdomains only share a sign-in when they share a principal, and principals are per origin: without one derivation origin authorized for all of them, the shared record names an account the reading origin can never hold, so `/reauth` bounces the user back forever. Set the derivation origin first, then the cookie domain. -14. **A silent re-issue without `hint`, or on the default transport.** `prompt: 'none'` asks the provider to answer from the session it holds; without `hint` it may answer for a different account and overwrite the shared record with it. And the re-issue runs on page load with no user gesture, so the default `window` transport is popup-blocked: use `transport: 'redirect'` on a route of its own. +14. **A silent re-issue without `hint`, on the default transport, or to an undeclared callback.** `prompt: 'none'` asks the provider to answer from the session it holds. Without `hint`, a provider holding more than one session refuses rather than guessing — `InteractionRequiredError` with `reason` `account_selection_required` — so what you lose is the resume, not the user's identity: a mint for an unexpected account is rejected client-side as `AccountMismatchError`. The re-issue also runs on page load with no user gesture, so the default `window` transport is popup-blocked: use `transport: 'redirect'` on a route of its own, and declare that route in the origin's `/.well-known/ii-auth-callbacks`, or the redirect never comes back. 15. **Serving `/.well-known/ii-app-metadata` on the wrong origin, or without CORS.** II reads app metadata from the origin identities are derived for — your validated `derivationOrigin` when the request sets one, the request's own origin otherwise. A document published only on the alternative origin the user visits is never fetched. The document *and* the logo it points at are both read cross-origin, and they fail differently: without `Access-Control-Allow-Origin` on the document none of your metadata is used (II falls back to its curated entry if it ships one for your app, and to your origin alone otherwise), while an unreadable logo costs you the logo alone — the name and description still render. See "Showing your app's name, description, and logo on the sign-in screen". @@ -289,6 +289,31 @@ if (status.state === "signed-in-elsewhere") { } ``` +**Each app origin declares the callback.** A redirect sign-in is delivered only to +a callback the returning origin itself declares, so every app serves +`/.well-known/ii-auth-callbacks` on its own origin, listing its own `/reauth`: + +```json +{ "callbacks": ["https://chat.example.com/reauth"] } +``` + +One file per app origin, not one on the derivation origin. The entry is matched +exactly, so it must be the full URL with no fragment, and II reads the document +cross-origin, so serve it as `application/json` with CORS. With +`@dfinity/static-site`, that is another `_headers` block, for the same reason +`ii-alternative-origins` needs one: + +``` +/.well-known/ii-auth-callbacks + Content-Type: application/json + Access-Control-Allow-Origin: * +``` + +Validation fails closed: undeclared, unreadable, or not exactly matching, and the +sign-in never comes back. A declared callback also has to terminate locally, since +the response arrives in the URL fragment and a `3xx` that carries none re-attaches +it to wherever it forwards. + **3. Pick it up on load, on every page.** Not only the pages that require a sign-in: a visitor who is already signed in on a sibling would otherwise land on a public page here and see a signed-out header. Each page reads the status as it From 0823cb9d89f4b14d39ebb2ed75dd400e3200c7ac Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 16 Sep 2026 21:26:45 +0200 Subject: [PATCH 19/20] fix(internet-identity): keep the reauth route out of top-level await The route's awaits sat at module scope, which the skill's own init pattern and eval case 1 both say not to do: Vite's default target has no top-level await, so a frontend following this section would not build. Co-Authored-By: Claude Opus 5 (1M context) --- skills/internet-identity/SKILL.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 8cb79d91..32652916 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -261,9 +261,14 @@ on a route of its own: ```javascript // /reauth — a route of its own, because this runs on page load with no user // gesture, and a popup opened without one is blocked. -const status = new AuthClient(clientOptions).getStatus(); +async function reauth() { + const status = new AuthClient(clientOptions).getStatus(); + + if (status.state !== "signed-in-elsewhere") { + location.replace("/"); + return; + } -if (status.state === "signed-in-elsewhere") { // A second client: prompt and hint are set when a client is built. const authClient = new AuthClient({ ...clientOptions, @@ -284,9 +289,9 @@ if (status.state === "signed-in-elsewhere") { } location.replace("/"); } -} else { - location.replace("/"); } + +reauth(); ``` **Each app origin declares the callback.** A redirect sign-in is delivered only to From 56ac07011d91180b7fc37d4e334326ba1d9622d5 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 16 Sep 2026 21:40:56 +0200 Subject: [PATCH 20/20] revert(caffeine-app): leave the frontend template's auth version alone Out of scope for this PR, which is the internet-identity skill. The template's own owners decide when it moves off ^7.1.0 and whether it goes to v9 or v10. Co-Authored-By: Claude Opus 5 (1M context) --- skills/caffeine-app/references/frontend-template.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/caffeine-app/references/frontend-template.md b/skills/caffeine-app/references/frontend-template.md index cfd7eebd..73250e31 100644 --- a/skills/caffeine-app/references/frontend-template.md +++ b/skills/caffeine-app/references/frontend-template.md @@ -65,7 +65,7 @@ deps once the app works). Verified to install with pnpm and build with Vite. }, "dependencies": { "@caffeineai/core-infrastructure": "^1.0.0", - "@icp-sdk/auth": "^9.0.0", + "@icp-sdk/auth": "^7.1.0", "@icp-sdk/core": "^5.3.0", "@react-three/cannon": "~6.6.0", "@react-three/drei": "~10.0.8",