From d354c9157723d19edd5bc7c2c7c63df7ea08454d Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:03:16 +0200 Subject: [PATCH 1/8] docs: update the Internet Identity guides for @icp-sdk/auth v9 Co-Authored-By: Claude Opus 5 (1M context) --- .../authentication/internet-identity.mdx | 102 +++++++++++++----- .../canister-calls/calling-from-clients.md | 2 +- .../identity-and-access-management.mdx | 13 ++- 3 files changed, 86 insertions(+), 31 deletions(-) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index 6b89724b..e3bf67b6 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -23,7 +23,7 @@ When a user authenticates through Internet Identity, the following happens: **Principal-per-app isolation:** II derives a different principal for each frontend origin. A user logging into `https://app-a.icp.net` gets a different principal than when logging into `https://app-b.icp.net`, even with the same passkey. This prevents apps from correlating users across services. -**Delegations expire.** The frontend sets a `maxTimeToLive` when requesting the delegation (default recommendation: 8 hours). After expiry, the user must re-authenticate. The maximum allowed delegation lifetime is 30 days (2,592,000,000,000,000 nanoseconds). +**Sessions expire, and the delegation the frontend signs with is replaced as it ages.** Signing in opens a session at Internet Identity; the client mints a short-lived delegation from it and replaces that delegation before it expires, so a long-lived session never means a long-lived key. Two optional bounds on `signIn()` decide how long the session itself may last: `maxTimeToIdle`, after which an unused session ends, and `maxTimeToLive`, which it can never outlive. Leave them unset and Internet Identity applies its own, currently seven days of idleness and thirty days in total. ## Project setup @@ -50,7 +50,7 @@ The `AuthClient` from `@icp-sdk/auth` handles the full sign-in flow: opening the ### Environment detection -Internet Identity runs at different URLs in local development versus mainnet. II uses a well-known frontend canister (`uqzsh-gqaaa-aaaaq-qaada-cai`) that you authenticate against. Detect the host to return the right URL: +Internet Identity is two things to the client: the page a sign-in is rendered at, served by II's frontend canister (`uqzsh-gqaaa-aaaaq-qaada-cai`), and the canister that mints delegations, which is II's backend (`rdmx6-jaaaa-aaaaa-aaadq-cai`). Both are named together, because neither is derived from the other. Only the page differs between local development and mainnet, since [system canisters run at their mainnet canister IDs locally](../../references/system-canisters.md): ```javascript import { AuthClient } from "@icp-sdk/auth/client"; @@ -62,32 +62,34 @@ import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; // environment branching. Available in browser contexts only; see note below for Node.js. const canisterEnv = safeGetCanisterEnv(); -function getIdentityProviderUrl() { +function getIdentityProvider() { const host = window.location.hostname; const isLocal = host === "localhost" || host === "127.0.0.1" || host.endsWith(".localhost"); - if (isLocal) { + return { // icp-cli sets up a local alias: http://id.ai.localhost:8000 - return "http://id.ai.localhost:8000/authorize"; - } - return "https://id.ai/authorize"; + authorizeUrl: isLocal + ? "http://id.ai.localhost:8000/authorize" + : "https://id.ai/authorize", + canisterId: "rdmx6-jaaaa-aaaaa-aaadq-cai", + }; } ``` ### Sign in, sign out, and session check -Create a single `AuthClient` instance on page load and reuse it for all operations. The identity provider URL is passed at construction time, not on each sign-in: +Where the sign-in is kept is the client's storage rather than the instance, so a client is cheap: construct one where you need it, and call `dispose()` when that page or component goes away. Several clients on one page read the same sign-in. The identity provider is passed at construction time, not on each sign-in: ```javascript -// Create the auth client (once, on page load) const authClient = new AuthClient({ - identityProvider: getIdentityProviderUrl(), + identityProvider: getIdentityProvider(), }); -// Check for existing session +// Check for an existing session. isAuthenticated() is synchronous, so it can +// run during a render; getIdentity() is async. if (authClient.isAuthenticated()) { const identity = await authClient.getIdentity(); // Restore session: create agent and actor with this identity @@ -96,9 +98,7 @@ if (authClient.isAuthenticated()) { // Sign in async function signIn() { try { - const identity = await authClient.signIn({ - maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), // 8 hours - }); + const identity = await authClient.signIn(); console.log("Signed in as:", identity.getPrincipal().toText()); return identity; } catch (error) { @@ -107,28 +107,72 @@ 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(); // Reset UI state or reload } + +// Release what the client hooked up, when the page or component goes away. +function teardown() { + authClient.dispose(); +} ``` `signIn()` returns the new `Identity` directly. It rejects if the user closes the popup or authentication fails, so wrap the call in `try`/`catch` instead of relying on success/error callbacks. +### Render on the status, not on a boolean + +`isAuthenticated()` answers whether this page can act as the user. `getStatus()` answers in more detail, and `subscribe()` tells you when to ask again, including when the answer changed in another tab, so a sign-out in one tab reaches the others without a reload: + +```javascript +const unsubscribe = authClient.subscribe(() => render(authClient.getStatus())); + +function render(status) { + switch (status.state) { + case "signed-in": + return showApp(status.principal); + case "expired": + // Still names the account, so this is a "your session ended" screen + // rather than a bare signed-out one. + return showSessionEnded(status.principal); + case "signed-in-elsewhere": + // Only reachable when the sign-in is shared across sibling subdomains. + return showResume(status.principal); + case "signed-out": + return showSignInButton(); + } +} +``` + ### One-click OpenID sign-in To skip the Internet Identity authentication-method screen and send the user straight to a specific OpenID provider, pass `openIdProvider` to the constructor. Supported values are `'google'`, `'apple'`, and `'microsoft'`: ```javascript const authClient = new AuthClient({ - identityProvider: getIdentityProviderUrl(), + identityProvider: getIdentityProvider(), openIdProvider: "google", }); ``` The rest of the flow (`signIn`, `getIdentity`, `signOut`) is unchanged. +For an organization's own SSO rather than a public provider, pass `ssoDomain` instead (the two are mutually exclusive). The user goes to whichever provider that organization publishes, and `isValidSsoDomain` checks a domain the user typed before you try it: + +```javascript +import { AuthClient, isValidSsoDomain } from "@icp-sdk/auth/client"; + +if (await isValidSsoDomain(domain, AbortSignal.timeout(5_000))) { + const authClient = new AuthClient({ + identityProvider: getIdentityProvider(), + ssoDomain: domain, // e.g. "acme.com" + }); + await authClient.signIn(); +} +``` + ### Create an authenticated agent After sign-in, create an `HttpAgent` using the delegation identity. The agent signs all subsequent canister calls with the user's delegated key: @@ -170,14 +214,14 @@ 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. - const noncePromise = anonymousActor._internet_identity_sign_in_start(); + // Mint the nonce, sign in, and request attributes in parallel. `nonce` is the + // function that fetches it, which the client calls when it needs the value — + // so the request is already in flight while the Internet Identity window + // opens, and the user still sees a single interaction. const signInPromise = authClient.signIn(); const attributesPromise = authClient.requestAttributes({ keys: ["name", "verified_email"], // the library reads verified_email for its email field - nonce: noncePromise, + nonce: () => anonymousActor._internet_identity_sign_in_start(), }); const identity = await signInPromise; @@ -220,7 +264,7 @@ When using one-click OpenID sign-in, attributes can be scoped to the provider. T import { AuthClient, scopedKeys } from "@icp-sdk/auth/client"; const authClient = new AuthClient({ - identityProvider: getIdentityProviderUrl(), + identityProvider: getIdentityProvider(), openIdProvider: "google", }); @@ -229,7 +273,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(), }); ``` @@ -532,7 +576,7 @@ icp network start icp deploy ``` -icp-cli pulls the mainnet II Wasm when deploying locally and registers a local alias so the II frontend is reachable at `http://id.ai.localhost:8000`. Use the `getIdentityProviderUrl` helper (shown in the environment detection section above) to point to this URL in local development. +icp-cli pulls the mainnet II Wasm when deploying locally and registers a local alias so the II frontend is reachable at `http://id.ai.localhost:8000`. Use the `getIdentityProvider` helper (shown in the environment detection section above) to point to this URL in local development. To test authentication from the command line: @@ -594,7 +638,7 @@ To keep principals consistent across your own custom domains, configure **altern ```javascript const authClient = new AuthClient({ - identityProvider: "https://id.ai", + identityProvider: getIdentityProvider(), derivationOrigin: "https://xxxxx.icp.net", // primary origin A }); ``` @@ -661,13 +705,14 @@ For the normative rules, including a JSON schema to validate your document again ## Common mistakes -- **Using the wrong II URL per environment**: local development must point to `http://id.ai.localhost:8000`, mainnet to `https://id.ai`. Use the `getIdentityProviderUrl` helper (shown above) to switch based on hostname. +- **Using the wrong II URL per environment**: local development must point to `http://id.ai.localhost:8000`, mainnet to `https://id.ai`. Use the `getIdentityProvider` helper (shown above) to switch based on hostname. - **`fetch` "Illegal invocation" in bundled builds**: always pass `fetch: window.fetch.bind(window)` to `HttpAgent.create()`. Without explicit binding, bundlers (Vite, webpack) extract `fetch` from `window` and call it without the correct `this` context. - **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. -- **Delegation expiry too long**: the maximum is 30 days. Values above this are silently clamped, causing confusing session behavior. Use 8 hours for typical apps. +- **Treating the session bounds as a delegation lifetime**: `maxTimeToLive` and `maxTimeToIdle` bound the session at Internet Identity, not the key your frontend signs with; that one is short-lived and replaced for you. Leave both unset unless the app has a policy of its own; the provider's defaults are seven days idle and thirty days in total. - **Passing principal as a string argument**: the backend reads the caller automatically from the IC protocol. Do not pass it as a function parameter. - **Using `shouldFetchRootKey: true` in browser code**: pass `rootKey: canisterEnv?.IC_ROOT_KEY` from `safeGetCanisterEnv()` instead. `shouldFetchRootKey: true` fetches the root key from the replica at runtime, which lets a man-in-the-middle substitute a fake key on mainnet. For Node.js scripts targeting a local replica only, `await agent.fetchRootKey()` is acceptable: but never on mainnet. -- **Creating multiple `AuthClient` instances**: create one on page load and reuse it. Multiple instances cause race conditions with session storage. +- **Leaking `AuthClient` instances**: several clients may share an origin (they read the same sign-in), but each one hooks browser listeners and schedules a refresh, so call `dispose()` when the page or component that made it goes away. +- **Passing a bare URL as `identityProvider`**: it is an object, `{ authorizeUrl, canisterId }`, and both fields are required together, because nothing about the minting canister is derived from the URL. A string throws a `TypeError`. - **Generating the attribute nonce on the frontend**: a frontend-generated nonce defeats the anti-replay guarantee. The nonce passed to `requestAttributes` must come from a backend canister call so the canister can later verify that the bundle's `implicit:nonce` is one it actually issued. - **Reading attribute data without verifying the signer**: the IC checks the signature, not the identity of the signer, so any canister can produce a valid bundle. The trusted signer for II is `rdmx6-jaaaa-aaaaa-aaadq-cai`. In Motoko, use the [`mo:identity-attributes`](https://mops.one/identity-attributes) mixin and configure `trusted_attribute_signers` and `frontend_origins` in `icp.yaml`: it verifies the signer (and the origin, nonce, and freshness) for you. In Rust, there is no CDK wrapper yet, so always check `msg_caller_info_signer()` against the trusted issuer before reading `msg_caller_info_data()`. @@ -678,6 +723,7 @@ For the normative rules, including a JSON schema to validate your document again - [Internet Identity specification](../../references/internet-identity-spec.md) for protocol details and the full alternative origins spec - [Security best practices](../../concepts/security.md) for identity and trust fundamentals - [AuthClient API reference](https://js.icp.build) for the full `@icp-sdk/auth` API +- [Upgrading to v9](https://js.icp.build/auth/latest/upgrading/v9/) if you are moving an app off `@icp-sdk/auth` v8 {/* TODO: Add Unity native app integration via deep links: see portal native-apps/unity_ii_* */} diff --git a/docs/guides/canister-calls/calling-from-clients.md b/docs/guides/canister-calls/calling-from-clients.md index 3341c3f1..87570593 100644 --- a/docs/guides/canister-calls/calling-from-clients.md +++ b/docs/guides/canister-calls/calling-from-clients.md @@ -307,7 +307,7 @@ import { HttpAgent } from "@icp-sdk/core/agent"; // identity obtained from Internet Identity delegation const agent = await HttpAgent.create({ host: "https://icp-api.io", - identity, // DelegationIdentity from @icp-sdk/auth + identity, // the Identity returned by AuthClient.getIdentity() }); ``` diff --git a/docs/guides/security/identity-and-access-management.mdx b/docs/guides/security/identity-and-access-management.mdx index 646d7bff..4c41aa4c 100644 --- a/docs/guides/security/identity-and-access-management.mdx +++ b/docs/guides/security/identity-and-access-management.mdx @@ -87,13 +87,22 @@ Implementing user authentication and canister calls yourself in your web app is ### Security concern -Currently, Internet Identity issues delegations with an expiry time. This expiry time can be set in the auth-client. After a delegation expires, the user has to re-authenticate. Setting a good value is a trade-off between security and usability. +Internet Identity issues a session with an expiry time, and the auth client asks for its bounds at sign-in. Once a session ends the user has to re-authenticate. Setting a good value is a trade-off between security and usability. ### Recommendation See the [OWASP recommendations](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-expiration). A timeout of 30 minutes should be set for security-sensitive applications. -The auth-client supports [idle timeouts](https://js.icp.build/auth/latest/api/client/classes/idlemanager). +Pass the bounds to `signIn()`: `maxTimeToIdle` ends a session nobody has used, and `maxTimeToLive` is the ceiling it cannot outlive. Both are in nanoseconds, and Internet Identity enforces them, so they hold across every tab and for as long as the device is away. + +```javascript +await authClient.signIn({ + maxTimeToIdle: 30n * 60n * 1_000_000_000n, // 30 minutes of inactivity + maxTimeToLive: 8n * 60n * 60n * 1_000_000_000n, // 8 hours in total +}); +``` + +Unset, the provider applies its own defaults (currently seven days of idleness and thirty days in total), which is too long for an application handling sensitive data. The delegation the frontend signs with is short-lived and replaced automatically regardless, so these bounds are about the session, not about key material sitting in the browser. ## Don't use fetchRootKey in the ICP JavaScript agent in production From 55b52277d94af0ebc6415bcc64949e04389330a5 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:11:22 +0200 Subject: [PATCH 2/8] docs: leave session length to the identity provider Co-Authored-By: Claude Opus 5 (1M context) --- docs/guides/security/identity-and-access-management.mdx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/guides/security/identity-and-access-management.mdx b/docs/guides/security/identity-and-access-management.mdx index 4c41aa4c..0f919e29 100644 --- a/docs/guides/security/identity-and-access-management.mdx +++ b/docs/guides/security/identity-and-access-management.mdx @@ -93,16 +93,17 @@ Internet Identity issues a session with an expiry time, and the auth client asks See the [OWASP recommendations](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html#session-expiration). A timeout of 30 minutes should be set for security-sensitive applications. -Pass the bounds to `signIn()`: `maxTimeToIdle` ends a session nobody has used, and `maxTimeToLive` is the ceiling it cannot outlive. Both are in nanoseconds, and Internet Identity enforces them, so they hold across every tab and for as long as the device is away. +How long a sign-in lasts is Internet Identity's policy, narrowed by what the user agrees to at consent and by any cap their organization sets. The auth client names no bounds of its own, so leaving `signIn()` unbounded gets that policy, which is the right default for most applications. + +An application with a stricter rule of its own can narrow it further, never widen it. `maxTimeToIdle` ends a session nobody has used and `maxTimeToLive` is the ceiling it cannot outlive; both are in nanoseconds, and Internet Identity enforces them, so they hold across every tab and for as long as the device is away. ```javascript await authClient.signIn({ maxTimeToIdle: 30n * 60n * 1_000_000_000n, // 30 minutes of inactivity - maxTimeToLive: 8n * 60n * 60n * 1_000_000_000n, // 8 hours in total }); ``` -Unset, the provider applies its own defaults (currently seven days of idleness and thirty days in total), which is too long for an application handling sensitive data. The delegation the frontend signs with is short-lived and replaced automatically regardless, so these bounds are about the session, not about key material sitting in the browser. +Note what this is not about: the delegation the frontend signs calls with is short-lived and replaced automatically whatever you pass here, so no key sits in the browser for the length of the session. ## Don't use fetchRootKey in the ICP JavaScript agent in production From 454c003426ad0daf7498b9a85cdf6c0b639b943c Mon Sep 17 00:00:00 2001 From: sea-snake Date: Tue, 15 Sep 2026 21:16:26 +0200 Subject: [PATCH 3/8] docs: let the subscription re-render after a sign-out Co-Authored-By: Claude Opus 5 (1M context) --- docs/guides/authentication/internet-identity.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index e3bf67b6..82c24720 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -108,10 +108,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: the state changes, so a subscriber re-renders. async function signOut() { await authClient.signOut(); - // Reset UI state or reload } // Release what the client hooked up, when the page or component goes away. From 74633e8753a9cfb5bb84e089e182b58b24d9a9f4 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 16 Sep 2026 10:23:05 +0200 Subject: [PATCH 4/8] docs: address review on the Internet Identity v9 guides Co-Authored-By: Claude Opus 5 (1M context) --- .../authentication/internet-identity.mdx | 32 +++++++++++++++---- .../identity-and-access-management.mdx | 2 ++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index 82c24720..fe590799 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -41,16 +41,22 @@ networks: ### Install frontend packages ```bash -npm install @icp-sdk/auth @icp-sdk/core +npm install @icp-sdk/auth@9 @icp-sdk/core@5 ``` +Both majors are pinned because this page documents that pair: `@icp-sdk/auth` v9 with +`@icp-sdk/core` v5, which is the peer range v9 declares. On v8 of the client the +`identityProvider` option below throws a `TypeError`, so see +[Upgrading to v9](https://js.icp.build/auth/latest/upgrading/v9/) if you are moving an +existing app. + ## Frontend integration The `AuthClient` from `@icp-sdk/auth` handles the full sign-in flow: opening the II popup, receiving the delegation, and managing session persistence. ### Environment detection -Internet Identity is two things to the client: the page a sign-in is rendered at, served by II's frontend canister (`uqzsh-gqaaa-aaaaq-qaada-cai`), and the canister that mints delegations, which is II's backend (`rdmx6-jaaaa-aaaaa-aaadq-cai`). Both are named together, because neither is derived from the other. Only the page differs between local development and mainnet, since [system canisters run at their mainnet canister IDs locally](../../references/system-canisters.md): +Internet Identity is two things to the client: the page a sign-in is rendered at, served by II's frontend canister (`uqzsh-gqaaa-aaaaq-qaada-cai`), and the canister that mints delegations, which is II's backend (`rdmx6-jaaaa-aaaaa-aaadq-cai`). Only the page differs between local development and mainnet, since [system canisters run at their mainnet canister IDs locally](../../references/system-canisters.md#using-system-canisters-in-local-development): ```javascript import { AuthClient } from "@icp-sdk/auth/client"; @@ -81,7 +87,7 @@ function getIdentityProvider() { ### Sign in, sign out, and session check -Where the sign-in is kept is the client's storage rather than the instance, so a client is cheap: construct one where you need it, and call `dispose()` when that page or component goes away. Several clients on one page read the same sign-in. The identity provider is passed at construction time, not on each sign-in: +The sign-in is kept in the client's storage rather than the instance, so a client is cheap: construct one where you need it, and call `dispose()` when that page or component goes away. Several clients on one page read the same sign-in and write to the same storage. The identity provider is passed at construction time, not on each sign-in: ```javascript const authClient = new AuthClient({ @@ -122,7 +128,7 @@ function teardown() { `signIn()` returns the new `Identity` directly. It rejects if the user closes the popup or authentication fails, so wrap the call in `try`/`catch` instead of relying on success/error callbacks. -### Render on the status, not on a boolean +### Render `isAuthenticated()` answers whether this page can act as the user. `getStatus()` answers in more detail, and `subscribe()` tells you when to ask again, including when the answer changed in another tab, so a sign-out in one tab reaches the others without a reload: @@ -138,7 +144,9 @@ function render(status) { // rather than a bare signed-out one. return showSessionEnded(status.principal); case "signed-in-elsewhere": - // Only reachable when the sign-in is shared across sibling subdomains. + // Someone is signed in on this domain and this origin holds no credential + // for them yet, so getIdentity() throws SessionNotHeldError until it does. + // Only reachable once the sign-in is shared across sibling subdomains. return showResume(status.principal); case "signed-out": return showSignInButton(); @@ -164,7 +172,17 @@ For an organization's own SSO rather than a public provider, pass `ssoDomain` in ```javascript import { AuthClient, isValidSsoDomain } from "@icp-sdk/auth/client"; -if (await isValidSsoDomain(domain, AbortSignal.timeout(5_000))) { +async function signInWithSso(domain) { + try { + if (!(await isValidSsoDomain(domain, AbortSignal.timeout(5_000)))) { + return showNoSsoConfiguration(domain); // the domain publishes nothing + } + } catch { + // An abandoned check is not a verdict: the organization's server was too + // slow, which is not the same as the domain being unusable. + return showCheckTimedOut(domain); + } + const authClient = new AuthClient({ identityProvider: getIdentityProvider(), ssoDomain: domain, // e.g. "acme.com" @@ -215,7 +233,7 @@ async function signInWithAttributes(authClient, canisterId, idl) { const anonymousActor = Actor.createActor(idl, { agent: anonymousAgent, canisterId }); // Mint the nonce, sign in, and request attributes in parallel. `nonce` is the - // function that fetches it, which the client calls when it needs the value — + // function that fetches it, which the client calls when it needs the value, // so the request is already in flight while the Internet Identity window // opens, and the user still sees a single interaction. const signInPromise = authClient.signIn(); diff --git a/docs/guides/security/identity-and-access-management.mdx b/docs/guides/security/identity-and-access-management.mdx index 0f919e29..7f501b95 100644 --- a/docs/guides/security/identity-and-access-management.mdx +++ b/docs/guides/security/identity-and-access-management.mdx @@ -103,6 +103,8 @@ await authClient.signIn({ }); ``` +Unset, Internet Identity applies its own: seven days of idleness, and thirty days in total, which is also its ceiling. Both bounds are clamped with a ten-minute floor, so the thirty minutes above sits comfortably inside what it accepts. + Note what this is not about: the delegation the frontend signs calls with is short-lived and replaced automatically whatever you pass here, so no key sits in the browser for the length of the session. ## Don't use fetchRootKey in the ICP JavaScript agent in production From 50af75b496eee10aeb16ed58362151124caba61b Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 16 Sep 2026 17:45:35 +0200 Subject: [PATCH 5/8] docs: share a sign-in across sibling subdomains Co-Authored-By: Claude Opus 5 (1M context) --- .../authentication/internet-identity.mdx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index fe590799..a1fbcd5c 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -665,6 +665,52 @@ To keep principals consistent across your own custom domains, configure **altern For full details, see the [Internet Identity specification](../../references/internet-identity-spec.md). +## Sharing a sign-in across sibling subdomains + +Apps on sibling subdomains of one domain, such as `chat.example.com` and `hr.example.com`, can share one sign-in: signing in on one signs the user in on the others without a second visit to Internet Identity, and signing out on one signs the user out on all of them. + +It rests on the section above. Every app has to derive from one shared derivation origin, authorized by that origin's `ii-alternative-origins` document, because principals are per origin and apps that do not share a principal have nothing to share. On top of that: + +1. **Share the record.** Every app passes the same cookie domain, so a sign-in on one writes a record the others read. Choosing a domain means trusting every origin under it, so do this only where you control the subdomains. + + ```javascript + const clientOptions = { + derivationOrigin: "https://auth.example.com", + stateStorage: new CookieStateStorage({ domain: "example.com" }), + }; + ``` + +2. **Acquire the sign-in where a sibling made it.** An app reading `signed-in-elsewhere` asks the provider for its own credential for that account. That request is made by a second client, since `prompt` and `hint` are set when a client is built, and it runs on page load with no user gesture, so it needs `transport: "redirect"` rather than the default window flow: + + ```javascript + const authClient = new AuthClient({ + ...clientOptions, + transport: "redirect", + prompt: "none", + hint: status.principal, // answer for the account already signed in + }); + + await authClient.signIn({ returnTo: "/" }); + ``` + + Without `hint` the provider may answer for a different account, signing the user in as someone else. An `InteractionRequiredError` means the provider has nothing to resume, so the sign-in is stale: sign out to clear it, or every app on the domain keeps sending the user back. + +3. **Pick it up on load, on every page.** Give that request a route of its own, `/reauth`, and have every page check the status as it loads, handing `signed-in-elsewhere` to that route with the page to return to. Every page, not only the ones that require a sign-in: a visitor already signed in on a sibling would otherwise land on a public page here and see a signed-out header. + + ```javascript + const status = new AuthClient(clientOptions).getStatus(); + + // This state only: 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` passes that `next` as `returnTo`, so the user lands back where they were asking to go, signed in, having seen nothing. This is what makes the sharing automatic rather than something the user has to click. + +The full walkthrough is in the client's [shared sessions guide](https://js.icp.build/auth/latest/shared-sessions/). + ## App metadata By default, the sign-in screens identify your app by its origin alone. To have II show your app's name, a short description, and its logo, serve a JSON document at `/.well-known/ii-app-metadata`. Any app can publish it: there is no list to join and no approval step. From 2f59b7fc33e271c6e497178157d6b82169af22fe Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 16 Sep 2026 18:07:44 +0200 Subject: [PATCH 6/8] docs: jump on load, ask once the page is open Co-Authored-By: Claude Opus 5 (1M context) --- docs/guides/authentication/internet-identity.mdx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index a1fbcd5c..715861c4 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -709,6 +709,19 @@ It rests on the section above. Every app has to derive from one shared derivatio `/reauth` passes that `next` as `returnTo`, so the user lands back where they were asking to go, signed in, having seen nothing. This is what makes the sharing automatic rather than something the user has to click. +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 is in the client's [shared sessions guide](https://js.icp.build/auth/latest/shared-sessions/). ## App metadata From 96d7b69980e419d672f7b1ac18cd318ef1430cad Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 16 Sep 2026 18:27:11 +0200 Subject: [PATCH 7/8] docs: declare the redirect callback, and say what hint costs Co-Authored-By: Claude Opus 5 (1M context) --- .../authentication/internet-identity.mdx | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index 715861c4..7c2a45fe 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -674,26 +674,53 @@ It rests on the section above. Every app has to derive from one shared derivatio 1. **Share the record.** Every app passes the same cookie domain, so a sign-in on one writes a record the others read. Choosing a domain means trusting every origin under it, so do this only where you control the subdomains. ```javascript + import { AuthClient, CookieStateStorage, InteractionRequiredError } from "@icp-sdk/auth/client"; + const clientOptions = { + identityProvider: getIdentityProvider(), derivationOrigin: "https://auth.example.com", stateStorage: new CookieStateStorage({ domain: "example.com" }), }; ``` -2. **Acquire the sign-in where a sibling made it.** An app reading `signed-in-elsewhere` asks the provider for its own credential for that account. That request is made by a second client, since `prompt` and `hint` are set when a client is built, and it runs on page load with no user gesture, so it needs `transport: "redirect"` rather than the default window flow: +2. **Acquire the sign-in where a sibling made it, on a `/reauth` route.** An app reading `signed-in-elsewhere` asks the provider for its own credential for that account. That request is made by a second client, since `prompt` and `hint` are set when a client is built, and it runs on page load with no user gesture, so it needs `transport: "redirect"` rather than the default window flow: ```javascript - const authClient = new AuthClient({ - ...clientOptions, - transport: "redirect", - prompt: "none", - hint: status.principal, // answer for the account already signed in - }); + // /reauth + const status = new AuthClient(clientOptions).getStatus(); - await authClient.signIn({ returnTo: "/" }); + if (status.state === "signed-in-elsewhere") { + 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) { + await authClient.signOut().catch(() => {}); + } + location.replace("/"); + } + } else { + location.replace("/"); + } + ``` + + Without `hint`, a provider holding more than one session refuses rather than guessing, with `InteractionRequiredError` and a `reason` of `account_selection_required`: what you lose is the resume, not the user's identity, since a mint for an unexpected account is rejected as `AccountMismatchError`. An `InteractionRequiredError` also means there may be nothing to resume, so the sign-in is stale: sign out to clear it, or every app on the domain keeps sending the user back. + + **Declare that route.** A redirect sign-in is delivered only to a callback the returning origin declares itself, so every app serves `/.well-known/ii-auth-callbacks` on its own origin (not once on the derivation origin), listing its own route: + + ```json + { "callbacks": ["https://chat.example.com/reauth"] } ``` - Without `hint` the provider may answer for a different account, signing the user in as someone else. An `InteractionRequiredError` means the provider has nothing to resume, so the sign-in is stale: sign out to clear it, or every app on the domain keeps sending the user back. + The entry is matched exactly, so it is the full URL with no fragment, and II reads the document cross-origin, so serve it as `application/json` with `Access-Control-Allow-Origin`. Validation fails closed: undeclared or unreadable, and the sign-in never comes back. The route also has to terminate locally, because the response arrives in the URL fragment and a `3xx` carrying none re-attaches it to wherever it forwards. 3. **Pick it up on load, on every page.** Give that request a route of its own, `/reauth`, and have every page check the status as it loads, handing `signed-in-elsewhere` to that route with the page to return to. Every page, not only the ones that require a sign-in: a visitor already signed in on a sibling would otherwise land on a public page here and see a signed-out header. From 769672e3807ecdf52efe55626a4caa9d6a8f5790 Mon Sep 17 00:00:00 2001 From: sea-snake Date: Wed, 16 Sep 2026 21:27:09 +0200 Subject: [PATCH 8/8] docs: keep the reauth route out of top-level await Co-Authored-By: Claude Opus 5 (1M context) --- docs/guides/authentication/internet-identity.mdx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index 7c2a45fe..082fad87 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -687,9 +687,14 @@ It rests on the section above. Every app has to derive from one shared derivatio ```javascript // /reauth - 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") { const authClient = new AuthClient({ ...clientOptions, transport: "redirect", @@ -707,9 +712,9 @@ It rests on the section above. Every app has to derive from one shared derivatio } location.replace("/"); } - } else { - location.replace("/"); } + + reauth(); ``` Without `hint`, a provider holding more than one session refuses rather than guessing, with `InteractionRequiredError` and a `reason` of `account_selection_required`: what you lose is the resume, not the user's identity, since a mint for an unexpected account is rejected as `AccountMismatchError`. An `InteractionRequiredError` also means there may be nothing to resume, so the sign-in is stale: sign out to clear it, or every app on the domain keeps sending the user back.