diff --git a/docs/guides/authentication/app-metadata.md b/docs/guides/authentication/app-metadata.md new file mode 100644 index 00000000..7fdc82ab --- /dev/null +++ b/docs/guides/authentication/app-metadata.md @@ -0,0 +1,57 @@ +--- +title: "App metadata" +description: "Show your app's name, description, and logo on the Internet Identity sign-in screen by publishing /.well-known/ii-app-metadata." +sidebar: + order: 2 +--- + +By default, the Internet Identity sign-in screens identify your app by its origin alone. To have them also show a name, a short description, and a logo, publish a JSON document at `/.well-known/ii-app-metadata`. Any app can publish one: there is no list to join and no approval step. + +## Publish the document + +```json +{ + "name": "Example App", + "description": "A short tagline shown on the sign-in screen", + "logo": "/logo.png", + "privacyPolicyUrl": "/privacy", + "termsOfServiceUrl": "https://legal.example.com/terms" +} +``` + +Every field is optional, and unknown fields are ignored, so a document stays valid as fields are added. In short: + +- `name` is at most 40 characters and `description` at most 120. +- `logo` is a raster image (PNG, JPEG, WebP, GIF, or AVIF; not SVG) on the same origin as the document. Write it as a relative URL, since II may read the document from any of your canister's gateway domains. A roughly square image of about 512 pixels works well. +- `privacyPolicyUrl` and `termsOfServiceUrl` are `https` links, on any origin, shown on the screen where a user connects an MCP client to your app. +- One field that fails validation invalidates the **whole document**, so none of it is shown. II logs which field is at fault to the browser console on the sign-in screen. + +The complete rules, including a JSON Schema to validate your document against, are in [App metadata](../../references/internet-identity-spec.md#app-metadata) in the Internet Identity specification. + +## Publish it on the right origin + +II reads the document from the origin your users' identities are derived for: your `derivationOrigin` when you set one (see [Shared sessions across subdomains](shared-sessions.md#use-one-derivation-origin)), and the origin the sign-in came from otherwise. Publish it once on that origin; every alternative origin it lists is shown with the same name, description, and logo. + +## Serve it with CORS + +II reads both the document and the logo cross-origin, so both need an `Access-Control-Allow-Origin` header, and the document, which has no file extension, needs its content type set. + +On a [static site](../frontends/static-site/overview.md), `.well-known/` is uploaded automatically; declare the headers in a `_headers` file at the root of your build directory: + +```text +/.well-known/ii-app-metadata + Content-Type: application/json + Access-Control-Allow-Origin: * + +/logo.png + Access-Control-Allow-Origin: * +``` + +A missing, unreachable, or invalid document never blocks sign-in: the screens fall back to the curated entry II still ships for a small list of apps, and to showing your origin otherwise. A logo that cannot be fetched costs you the logo alone; the name and description still show. + +The metadata is exactly as trustworthy as the origin serving it, so II keeps showing your origin next to it: the origin is what users can actually check. + +## Next steps + +- [Getting started](internet-identity.md): add sign-in to your frontend. +- [Shared sessions across subdomains](shared-sessions.md): serve one app from several origins with one set of metadata. diff --git a/docs/guides/authentication/enterprise-sso.md b/docs/guides/authentication/enterprise-sso.md new file mode 100644 index 00000000..c824a14e --- /dev/null +++ b/docs/guides/authentication/enterprise-sso.md @@ -0,0 +1,190 @@ +--- +title: "Enterprise SSO" +description: "Connect your company's OpenID Connect provider to Internet Computer applications: register an OIDC client, publish one file on your domain, and optionally gate access app by app." +sidebar: + order: 5 +--- + +Internet Identity can authenticate your staff against your company's existing OpenID Connect provider, such as Okta, Entra ID, Google Workspace, or Auth0. Staff enter your company domain on the sign-in screen and authenticate with the account they already have. + +Setup takes two required steps, one OIDC client and one file on your domain, plus an optional third that controls access app by app. Nothing has to be registered with Internet Identity: it discovers your configuration from that file. + +This guide is for the SSO administrator. If you are building an application, see [One-click sign-in](one-click-sign-in.md#sign-in-with-an-organizations-sso). + +## 1. Register an OIDC client + +In your identity provider, register an **OIDC web application** (in Okta: Create App Integration → OIDC → Web Application) with these settings: + +| Setting | Value | +|---------|-------| +| Redirect URI | `https://id.ai/callback` | +| Grant types | Authorization Code and Implicit (hybrid) | +| ID token | Allow ID Token with implicit grant | +| Access token | Leave Access Token unchecked | +| Scopes | `openid`, `profile`, `email` | + +Copy down the `client_id`, for example `0oaDEFAULT`. You need it in step 2. + +## 2. Publish the discovery file + +Serve a file over HTTPS at exactly this path on your company domain: + +```text +https://acme.com/.well-known/ii-openid-configuration +``` + +```json +{ + "client_id": "0oaDEFAULT", + "openid_configuration": "https://acme.okta.com/.well-known/openid-configuration", + "name": "Acme Corp" +} +``` + +| Field | Value | +|-------|-------| +| `client_id` | The client from step 1 | +| `openid_configuration` | Your IdP's OIDC discovery URL | +| `name` | Optional label on the sign-in screen | + +`openid_configuration` must be an `https` URL, and your IdP's issuer and authorization endpoint must be on the same host as it. Internet Identity fetches the file itself, so it needs no CORS header. + +That is the whole setup. On **id.ai**, staff choose **Sign in with SSO**, enter **acme.com** as their company domain, then authenticate against your IdP. The domain is entered bare, such as `acme.com`: `https://acme.com`, `acme.com/`, and `acme.com/sso` are not domains. + +### How long a sign-in lasts + +A sign-in stays valid for eight hours. Once that much time has passed since a member of staff authenticated, they authenticate against your IdP again. Set `"session_max_age_seconds"` to choose a different length: + +```json +{ + "client_id": "0oaDEFAULT", + "openid_configuration": "https://acme.okta.com/.well-known/openid-configuration", + "session_max_age_seconds": 28800 +} +``` + +The default of eight hours (`28800`) covers a working day, so staff re-authenticate at most daily. The maximum is 30 days (`2592000`): a value of `0` or above the maximum is not clamped, it makes Internet Identity reject the whole file (see [Limits](#limits)). + +Applications choose their own session length as well, and this value caps it: an application asking for 30 days on a domain that allows eight hours gets eight hours. + +## 3. Gate access per app (optional) + +By default your staff can sign in to any Internet Computer application with the client from step 1, and your provider's assignment rules for that client apply everywhere. To govern one application on its own, give it a client of its own. + +Repeat these three steps for each application you want to gate. + + + +**a. Add a client for the app.** Register a second OIDC client, identical settings to step 1. Copy its `client_id`, for example `0oaPAYROLL`. + +**b. Assign who is allowed.** That client → **Assignments** → add the groups or users. This assignment is the access rule: assigned staff sign in as normal, anyone else is stopped by your IdP. On Entra ID, set **Assignment required** to **Yes** on the client as well. It defaults to **No**, which leaves the app open to your whole tenant. + +**c. Map the app to it.** Add one `app_clients` line to the file from step 2, keyed by the application's origin: + +```json +"app_clients": { + "https://payroll.acme.com": "0oaPAYROLL" +} +``` + +The key must be the application's exact origin: scheme, host, and port if any, with no path and no trailing slash. A key that does not match exactly is never used, and that application silently falls back to the organization's client. + +### Applications you have not listed + +By default, an application missing from `app_clients` falls back to the organization's client from step 1, so staff can sign in to it like any other. Set `"gate_all_apps": true` to refuse those sign-ins instead, and staff visiting an unlisted application are told your organization has not granted it access. + +Use `true` when the list is meant to be exhaustive, so a new application cannot be signed in to until you have added it deliberately. + +### Providers that issue a per-client subject + +This applies only once an application has a client of its own. + +Some providers, Entra ID among them, issue a different `sub` for the same person in each OIDC client. Sign-ins through the per-app client would then look like a different person from sign-ins through the organization's client. Set `"stable_identifier_claim"` to a claim that stays the same across your clients: on Entra ID that is `oid`. + +It defaults to `sub`, which is correct when your provider's `sub` is already the same in every client. + +Choose it before staff sign in through per-app clients. Internet Identity recognizes a person across your clients by the value of this claim, so changing it later means sign-ins through per-app clients no longer match the people they matched before. + + +### Hiding an app name + +The file is public, so any origin you list is visible to anyone who reads it. To map an application without naming it, use a salted hash of its origin as the key instead of the origin itself. + +Run this in a shell, with `origin` set to the application's URL: + +```bash +origin=https://payroll.acme.com +salt=$(openssl rand -hex 8) +data=$origin$salt +out=$(printf %s "$data" | openssl dgst -sha256 -r) +hash=$(echo $out | cut -d' ' -f1) +echo "$hash:$salt" +``` + +It prints one value, in the form `:`. Use it as the key in place of the origin: + +```json +"app_clients": { + "9c8dbbd738e2e390267c7dd7350623c541907a66a1f064e22c13d954e08322af:9f86d081884c7d65": "0oaPAYROLL" +} +``` + +Internet Identity matches the key by hashing the origin of whichever application the user is signing in to, so cleartext and hashed keys can be mixed in one file. + +## The complete file + +Every field, with the optional ones filled in: + +```json +{ + "client_id": "0oaDEFAULT", + "openid_configuration": "https://acme.okta.com/.well-known/openid-configuration", + "name": "Acme Corp", + "session_max_age_seconds": 28800, + "app_clients": { + "https://payroll.acme.com": "0oaPAYROLL", + "https://board.acme.com": "0oaBOARD" + }, + "gate_all_apps": false, + "stable_identifier_claim": "sub" +} +``` + +`client_id` and `openid_configuration` are required. The rest are optional: + +| Field | Default | Purpose | +|-------|---------|---------| +| `name` | the domain | Label shown on the sign-in screen | +| `session_max_age_seconds` | `28800` (eight hours) | How long a sign-in stays valid before staff authenticate again | +| `app_clients` | none | Maps an application's origin, or a salted hash of it, to the client that governs it | +| `gate_all_apps` | `false` | Refuse applications that are not listed in `app_clients` | +| `stable_identifier_claim` | `sub` | The claim that identifies the same person across your clients | + +## Limits + +Internet Identity checks the whole file, and a value outside these limits rejects **the whole file**, not just that field. While it is rejected, staff cannot sign in through your domain at all. + +| What | Limit | +|------|-------| +| The file, as served | At most 64 KiB | +| `client_id`, `name`, `stable_identifier_claim` | At most 255 bytes each | +| `session_max_age_seconds` | More than `0` and at most `2592000` (30 days) | +| `app_clients` | At most 100 entries | +| An `app_clients` key or client ID | At most 255 bytes each | +| All `app_clients` keys and client IDs together | At most 16 KiB | + +Your IdP's own discovery document is checked the same way: at most 64 KiB, with `issuer`, `jwks_uri`, and `authorization_endpoint` at most 255 bytes each. + +## When changes take effect + +Internet Identity caches your file and the discovery document it points to. A cached copy is fresh for an hour; after that, the next sign-in through your domain, or an application checking the domain, fetches it again. That sign-in, and any that arrive while the fetch runs, still use the copy that was cached, which after a quiet period can be up to seven days old; every sign-in after the fetch uses your current file. + +- **To stop new sign-ins to an application at once**, remove the people or groups from that application's client in your IdP: your IdP enforces its assignments on every sign-in, while a change to `app_clients` or `gate_all_apps` applies only from the next fetch. Sessions already established last until they end, at most `session_max_age_seconds` after the sign-in. +- **If your file becomes unreachable or invalid**, Internet Identity keeps using the last good copy until it is two hours old (an hour past fresh), or drops it at once if it is older, and then refuses sign-ins through your domain until the file is fixed. After a failed fetch it waits before trying again, starting at one minute and doubling each time. + +## Next steps + +- [One-click sign-in](one-click-sign-in.md#sign-in-with-an-organizations-sso): how applications send staff into this flow. +- [Identity attributes](identity-attributes.md#scoped-keys): the `sso:` attributes your staff can share with applications. + + diff --git a/docs/guides/authentication/identity-attributes.mdx b/docs/guides/authentication/identity-attributes.mdx new file mode 100644 index 00000000..34556803 --- /dev/null +++ b/docs/guides/authentication/identity-attributes.mdx @@ -0,0 +1,214 @@ +--- +title: "Identity attributes" +description: "Request a user's name and verified email from Internet Identity as a signed bundle, and verify it in a Motoko or Rust backend." +sidebar: + order: 3 +--- + +import { Tabs, TabItem } from '@astrojs/starlight/components'; + +When your backend needs more than the user's principal, for example a verified email address, Internet Identity can return **identity attributes**: a bundle of values signed by Internet Identity, which your frontend attaches to a call and your backend verifies. This page covers which attributes exist, how to request them, and how to verify them. + +## Available attributes + +| Key | What it is | Use it for | +|-----|------------|------------| +| `name` | The user's display name from their linked account. | Personalization. | +| `email` | The email address from their linked account. Internet Identity does not check it, so treat it as user-supplied input. | Contact details, mailing lists. | +| `verified_email` | An email address Internet Identity knows the user has access to: one that an OpenID provider such as Google marked as verified, or one that the user linked and verified with Internet Identity. | Anything that gates access on the email, such as an admin allowlist. | + +`requestAttributes` has no default key set: pass the keys you need. + +### Scoped keys + +Attributes can also be scoped to one source, so the value comes from that account and the user grants it in the same step they sign in with: + +- `openid::` for Google, Apple, or Microsoft, with `name`, `email`, and `verified_email`. +- `sso::` for an organization's SSO, with `name` and `email`. An organization's SSO is run by that organization, so it never produces a `verified_email`. + +`scopedKeys` from `@icp-sdk/auth/client` builds them: + +```javascript +import { scopedKeys } from "@icp-sdk/auth/client"; + +scopedKeys({ openIdProvider: "google", keys: ["name", "verified_email"] }); +// ["openid:https://accounts.google.com:name", "openid:https://accounts.google.com:verified_email"] + +scopedKeys({ ssoDomain: "acme.com" }); +// ["sso:acme.com:name", "sso:acme.com:email"] +``` + +The Microsoft provider URL is the literal `https://login.microsoftonline.com/{tid}/v2.0`: `{tid}` is part of the key, not a placeholder for a tenant ID. Scoped keys pair with [one-click sign-in](one-click-sign-in.md), which sends the user to that same provider. + +## Request attributes with the sign-in + +The backend starts the flow by issuing a single-use **nonce**, and verifies at the end that the bundle carries it. A nonce made by the frontend would let a captured bundle be replayed, so it must come from the canister. + +The backend exposes two methods: `_internet_identity_sign_in_start` returns a nonce, and `_internet_identity_sign_in_finish` verifies the bundle the call carries. Sign-in and the attribute request run in parallel, so the user sees a single Internet Identity interaction: + +```javascript +import { AuthClient } from "@icp-sdk/auth/client"; +import { AttributesIdentity } from "@icp-sdk/core/identity"; +import { HttpAgent, Actor } from "@icp-sdk/core/agent"; +import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; +import { Principal } from "@icp-sdk/core/principal"; + +const II_CANISTER_ID = "rdmx6-jaaaa-aaaaa-aaadq-cai"; +const rootKey = safeGetCanisterEnv()?.IC_ROOT_KEY; + +async function signInWithAttributes(authClient, canisterId, idlFactory) { + // Anonymous actor, used only to fetch the nonce. + const anonymous = Actor.createActor(idlFactory, { + agent: await HttpAgent.create({ rootKey }), + canisterId, + }); + + const signInPromise = authClient.signIn(); + const attributesPromise = authClient.requestAttributes({ + keys: ["name", "verified_email"], + // A function: the client calls it when it needs the nonce. + nonce: () => anonymous._internet_identity_sign_in_start(), + }); + const identity = await signInPromise; + const attributes = await attributesPromise; + + // The bundle travels with every call this agent makes. + const agent = await HttpAgent.create({ + identity: new AttributesIdentity({ + inner: identity, + attributes, + signer: { canisterId: Principal.fromText(II_CANISTER_ID) }, + }), + rootKey, + }); + const backend = Actor.createActor(idlFactory, { agent, canisterId }); + + const result = await backend._internet_identity_sign_in_finish(); + if ("err" in result) { + throw new Error(`Attribute verification failed: ${JSON.stringify(result.err)}`); + } + return identity; +} +``` + +To request attributes later, for example when a user links an email to an existing account, expose another pair of methods and run the same steps: a fresh nonce, `requestAttributes`, and verification. + +## Verify attributes in the backend + +The bundle is an [ICRC-3 value](https://github.com/dfinity/ICRC-1/tree/main/standards/ICRC-3) map with the keys you requested plus three implicit fields. Before reading any attribute, the backend checks: + +1. **The signer** is Internet Identity, `rdmx6-jaaaa-aaaaa-aaadq-cai`. The network checks that the bundle is signed, not who signed it, and any canister can sign a bundle. +2. **`implicit:origin`** is a frontend origin you trust, so another app cannot forward its users' bundles to your backend. +3. **`implicit:issued_at_timestamp_ns`** is recent; a few minutes is typical. +4. **`implicit:nonce`** is one this canister issued and has not consumed yet; consume it. + +The `identity-attributes` library, for [Motoko](https://mops.one/identity-attributes) and [Rust](https://crates.io/crates/identity-attributes), adds both methods and runs your function only for a bundle that passes every check: + + + + +Add it to `mops.toml`: + +```toml +[dependencies] +identity-attributes = "0.4.1" +core = "2.5.0" + +[toolchain] +moc = "1.6.0" +``` + +```motoko +import IdentityAttributes "mo:identity-attributes"; +import Map "mo:core/Map"; +import Principal "mo:core/Principal"; + +persistent actor { + type Profile = { name : ?Text; email : ?Text; sso : ?Text }; + + let profiles = Map.empty(); + + include IdentityAttributes({ + onVerified = func(caller, attrs) { + profiles.add(caller, attrs); + }; + }); + + public query func getProfile(userId : Principal) : async ?Profile { + profiles.get(userId) + }; +}; +``` + + + + +Add it to `Cargo.toml`: + +```toml +[dependencies] +candid = "0.10" +ic-cdk = "0.20.1" +identity-attributes = "0.1" +``` + +```rust +use candid::Principal; +use ic_cdk::query; +use identity_attributes::{identity_attributes, IdentityAttributes}; +use std::cell::RefCell; +use std::collections::BTreeMap; + +thread_local! { + // On the heap to keep the example short: an upgrade clears it. + static PROFILES: RefCell> = + const { RefCell::new(BTreeMap::new()) }; +} + +#[identity_attributes] +fn consume_attributes(caller: Principal, attributes: IdentityAttributes) { + PROFILES.with_borrow_mut(|profiles| profiles.insert(caller, attributes)); +} + +#[query] +fn get_profile(user_id: Principal) -> Option { + PROFILES.with_borrow(|profiles| profiles.get(&user_id).cloned()) +} + +ic_cdk::export_candid!(); +``` + + + + +Your function receives `{ name, email, sso }`: + +- `email` comes from `verified_email` (or `openid::verified_email`), and for an SSO sign-in from `sso::email`. The library never reads the unverified `email` key of other sources, which is why the frontend requests `verified_email`. +- `sso` is the organization's domain when the values came from `sso:` keys, and empty otherwise. + +Configure the library through the canister's environment variables in `icp.yaml`: + +```yaml +canisters: + - name: backend + settings: + environment_variables: + trusted_attribute_signers: "rdmx6-jaaaa-aaaaa-aaadq-cai" # required + frontend_origins: "https://your-app.icp.net" # required, comma-separated + trusted_sso_domains: "acme.com" # optional, comma-separated; omit to reject all sso: keys +``` + +An unconfigured canister trusts nothing: without `trusted_attribute_signers` every bundle is rejected, and without `frontend_origins` the finish method returns `err` with `FrontendOriginsNotConfigured`. A bundle with an `sso:` key from a domain outside `trusted_sso_domains`, or one mixing `sso:` keys with keys of another source, is rejected as well. Both libraries return the same Candid types, so the same frontend works against either. + +## Common mistakes + +- **Generating the nonce in the frontend.** The canister cannot tell such a nonce from a replayed one. Always fetch it from `_internet_identity_sign_in_start`. +- **Reading the bundle without checking the signer.** Any canister can sign a bundle with `email = "admin@your-app.com"`; only Internet Identity's signature counts. +- **Gating access on `email`.** It is not checked by Internet Identity. Use `verified_email`, or `sso::email` from a domain you trust. +- **Substituting `{tid}` in the Microsoft key.** It is literal; a key with a tenant ID in it matches nothing. + +## Next steps + +- [One-click sign-in](one-click-sign-in.md): send users straight to the provider their scoped attributes come from. +- [Enterprise SSO](enterprise-sso.md): how an organization makes `sso:` attributes available. +- [Security best practices](../../concepts/security.md): identity and trust fundamentals. diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index 9a27a3ef..495ac67c 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -1,152 +1,72 @@ --- -title: "Internet Identity" -description: "Integrate passkey-based authentication with Internet Identity for frontend sign-in, backend caller verification, and session management" +title: "Getting started" +description: "Add Internet Identity sign-in to a frontend with @icp-sdk/auth, call your backend as the signed-in user, and reject anonymous callers." sidebar: order: 1 --- import { Tabs, TabItem } from '@astrojs/starlight/components'; -Internet Identity (II) is the Internet Computer's native authentication system. Users sign in with passkeys or OpenID accounts (Google, Apple, Microsoft) instead of passwords. Each user receives a unique principal per frontend origin, preventing cross-app tracking. - -This guide covers setting up II authentication end-to-end: configuring your project, adding sign-in to your frontend, and verifying callers in your backend. +Internet Identity (II) is the Internet Computer's sign-in. Users authenticate with a passkey, with a Google, Apple, or Microsoft account, or through their organization's SSO, and your app receives an identity it can call canisters with. This page adds sign-in to a frontend and checks the caller in a backend. ## How it works -When a user authenticates through Internet Identity, the following happens: - -1. Your frontend opens an II popup window. -2. The user authenticates with a passkey or OpenID provider. -3. II creates a **delegation identity**: a temporary key pair that can sign messages on behalf of the user's master key. -4. Your frontend receives this delegation and uses it to sign canister calls. -5. The backend canister sees the user's **principal** (derived from the delegation chain) as `msg.caller`. - -**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. - -**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. +Signing in opens a session at Internet Identity. The `AuthClient` from `@icp-sdk/auth` receives a **delegation** from that session: a short-lived key that may sign canister calls for the user, which the client replaces before it expires. Canisters see the user's **principal** as the caller. -## Project setup - -### Configure icp.yaml for local Internet Identity - -Add `ii: true` to your local network configuration. This tells icp-cli to deploy a local Internet Identity canister automatically: - -```yaml -networks: - - name: local - mode: managed - ii: true -``` +II derives a different principal for each app origin, so one person is a different user to `https://app-a.icp.net` and `https://app-b.icp.net`, and apps cannot correlate users across services. To keep one principal across several origins you control, see [Shared sessions across subdomains](shared-sessions.md#use-one-derivation-origin). -### Install frontend packages +## Install ```bash -npm install @icp-sdk/auth@9 @icp-sdk/core@5 +npm install @icp-sdk/auth @icp-sdk/core ``` -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. +Install both together: each `@icp-sdk/auth` major peers a specific `@icp-sdk/core` major. -## Frontend integration +## Sign in and sign out -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`). 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): +A client reads and writes the sign-in held in the browser's storage, not in the instance, so it is cheap: construct one where you need it and call `dispose()` when that page or component goes away. Without options, it signs in against mainnet Internet Identity: ```javascript import { AuthClient } from "@icp-sdk/auth/client"; -import { HttpAgent, Actor } from "@icp-sdk/core/agent"; -import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; -// Read the ic_env cookie set by the frontend canister or Vite dev server. -// Contains IC_ROOT_KEY and canister IDs: works in both local and production without -// environment branching. Available in browser contexts only; see note below for Node.js. -const canisterEnv = safeGetCanisterEnv(); +const authClient = new AuthClient(); -function getIdentityProvider() { - const host = window.location.hostname; - const isLocal = - host === "localhost" || - host === "127.0.0.1" || - host.endsWith(".localhost"); - - return { - // icp-cli sets up a local alias: http://id.ai.localhost:8000 - 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 - -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({ - identityProvider: getIdentityProvider(), -}); - -// 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 -} - -// Sign in async function signIn() { try { const identity = await authClient.signIn(); - console.log("Signed in as:", identity.getPrincipal().toText()); - return identity; + console.log("Signed in as", identity.getPrincipal().toText()); } catch (error) { + // The user closed the window, or authentication failed. console.error("Sign-in failed:", error); - throw error; } } -// 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: the state changes, so a subscriber re-renders. async function signOut() { + // Ends the session at Internet Identity, in every tab of this origin. await authClient.signOut(); } - -// 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. +`signIn()` accepts two optional bounds on the session, both in nanoseconds: `maxTimeToIdle`, after which an unused session ends, and `maxTimeToLive`, which it never outlives. Leave them unset unless your app has a policy of its own: Internet Identity then applies seven days of idleness and thirty days in total. -### Render +## Render on the sign-in status -`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: +`isAuthenticated()` answers whether this page can act as the user, synchronously. `getStatus()` answers in more detail, and `subscribe()` tells you when to read it again, including when another tab signs in or out: ```javascript -const unsubscribe = authClient.subscribe(() => render(authClient.getStatus())); +authClient.subscribe(() => render(authClient.getStatus())); +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. + // Still names the account: show "your session ended" rather than a bare sign-in screen. return showSessionEnded(status.principal); case "signed-in-elsewhere": - // 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. + // Only reachable when sessions are shared across subdomains. return showResume(status.principal); case "signed-out": return showSignInButton(); @@ -154,154 +74,28 @@ function render(status) { } ``` -### One-click OpenID sign-in +`subscribe()` returns a function that stops the subscription; `dispose()` ends every subscription and listener the client holds, without signing out. -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'`: +## Call your backend as the user -```javascript -const authClient = new AuthClient({ - 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: +Pass the identity to an `HttpAgent`. The root key comes from the `ic_env` cookie that the frontend canister (or the Vite dev server) sets, so the same code runs locally and on mainnet: ```javascript -import { AuthClient, isValidSsoDomain } from "@icp-sdk/auth/client"; - -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" - }); - await authClient.signIn(); -} -``` - -### Create an authenticated agent +import { HttpAgent, Actor } from "@icp-sdk/core/agent"; +import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; -After sign-in, create an `HttpAgent` using the delegation identity. The agent signs all subsequent canister calls with the user's delegated key: +const canisterEnv = safeGetCanisterEnv(); -```javascript -async function createAuthenticatedActor(identity, canisterId, idlFactory) { +async function createActor(canisterId, idlFactory) { const agent = await HttpAgent.create({ - identity, - host: window.location.origin, + identity: await authClient.getIdentity(), rootKey: canisterEnv?.IC_ROOT_KEY, }); - return Actor.createActor(idlFactory, { agent, canisterId }); } ``` -:::note[Node.js environments] -`safeGetCanisterEnv()` reads the `ic_env` cookie set by the frontend canister or Vite dev server (it only works in browser contexts. For Node.js scripts or tests connecting to a **local** replica, create the agent normally and call `await agent.fetchRootKey()` explicitly after creation. Never call `fetchRootKey()` against a mainnet endpoint) on mainnet the root key is pre-trusted, and fetching it at runtime exposes a man-in-the-middle risk. -::: - -### Requesting identity attributes - -When a backend canister needs more than just the user's principal (for example, a verified email address), Internet Identity can return signed attributes alongside the delegation. The flow is a two-method handshake on the backend: `_internet_identity_sign_in_start` mints a nonce, and `_internet_identity_sign_in_finish` verifies the bundle. In Motoko the [`mo:identity-attributes`](https://mops.one/identity-attributes) library provides both methods; in Rust you implement them by hand (see [Read identity attributes](#read-identity-attributes)). The frontend below is identical against either backend. - -**Why a backend-issued nonce?** The canister issues a single-use nonce and consumes it on sign-in, so an intercepted bundle cannot be redeemed again. The nonce must originate from the canister, not the frontend. - -```typescript -import { AuthClient } from "@icp-sdk/auth/client"; -import { AttributesIdentity } from "@icp-sdk/core/identity"; -import { HttpAgent, Actor } from "@icp-sdk/core/agent"; -import { Principal } from "@icp-sdk/core/principal"; - -const II_PRINCIPAL = "rdmx6-jaaaa-aaaaa-aaadq-cai"; - -// `idl` and `canisterId` identify your backend, which exposes -// _internet_identity_sign_in_start / _internet_identity_sign_in_finish. -async function signInWithAttributes(authClient, canisterId, idl) { - // Anonymous handle, used only to mint the nonce. - const anonymousAgent = await HttpAgent.create(); - 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, - // 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: () => anonymousActor._internet_identity_sign_in_start(), - }); - - const identity = await signInPromise; - const attributes = await attributesPromise; - - // Wrap the identity so the signed bundle travels as sender_info on each call. - const verifiedAgent = await HttpAgent.create({ - identity: new AttributesIdentity({ - inner: identity, - attributes, - // The Internet Identity backend canister is the trusted attribute signer. - signer: { canisterId: Principal.fromText(II_PRINCIPAL) }, - }), - }); - const verifiedActor = Actor.createActor(idl, { agent: verifiedAgent, canisterId }); - - // The backend verifies signer, origin, nonce, and freshness, then runs its - // verification logic. Returns { ok } on success, { err } otherwise. - const result = await verifiedActor._internet_identity_sign_in_finish(); - if ("err" in result) { - throw new Error(`Attribute verification failed: ${JSON.stringify(result.err)}`); - } - return identity; -} -``` - -Each signed attribute bundle carries three implicit fields the backend should verify: - -- `implicit:nonce`: matches a single-use nonce the canister issued and consumes on sign-in, so a captured bundle cannot be replayed. -- `implicit:origin`: the requesting frontend origin, so a malicious dapp cannot forward attributes to a different backend. -- `implicit:issued_at_timestamp_ns`: issuance time, letting the canister reject stale bundles even when the nonce is still valid. - -Attributes can also be requested again later, for example to link an email to an existing account, by exposing another start/finish method pair: mint a fresh nonce, call `requestAttributes`, and verify the bundle the same way. - -#### OpenID-scoped attributes - -When using one-click OpenID sign-in, attributes can be scoped to the provider. The user authenticates and shares attributes in a single step, with no extra prompt: - -```typescript -import { AuthClient, scopedKeys } from "@icp-sdk/auth/client"; - -const authClient = new AuthClient({ - identityProvider: getIdentityProvider(), - openIdProvider: "google", -}); - -// In signInWithAttributes, request the Google-scoped keys instead. They arrive -// in the bundle as e.g. "openid:https://accounts.google.com:verified_email", -// 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: () => anonymousActor._internet_identity_sign_in_start(), -}); -``` - -## Backend authentication - -Your backend canister receives the caller's principal automatically through the IC protocol. You do not pass the principal as a function argument: use `msg.caller` (Motoko) or `ic_cdk::api::msg_caller()` (Rust) to read it. - -### Reject anonymous callers - -Any unauthenticated request uses the anonymous principal (`2vxsx-fae`). Reject it in protected endpoints: +The backend reads the caller from the call itself (`msg.caller` in Motoko, `ic_cdk::api::msg_caller()` in Rust), so never pass the principal as an argument. An unauthenticated call arrives as the anonymous principal `2vxsx-fae`; reject it in protected methods: @@ -311,22 +105,10 @@ import Principal "mo:core/Principal"; import Runtime "mo:core/Runtime"; persistent actor { - func requireAuth(caller : Principal) : () { + public shared ({ caller }) func protectedAction() : async Text { if (Principal.isAnonymous(caller)) { Runtime.trap("Anonymous principal not allowed."); }; - }; - - public shared query ({ caller }) func whoAmI() : async Text { - if (Principal.isAnonymous(caller)) { - "anonymous" - } else { - Principal.toText(caller) - }; - }; - - public shared ({ caller }) func protectedAction() : async Text { - requireAuth(caller); "Action performed by " # Principal.toText(caller) }; }; @@ -337,526 +119,47 @@ persistent actor { ```rust use candid::Principal; -use ic_cdk::{query, update}; +use ic_cdk::update; -fn require_auth() -> Principal { +#[update] +fn protected_action() -> String { let caller = ic_cdk::api::msg_caller(); if caller == Principal::anonymous() { ic_cdk::trap("Anonymous principal not allowed."); } - caller -} - -#[query] -fn who_am_i() -> String { - let caller = ic_cdk::api::msg_caller(); - if caller == Principal::anonymous() { - "anonymous".to_string() - } else { - format!("{}", caller) - } -} - -#[update] -fn protected_action() -> String { - let caller = require_auth(); - format!("Action performed by {}", caller) -} -``` - - - - -### Rust: capture caller before await - -In async update functions, bind the caller at the top of the function before any `.await` points. The current ic-cdk executor preserves the caller across await points, but capturing it early is a defensive practice that guards against future executor changes: - -```rust -#[update] -async fn protected_async_action() -> String { - let caller = require_auth(); // Capture before any await - // Replace with your actual async canister call, e.g.: - // ic_cdk::call::<_, (String,)>(some_canister_id, "some_method", ()).await - format!("Action completed by {}", caller) -} -``` - -### Read identity attributes - -The backend exposes two methods the frontend calls: `_internet_identity_sign_in_start` (mints a nonce) and `_internet_identity_sign_in_finish` (verifies the wrapped bundle and runs your logic). The checks are the same in both languages: the bundle must be signed by a trusted signer, its `implicit:origin` must be one you allow, its `implicit:issued_at_timestamp_ns` must be fresh, and its `implicit:nonce` must be one you issued and have not consumed. Motoko gets these checks from a library; Rust does them by hand. - -**Always verify the signer.** The IC checks that the bundle is signed; it does not check *who* signed it, and any canister could have signed an arbitrary one. The trusted signer for Internet Identity is `rdmx6-jaaaa-aaaaa-aaadq-cai`. - -The bundle is Candid-encoded as an [ICRC-3 Value](../../references/internet-identity-spec.md) `Map` with three implicit fields plus the keys you requested: - -- `implicit:nonce`: must equal a nonce your canister issued and not yet consumed. -- `implicit:origin`: must equal a trusted frontend origin. -- `implicit:issued_at_timestamp_ns`: reject if too old (a few minutes is typical). -- Plain attribute keys (for example, `"verified_email"`) for default-scope attributes; OpenID-scoped keys (for example, `"openid:https://accounts.google.com:verified_email"`) when the frontend used `scopedKeys`. - - - - -The [`mo:identity-attributes`](https://mops.one/identity-attributes) mixin injects both methods and runs your `onVerified` callback only on a bundle that passes every check. Add it to `mops.toml`: - -```toml -[dependencies] -identity-attributes = "0.4.1" -core = "2.5.0" - -[toolchain] -moc = "1.6.0" -``` - -`onVerified` receives the resolved `{ name : ?Text; email : ?Text; sso : ?Text }`. The `email` field comes from the `verified_email` key (or its scoped form), which is why the frontend requests `verified_email`. The `sso` field is the matched trusted domain when name and email came from `sso:` keys, otherwise `null`. - -```motoko -import IdentityAttributes "mo:identity-attributes"; -import Map "mo:core/Map"; -import Principal "mo:core/Principal"; - -persistent actor { - type Profile = { name : ?Text; email : ?Text; sso : ?Text }; - - let profiles = Map.empty(); - - // Injects _internet_identity_sign_in_start / _internet_identity_sign_in_finish. - // onVerified runs only on a bundle that passed the signer, origin, nonce, and - // freshness checks. - include IdentityAttributes({ - onVerified = func(caller, attrs) { - profiles.add(caller, attrs); - }; - }); - - public query func getProfile(caller : Principal) : async ?Profile { - profiles.get(caller) - }; -}; -``` - -Configure the env vars in your `icp.yaml` so `icp deploy` sets them on the canister. The values are comma-separated, so list both your local and mainnet II principals if your tests run against a locally deployed II: - -```yaml -canisters: - - name: backend - settings: - environment_variables: - trusted_attribute_signers: "rdmx6-jaaaa-aaaaa-aaadq-cai" # required - frontend_origins: "https://your-app.icp.net" # required, comma-separated - trusted_sso_domains: "your-org.com" # optional; omit to reject all sso:* keys -``` - -If `trusted_attribute_signers` is unset the bundle is rejected as untrusted; if `frontend_origins` is unset the finish method returns `#err(#FrontendOriginsNotConfigured)`. Both are correct: an unconfigured canister must not trust attribute bundles. - - - - -There is no CDK wrapper yet, so implement the two methods by hand. `_internet_identity_sign_in_start` mints a nonce and stores it; `_internet_identity_sign_in_finish` checks the signer with `msg_caller_info_signer()`, decodes the ICRC-3 `Value::Map` from `msg_caller_info_data()`, then verifies origin, freshness, and the nonce before reading attributes. This mirrors what the Motoko library does internally. - -```rust -use candid::{decode_one, CandidType, Deserialize, Principal}; -use ic_cdk::api::{msg_caller, msg_caller_info_data, msg_caller_info_signer, time}; -use ic_cdk::update; -use std::cell::RefCell; -use std::collections::HashSet; - -const II_PRINCIPAL: &str = "rdmx6-jaaaa-aaaaa-aaadq-cai"; -const TRUSTED_ORIGIN: &str = "https://your-app.icp.net"; -const FRESHNESS_NS: u64 = 300_000_000_000; // 5 minutes - -thread_local! { - // Nonces issued by sign_in_start and consumed by sign_in_finish. - static PENDING_NONCES: RefCell>> = RefCell::new(HashSet::new()); -} - -// Mirrors the mo:identity-attributes Result so the frontend "err" check works -// against either backend. -#[derive(CandidType)] -enum SignInResult { - #[serde(rename = "ok")] - Ok, - #[serde(rename = "err")] - Err(String), -} - -#[derive(CandidType, Deserialize)] -enum Icrc3Value { - Nat(candid::Nat), - Int(candid::Int), - Blob(Vec), - Text(String), - Array(Vec), - Map(Vec<(String, Icrc3Value)>), -} - -fn lookup_text<'a>(entries: &'a [(String, Icrc3Value)], key: &str) -> Option<&'a str> { - entries.iter().find_map(|(k, v)| match v { - Icrc3Value::Text(s) if k == key => Some(s.as_str()), - _ => None, - }) -} - -fn lookup_blob<'a>(entries: &'a [(String, Icrc3Value)], key: &str) -> Option<&'a [u8]> { - entries.iter().find_map(|(k, v)| match v { - Icrc3Value::Blob(b) if k == key => Some(b.as_slice()), - _ => None, - }) -} - -fn lookup_nat<'a>(entries: &'a [(String, Icrc3Value)], key: &str) -> Option<&'a candid::Nat> { - entries.iter().find_map(|(k, v)| match v { - Icrc3Value::Nat(n) if k == key => Some(n), - _ => None, - }) -} - -// Mint a fresh nonce. The frontend calls this anonymously before sign-in. -#[update] -async fn _internet_identity_sign_in_start() -> Vec { - let nonce = ic_cdk::management_canister::raw_rand() - .await - .expect("raw_rand failed"); - PENDING_NONCES.with_borrow_mut(|n| n.insert(nonce.clone())); - nonce -} - -// Runs every check the mo:identity-attributes mixin runs internally. -fn verified_attributes() -> Result, String> { - // 1. Trusted signer: the IC checks the signature, not who signed it. - let trusted = Principal::from_text(II_PRINCIPAL).unwrap(); - if msg_caller_info_signer() != Some(trusted) { - return Err("Untrusted attribute signer".to_string()); - } - - // 2. Decode the bundle as an ICRC-3 Value::Map. - let value: Icrc3Value = - decode_one(&msg_caller_info_data()).map_err(|_| "Malformed attribute bundle".to_string())?; - let Icrc3Value::Map(entries) = value else { - return Err("Expected attribute map".to_string()); - }; - - // 3. Origin must be one we allow. - let origin = lookup_text(&entries, "implicit:origin").ok_or("Missing origin")?; - if origin != TRUSTED_ORIGIN { - return Err(format!("Untrusted frontend origin: {origin}")); - } - - // 4. Bundle must be fresh. - let issued_at: u64 = lookup_nat(&entries, "implicit:issued_at_timestamp_ns") - .ok_or("Missing timestamp")? - .0 - .clone() - .try_into() - .map_err(|_| "Timestamp out of range".to_string())?; - if time() > issued_at + FRESHNESS_NS { - return Err("Bundle too old".to_string()); - } - - // 5. Nonce must be one we issued and have not consumed yet. - let nonce = lookup_blob(&entries, "implicit:nonce").ok_or("Missing nonce")?; - if !PENDING_NONCES.with_borrow_mut(|n| n.remove(nonce)) { - return Err("Unknown or already-consumed nonce".to_string()); - } - - Ok(entries) -} - -#[update] -fn _internet_identity_sign_in_finish() -> SignInResult { - let entries = match verified_attributes() { - Ok(entries) => entries, - Err(e) => return SignInResult::Err(e), - }; - - // Your app logic. verified_email gates access. - let Some(email) = lookup_text(&entries, "verified_email") else { - return SignInResult::Err("Missing verified_email".to_string()); - }; - let caller = msg_caller(); - let name = lookup_text(&entries, "name"); - // For example, persist a profile keyed by `caller` here. - let _ = (caller, email, name); - - SignInResult::Ok + format!("Action performed by {caller}") } ``` -## Local development +For role checks and other access-control patterns, see [Security best practices](../../concepts/security.md). -Start the local network and deploy. With `ii: true` in your `icp.yaml`, icp-cli deploys a local Internet Identity canister automatically: - -```bash -icp network start -icp deploy -``` +## Develop locally -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. +The default client signs in against mainnet Internet Identity from a local network as well. +{/* Needs human verification: that a local network started by icp-cli trusts delegations signed by mainnet Internet Identity (stated in the internet-identity IC skill, not found in the pinned icp-cli docs). */} +For a fully local setup, add `ii: true` to the local network in `icp.yaml`, construct the client with `identityProvider: { authorizeUrl: "http://id.ai.localhost:8000/authorize", canisterId: "rdmx6-jaaaa-aaaaa-aaadq-cai" }`, and pass `agentOptions: { rootKey: canisterEnv?.IC_ROOT_KEY }` so the client accepts the local network's certificates. -To test authentication from the command line: +To call a protected method as anonymous from the command line: ```bash -# Test as the default identity (authenticated) -icp canister call backend whoAmI - -# Test as anonymous using --identity to avoid changing your global default icp canister call backend protectedAction --identity anonymous -# Expected: Error containing "Anonymous principal not allowed" -``` - -For mainnet deployment, Internet Identity is already running: backend canister `rdmx6-jaaaa-aaaaa-aaadq-cai` and frontend canister `uqzsh-gqaaa-aaaaq-qaada-cai` (served at `https://id.ai`). Both IDs are identical on local replicas when `ii: true` is configured. Deploy only your own canisters: - -```bash -icp deploy -e ic -``` - -## Alternative origins - -By default, each frontend origin produces a different user principal. If you serve your app from multiple domains (for example, migrating from `.icp.net` to a custom domain), users would get different principals on each domain. - -:::note -II now automatically handles the `icp0.io` vs `ic0.app` domain difference: you do **not** need to use `derivationOrigin` or `ii-alternative-origins` for that case. Use alternative origins only when you have two genuinely distinct custom domains that should share the same user principal. -::: - -To keep principals consistent across your own custom domains, configure **alternative origins**: - -1. **On the primary origin (A):** Create a file at `.well-known/ii-alternative-origins` listing the alternative domains: - - ```json - { - "alternativeOrigins": ["https://www.yourcustomdomain.com"] - } - ``` - - A maximum of 100 alternative origins can be listed. No trailing slashes or paths. - -2. **Serve it with the right content type and CORS headers.** II reads the file cross-origin, and nothing is set for you. - - On a [static site](../frontends/static-site/overview.md), `.well-known/` is uploaded automatically; declare the two headers in a `_headers` file at the root of your build directory: - - ```text - /.well-known/ii-alternative-origins - Content-Type: application/json - Access-Control-Allow-Origin: * - ``` - - On the [legacy asset canister](../frontends/asset-canister.md), the directory has to be un-ignored as well, in `.ic-assets.json5`: - - ```json - [ - { - "match": ".well-known", - "ignore": false - }, - { - "match": ".well-known/ii-alternative-origins", - "headers": { - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json" - }, - "ignore": false - } - ] - ``` - -3. **On the alternative origin (B):** Set the `derivationOrigin` on the `AuthClient` constructor to point back to the primary origin: - - ```javascript - const authClient = new AuthClient({ - identityProvider: getIdentityProvider(), - derivationOrigin: "https://xxxxx.icp.net", // primary origin A - }); - ``` - - The primary origin (A) does not need `derivationOrigin`: it is only required on alternative origins. - -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 - 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, 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 - // /reauth - async function reauth() { - const status = new AuthClient(clientOptions).getStatus(); - - if (status.state !== "signed-in-elsewhere") { - location.replace("/"); - return; - } - - 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("/"); - } - } - - 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. - - **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"] } - ``` - - 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. - - ```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. - -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 - -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. - -```json -{ - "name": "Example App", - "description": "A short tagline shown on the sign-in screen", - "logo": "/logo.png" -} ``` -II fetches this document when the authorization flow starts, from the origin your users' identities are derived for: your `derivationOrigin` when you set one (see [Alternative origins](#alternative-origins) above), and the origin the request came from otherwise. Publish it once on that origin, and every alternative origin listed there is presented with the same name, description, and logo, with nothing to keep in sync between them. - -All three fields are optional, and unknown fields are ignored, so a document stays valid as fields are added: - -- `name` is limited to 40 characters and `description` to 120, counted in Unicode code points on the value as served. Runs of whitespace are collapsed before display. -- Control characters, `U+FEFF`, and the bidirectional embedding and override characters `U+202A` to `U+202E` are rejected, since they can make rendered text read differently from what it contains. The bidirectional marks and isolates that mixed-direction names legitimately need are accepted, provided every isolate a field opens it also closes. -- A field that fails validation invalidates the **whole document**, which is then ignored, so an app is never shown with half of its metadata applied. II logs which field is at fault to the browser console: check the console on the sign-in screen if your metadata does not appear. -- `logo` must point to a raster image on the same origin as the document (relative URLs resolve against it), served as `image/png`, `image/jpeg`, `image/webp`, `image/gif`, or `image/avif`, at most 1 MiB and 4096 pixels per side. SVG is not accepted. II downloads the image, re-encodes it at up to 512 pixels on its longest side, and renders its own copy, so a roughly square PNG or WebP of about 512 pixels works well. -- Only the shape of `logo` (a non-empty URL on the document's own origin) is part of the validation above. Once it passes, a logo that cannot be fetched or decoded, or that breaks the content type, size, or dimension rules, costs you the logo alone: the name and description still render. Fetching a second resource can fail transiently, so that is treated differently from a mistake in the document itself. -- The document must not exceed 8 KiB, must be answered with `200`, and must not redirect. II requests it without credentials and gives up after 10 seconds. - -Both the document and the logo are read cross-origin, so they need CORS headers too. Extend the configuration shown under [Alternative origins](#alternative-origins) with an entry for each. - -With `_headers`: - -```text -/.well-known/ii-app-metadata - Content-Type: application/json - Access-Control-Allow-Origin: * - -/logo.png - Access-Control-Allow-Origin: * -``` - -With `.ic-assets.json5`: - -```json -[ - { - "match": ".well-known", - "ignore": false - }, - { - "match": ".well-known/ii-app-metadata", - "headers": { - "Access-Control-Allow-Origin": "*", - "Content-Type": "application/json" - }, - "ignore": false - }, - { - "match": "logo.png", - "headers": { - "Access-Control-Allow-Origin": "*" - } - } -] -``` - -If the document is missing, unreachable, or invalid, sign-in is unaffected: the screens fall back to the curated entry II still ships for a small list of apps (the mechanism this file supersedes), and to showing your origin alone otherwise. Metadata is a display nicety and never blocks authentication. - -:::note -This metadata is exactly as trustworthy as the origin serving it, and publishing it does not verify your app's identity in any way. II therefore keeps displaying the origin alongside whatever you provide, since the origin is the value users can actually check. -::: - -For the normative rules, including a JSON schema to validate your document against, see [App metadata](../../references/internet-identity-spec.md#app-metadata) in the Internet Identity specification. - ## 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 `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. -- **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. -- **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()`. +- **Not handling a rejected `signIn()`.** It rejects when the user closes the window or authentication fails. Without `await` and a `catch`, that failure is silently lost. +- **Signing out on the delegation's expiry.** The delegation an identity signs with is short-lived and replaced for you, so a timer set from it ends the session after minutes. The session's end arrives as the `expired` status. +- **Leaking clients.** Each client hooks browser listeners and schedules delegation refreshes; call `dispose()` when the view that made it goes away. +- **Passing `identityProvider` as a URL string.** It is `{ authorizeUrl, canisterId }`, both required together, and only needed for a non-mainnet Internet Identity. +- **Fetching the root key at runtime.** Use the `rootKey` from the `ic_env` cookie. `shouldFetchRootKey` or `fetchRootKey()` against mainnet lets a man-in-the-middle substitute the key. ## Next steps -- [Wallet integration](../digital-assets/wallet-integration.md) for token-based authentication alternatives -- [Frontend frameworks](../frontends/frameworks.md) for framework-specific auth setup patterns -- [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_* */} - -{/* Upstream: informed by dfinity/internet-identity (docs/ii-spec.mdx: the App metadata section, #4221); dfinity/portal (docs/building-apps/authentication/overview.mdx, docs/building-apps/authentication/integrate-internet-identity.mdx, docs/building-apps/authentication/alternative-origins.mdx); dfinity/icskills (skills/internet-identity/SKILL.md); dfinity/icp-js-sdk-docs (public/auth/latest.zip api/client/: AuthClient, scopedKeys, SignedAttributes, AuthClientCreateOptions; public/core/latest.zip libs/identity/api.md: AttributesIdentity); dfinity/cdk-rs (ic-cdk/src/api.rs); dfinity/motoko-identity-attributes (README.md, src/lib.mo, src/Internal/Verify.mo @ v0.4.1: the mixin and its verification order); caffeinelabs/motoko-core (src/CallerAttributes.mo getAttributes wrapper, src/Map.mo); caffeinelabs/motoko (src/prelude/prim.mo callerInfoData/Signer, test/run-drun/caller-info/caller-info.mo); dfinity/icp-cli (docs/reference/canister-settings.md#environment_variables) */} +- [App metadata](app-metadata.md): show your app's name and logo on the sign-in screen. +- [Identity attributes](identity-attributes.md): receive a verified email or name with the sign-in. +- [One-click sign-in](one-click-sign-in.md): send users straight to Google, Apple, Microsoft, or their organization's SSO. +- [Shared sessions across subdomains](shared-sessions.md): sign in once across sibling subdomains, and keep one principal across your origins. +- [`@icp-sdk/auth` API reference](https://js.icp.build/auth/latest/): every option and method. diff --git a/docs/guides/authentication/one-click-sign-in.md b/docs/guides/authentication/one-click-sign-in.md new file mode 100644 index 00000000..db7db4c3 --- /dev/null +++ b/docs/guides/authentication/one-click-sign-in.md @@ -0,0 +1,108 @@ +--- +title: "One-click sign-in" +description: "Send users straight to Google, Apple, Microsoft, or their organization's SSO when they sign in with Internet Identity, and check an organization domain before they do." +sidebar: + order: 4 +--- + +By default, `signIn()` opens Internet Identity and the user picks how to authenticate. One-click sign-in skips that choice: the user lands directly on the provider's own screen. Internet Identity is still the signer: it verifies the provider's token and issues the delegation. + +A client is built for one of two entry points, never both: + +| Option | Sends the user to | Value | +|--------|-------------------|-------| +| `openIdProvider` | A provider Internet Identity has built in | `"google"`, `"apple"`, or `"microsoft"` | +| `ssoDomain` | An organization's own OpenID provider | The organization's domain, such as `"acme.com"` | + +Both are constructor options, fixed for the client's lifetime. To offer several, build one client per choice. + +## Sign in with a built-in provider + +```javascript +import { AuthClient } from "@icp-sdk/auth/client"; + +const authClient = new AuthClient({ openIdProvider: "google" }); +await authClient.signIn(); +``` + +The user goes straight to Google's account chooser. The rest of the flow (`getStatus()`, `getIdentity()`, `signOut()`) is unchanged. + +## Sign in with an organization's SSO + +```javascript +const authClient = new AuthClient({ ssoDomain: "acme.com" }); +await authClient.signIn(); +``` + +Internet Identity reads the organization's configuration from `https://acme.com/.well-known/ii-openid-configuration` and sends the user to the provider named there. Any organization that [publishes that file](enterprise-sso.md) can be signed in against, with nothing registered ahead of time. + +The domain is normalized when the client is built: lowercased and IDNA-encoded. A value that is not a domain, such as one carrying a scheme or a path, or a malformed name such as `foo..com`, is reported as `invalid` (see below). + +If your app sets a `derivationOrigin`, the client sends it along with the domain, so Internet Identity uses the client the organization assigned to that origin. + +## Check a domain the user typed + +An app that asks the user for their organization's domain can show whether it works before they continue. A client built with `ssoDomain` checks its domain against Internet Identity by itself, and `getSsoStatus()` returns the result, the same way `getStatus()` returns the session: + +| State | Meaning | Show | +|-------|---------|------| +| `checking` | Internet Identity is resolving the domain. | A spinner. | +| `available` | The domain is ready for sign-in. `name` is the organization's display name, when it publishes one. | "Continue with Acme Corp". | +| `invalid` | The input is not a domain. | Ask the user to correct it. | +| `unavailable` | The domain publishes no usable configuration, or resolving it failed. `retryAfter` is when a retry can succeed, when known. | The failure, and a retry button. | + +`getSsoStatus()` is synchronous and never throws, and `subscribe()` calls back whenever it changes. Build a new client once the user pauses typing, and dispose of the one it replaces, which also stops its check: + +```javascript +let client; +let timer; + +input.addEventListener("input", () => { + // Drop the old domain at once, so Continue never signs in to it. + client?.dispose(); + client = undefined; + showSpinner(); + + clearTimeout(timer); + timer = setTimeout(() => { + const next = new AuthClient({ ssoDomain: input.value }); + next.subscribe(() => render(next.getSsoStatus())); + render(next.getSsoStatus()); + client = next; + }, 300); +}); + +function render(sso) { + switch (sso.state) { + case "checking": + return showSpinner(); + case "available": + return enableContinue(sso.name); + case "invalid": + return showNotADomain(); + case "unavailable": + return showUnavailable(sso.retryAfter); + } +} + +// Call signIn() before any await, or the browser blocks the popup. +continueButton.addEventListener("click", () => { + client?.signIn().then(showApp, showSignInError); +}); +``` + +A replaced client is disposed before it can report, so a stale result never renders. The debounce builds one client per pause rather than one per keystroke. + +For a "Try again" button, call `refreshSsoStatus()`. While `retryAfter` is in the future, keep the button disabled and count down to it ("Try again in 2 min"): Internet Identity does not retry a failing domain sooner, and an early retry answers `unavailable` again at once. + +The check also prepares Internet Identity for this domain, so `signIn()` on the same client starts without waiting. `signIn()` opens Internet Identity in every state. A sign-in through an `unavailable` or `invalid` domain shows Internet Identity's own error screen. In every state, `signIn()` rejects when the user closes Internet Identity or authentication fails, so handle the rejection. + +## Request attributes in the same step + +A sign-in that goes to one provider can request attributes scoped to that provider, which the user grants on the same screen. See [Identity attributes](identity-attributes.md#scoped-keys). + +## Next steps + +- [Enterprise SSO](enterprise-sso.md): how an organization enables SSO sign-in for its staff. +- [Identity attributes](identity-attributes.md): request a name and email with the sign-in. +- [`@icp-sdk/auth` API reference](https://js.icp.build/auth/latest/): every option and method. diff --git a/docs/guides/authentication/shared-sessions.md b/docs/guides/authentication/shared-sessions.md new file mode 100644 index 00000000..fa1c9bc2 --- /dev/null +++ b/docs/guides/authentication/shared-sessions.md @@ -0,0 +1,152 @@ +--- +title: "Shared sessions across subdomains" +description: "Keep one Internet Identity principal across your origins, and share one sign-in across sibling subdomains such as chat.example.com and hr.example.com." +sidebar: + order: 6 +--- + +Internet Identity derives a principal per origin, so the same person is a different user on every origin your app is served from. This page first gives your origins one principal, then lets apps on sibling subdomains share one sign-in: sign in on one and the others are signed in too, sign out of one and the others follow. + +## Use one derivation origin + +To keep one principal across several origins, pick one of them as the **derivation origin** and have the others derive their principals from it. Pick the origin least likely to change, such as the canister's own `https://.icp.net` address rather than a custom domain, and pin it before the app has users: changing it later gives every existing user a new principal. + +The official gateway domains (`icp.net`, `icp0.io`, and `ic0.app`) already produce the same principal for one canister, so they need none of this. + +**1. Every other origin sets `derivationOrigin`.** The derivation origin itself does not set it: + +```javascript +import { AuthClient } from "@icp-sdk/auth/client"; + +const authClient = new AuthClient({ + derivationOrigin: "https://.icp.net", +}); +``` + +**2. The derivation origin lists the others.** Serve `/.well-known/ii-alternative-origins` on the derivation origin: + +```json +{ "alternativeOrigins": ["https://www.example.com", "https://chat.example.com"] } +``` + +Entries are origins, with no paths and no trailing slashes. At most 100 are allowed, and a longer list is rejected as a whole, so every alternative origin stops signing in. + +**3. Serve it as JSON with CORS.** Internet Identity reads the file cross-origin. On a [static site](../frontends/static-site/overview.md), `.well-known/` is uploaded automatically; declare the headers in a `_headers` file at the root of your build directory: + +```text +/.well-known/ii-alternative-origins + Content-Type: application/json + Access-Control-Allow-Origin: * +``` + +The normative rules are in [Alternative frontend origins](../../references/internet-identity-spec.md#alternative-frontend-origins) in the Internet Identity specification. + +## Share one 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. This builds on the section above: every app derives from the same derivation origin, or each has its own principal and there is nothing to share. + +### Share the record + +Every app builds its client with the same derivation origin and the same cookie domain, so a sign-in on one writes a record the others read. The record holds only the signed-in principal and when the session ends. + +```javascript +import { AuthClient, CookieStateStorage, InteractionRequiredError } from "@icp-sdk/auth/client"; + +const clientOptions = { + derivationOrigin: "https://auth.example.com", + stateStorage: new CookieStateStorage({ domain: "example.com" }), +}; +``` + +Choosing a cookie domain means trusting every origin under it, so do this only on a domain whose subdomains you all control. + +### Pick up the sign-in on a `/reauth` route + +An app whose status is `signed-in-elsewhere` holds no credential for the account its sibling signed in with. It asks Internet Identity for its own, silently, on a route of its own. That runs on page load without a user gesture, which a popup would be blocked for, so it uses the redirect transport: + +```javascript +// Runs on the /reauth route. +async function reauth() { + const status = new AuthClient(clientOptions).getStatus(); + if (status.state !== "signed-in-elsewhere") { + location.replace("/"); + return; + } + + // A second client: transport, prompt, and hint are fixed 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) { + // Nothing to pick up: the shared record is stale, so clear it. + await authClient.signOut().catch(() => {}); + } + location.replace("/"); + } +} + +reauth(); +``` + +Without `hint`, Internet Identity refuses when it holds more than one session rather than guessing, with an `InteractionRequiredError` whose `reason` is `account_selection_required`. A stale record that is not cleared sends the user back here on every page. + +### Declare the callback + +A redirect sign-in returns only to a callback the returning origin declares itself. 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"] } +``` + +Each entry is matched exactly, so it is the full URL with no fragment. Serve the file like the alternative origins one, as `application/json` with `Access-Control-Allow-Origin: *`. An undeclared, unreadable, or mismatched callback means the sign-in never comes back. The route must also not redirect: the response arrives in the URL fragment, which a redirect carries along to wherever it forwards. + +### Send every page there on load + +Every page reads the status as it loads and hands `signed-in-elsewhere` to `/reauth` with the page to come back to. Every page, not only the ones that need a sign-in: otherwise a visitor already signed in on a sibling lands on a public page here and sees a signed-out header. + +```javascript +const authClient = new AuthClient(clientOptions); +const status = authClient.getStatus(); + +// This state only: signed-out and expired need a normal sign-in. +if (status.state === "signed-in-elsewhere") { + location.replace(`/reauth?next=${encodeURIComponent(location.pathname + location.search)}`); +} +``` + +### Offer it to an open page + +Once a page is open, the status of that same client can still turn `signed-in-elsewhere` when someone signs in on a sibling in another tab. Redirecting a page the user is working on would lose their work, so offer the same redirect behind a button: + +```javascript +authClient.subscribe(() => { + if (authClient.getStatus().state === "signed-in-elsewhere") { + showResumeDialog(() => + location.replace(`/reauth?next=${encodeURIComponent(location.pathname + location.search)}`), + ); + } +}); +``` + +## When a shared session ends + +- **Idleness.** Pass `maxTimeToIdle` (nanoseconds) to `signIn()` to end the session after that long without use in any of the apps. Internet Identity enforces it, so it holds across tabs whether or not any is open; without it, Internet Identity applies seven days. +- **Signing out.** `signOut()` in any app ends the session at Internet Identity and removes the shared record. Every sibling reads the record as gone on its next render. A sibling in the middle of a request finishes it, and its next one is refused within five minutes, which is how long a delegation it already holds stays valid. +- **Signing in again elsewhere** replaces the session rather than ending it: a sibling still holding a credential for the old one drops it on its next use and picks up the new sign-in through `/reauth`, with nothing shown to the user. + +Your app learns that a session ended the next time it reads the status: the state becomes `expired`, which still names the account, so the screen can say whose session ended. + +## Next steps + +- [Getting started](internet-identity.md#render-on-the-sign-in-status): the four sign-in states and how to render them. +- [App metadata](app-metadata.md): publish your app's name and logo once, on the derivation origin. +- [Custom domains](../frontends/custom-domains.md): serve your app from your own domain. diff --git a/docs/guides/authentication/verifiable-credentials.md b/docs/guides/authentication/verifiable-credentials.md index 235833e1..7be54c88 100644 --- a/docs/guides/authentication/verifiable-credentials.md +++ b/docs/guides/authentication/verifiable-credentials.md @@ -2,7 +2,8 @@ title: "Verifiable credentials" description: "Issue and verify credentials on ICP using Internet Identity and the VC protocol: covers issuer and relying party integration patterns." sidebar: - order: 2 + order: 7 + hidden: true --- A verifiable credential (VC) is a cryptographically signed digital attestation about a user: for example, that they are over 18, passed KYC, or are a member of an organization. On ICP, verifiable credentials are issued by canister-based issuers, mediated by Internet Identity, and consumed by relying party applications. diff --git a/docs/guides/canister-calls/calling-from-clients.md b/docs/guides/canister-calls/calling-from-clients.md index 309b9bf9..8dee546c 100644 --- a/docs/guides/canister-calls/calling-from-clients.md +++ b/docs/guides/canister-calls/calling-from-clients.md @@ -297,7 +297,7 @@ Anonymous calls work without any setup. The sender principal is `"2vxsx-fae"`. C ### Authenticated calls with Internet Identity -To associate calls with a user's Internet Identity, use `@icp-sdk/auth` to complete the delegation flow and get an `Identity` object, then pass it to the agent. See [Internet Identity](../authentication/internet-identity.md#create-an-authenticated-agent) for the full integration guide. +To associate calls with a user's Internet Identity, use `@icp-sdk/auth` to complete the delegation flow and get an `Identity` object, then pass it to the agent. See [Internet Identity](../authentication/internet-identity.md#call-your-backend-as-the-user) for the full integration guide. Once you have an authenticated identity, pass it to the agent at creation time: diff --git a/docs/guides/frontends/custom-domains.md b/docs/guides/frontends/custom-domains.md index 20553f52..297e2fb1 100644 --- a/docs/guides/frontends/custom-domains.md +++ b/docs/guides/frontends/custom-domains.md @@ -259,7 +259,7 @@ To point an existing custom domain at a different canister: Internet Identity (II) derives user principals from the origin domain. If your users authenticate using the canister URL (`.icp.net`) and you switch to a custom domain, they will get different principals on the new domain. -To preserve the same principals across both origins, configure alternative origins. See [Internet Identity](../authentication/internet-identity.md) for the setup. +To preserve the same principals across both origins, configure alternative origins. See [Use one derivation origin](../authentication/shared-sessions.md#use-one-derivation-origin) for the setup. ## DNS configuration by registrar @@ -358,6 +358,6 @@ Remove any duplicates and keep exactly one record containing your canister ID. - [Certification](certification.md): Enable certified asset responses for your custom domain - [Cycles management](../canister-management/cycles-management.md): Ensure your canister has sufficient cycles for production traffic -- [Internet Identity](../authentication/internet-identity.md): Configure alternative origins if your users authenticate with II +- [Use one derivation origin](../authentication/shared-sessions.md#use-one-derivation-origin): Configure alternative origins if your users authenticate with II diff --git a/docs/guides/frontends/frameworks.md b/docs/guides/frontends/frameworks.md index c8c1cb09..75ea7603 100644 --- a/docs/guides/frontends/frameworks.md +++ b/docs/guides/frontends/frameworks.md @@ -184,7 +184,7 @@ If your Vue app calls `getCanisterEnv()` to read canister IDs, add the same `ser ## Authentication -Authentication with Internet Identity is framework-agnostic. The `@icp-sdk/auth` package works the same way in React, Vue, Svelte, and Next.js static export mode. See the [Internet Identity guide](../authentication/internet-identity.md#frontend-integration) for integration steps. +Authentication with Internet Identity is framework-agnostic. The `@icp-sdk/auth` package works the same way in React, Vue, Svelte, and Next.js static export mode. See the [Internet Identity guide](../authentication/internet-identity.md#sign-in-and-sign-out) for integration steps. ## Svelte and SvelteKit diff --git a/docs/guides/frontends/service-discoverability.md b/docs/guides/frontends/service-discoverability.md index f2ecd728..822016e7 100644 --- a/docs/guides/frontends/service-discoverability.md +++ b/docs/guides/frontends/service-discoverability.md @@ -149,7 +149,7 @@ https://hcv4s-uaaaa-aaabq-qaaba-cai.icp.net If you use the default (the app's own origin), you may omit the file. Its absence means "derive for the visible / requested origin itself." Serve it with no file extension and exempt `/.well-known/*` from the SPA catch-all, exactly as for the manifest. Generate it at deploy time when the origin is a per-network canister URL. -**Relationship between derivation origin and alternative-origins.** A custom origin is enabled by two coupled files: the app pins `derivationOrigin` in its Internet Identity configuration, and the derivation origin publishes `/.well-known/ii-alternative-origins` listing the origins permitted to derive against it. That list answers "who may point here," not "where does this app point." The two are not interchangeable, and there is no reverse lookup from an app URL to its custom derivation origin. Reading it the wrong way round silently produces the wrong principal. See [Internet Identity](../authentication/internet-identity.md#alternative-origins) for how to configure `derivationOrigin` and `ii-alternative-origins`. +**Relationship between derivation origin and alternative-origins.** A custom origin is enabled by two coupled files: the app pins `derivationOrigin` in its Internet Identity configuration, and the derivation origin publishes `/.well-known/ii-alternative-origins` listing the origins permitted to derive against it. That list answers "who may point here," not "where does this app point." The two are not interchangeable, and there is no reverse lookup from an app URL to its custom derivation origin. Reading it the wrong way round silently produces the wrong principal. See [Use one derivation origin](../authentication/shared-sessions.md#use-one-derivation-origin) for how to configure `derivationOrigin` and `ii-alternative-origins`. ## Deployment checklist @@ -185,5 +185,5 @@ End to end: an agent given only `https://APP` resolves the backend ID first (lab - [Asset canister](asset-canister.md): serve `.well-known` files and configure SPA routing. - [Custom domains](custom-domains.md): apply the same `.well-known` pattern to domain ownership. -- [Internet Identity](../authentication/internet-identity.md#alternative-origins): configure `derivationOrigin` and alternative origins. +- [Use one derivation origin](../authentication/shared-sessions.md#use-one-derivation-origin): configure `derivationOrigin` and alternative origins. - [Candid interface](../canister-calls/candid.md): define the typed interface agents read.