diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json index 21697707..47c016a9 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,8 +49,9 @@ "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 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 the authorizeUrl passed to the AuthClient constructor (including the /authorize path)", + "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" ] }, { @@ -187,6 +191,69 @@ "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" + ] + }, + { + "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, 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", + "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", + "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" + ] + }, + { + "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 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 the missing hint signs the user in as a different account" + ] + }, + { + "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 4e9e474d..32652916 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. @@ -57,15 +57,19 @@ 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`, 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". + +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 **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 +82,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 +97,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,10 +125,11 @@ 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. 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. @@ -147,11 +153,45 @@ 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(); ``` +### The client's lifecycle + +One client for the page, or one per component: both work, and they read and write +the same sign-in. + +```javascript +// 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(); + +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. + ### 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. @@ -162,7 +202,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", }); ``` @@ -189,6 +228,140 @@ 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. +async function reauth() { + const status = new AuthClient(clientOptions).getStatus(); + + if (status.state !== "signed-in-elsewhere") { + location.replace("/"); + return; + } + + // 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("/"); + } +} + +reauth(); +``` + +**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 +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. + +**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/). + ### 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. @@ -276,17 +449,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 +495,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 +503,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 +711,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 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. -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 +732,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.