diff --git a/docs/guides/authentication/internet-identity.mdx b/docs/guides/authentication/internet-identity.mdx index dac345a..45570c7 100644 --- a/docs/guides/authentication/internet-identity.mdx +++ b/docs/guides/authentication/internet-identity.mdx @@ -1,6 +1,6 @@ --- title: "Internet Identity" -description: "Integrate passkey-based authentication with Internet Identity for frontend login, backend caller verification, and session management" +description: "Integrate passkey-based authentication with Internet Identity for frontend sign-in, backend caller verification, and session management" sidebar: order: 1 --- @@ -9,7 +9,7 @@ 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 login to your frontend, and verifying callers in your backend. +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. ## How it works @@ -46,7 +46,7 @@ npm install @icp-sdk/auth @icp-sdk/core ## Frontend integration -The `AuthClient` from `@icp-sdk/auth` handles the full login flow: opening the II popup, receiving the delegation, and managing session persistence. +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 @@ -77,50 +77,61 @@ function getIdentityProviderUrl() { } ``` -### Login, logout, and session check +### Sign in, sign out, and session check -Create a single `AuthClient` instance on page load and reuse it for all operations: +Create a single `AuthClient` instance on page load and reuse it for all operations. The identity provider URL is passed at construction time, not on each sign-in: ```javascript // Create the auth client (once, on page load) -const authClient = await AuthClient.create(); +const authClient = new AuthClient({ + identityProvider: getIdentityProviderUrl(), +}); // Check for existing session -const isAuthenticated = await authClient.isAuthenticated(); -if (isAuthenticated) { - const identity = authClient.getIdentity(); +if (authClient.isAuthenticated()) { + const identity = await authClient.getIdentity(); // Restore session: create agent and actor with this identity } -// Login -async function login() { - return new Promise((resolve, reject) => { - authClient.login({ - identityProvider: getIdentityProviderUrl(), +// Sign in +async function signIn() { + try { + const identity = await authClient.signIn({ maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), // 8 hours - onSuccess: () => { - const identity = authClient.getIdentity(); - console.log("Logged in as:", identity.getPrincipal().toText()); - resolve(identity); - }, - onError: (error) => { - console.error("Login failed:", error); - reject(error); - }, }); - }); + console.log("Signed in as:", identity.getPrincipal().toText()); + return identity; + } catch (error) { + console.error("Sign-in failed:", error); + throw error; + } } -// Logout -async function logout() { - await authClient.logout(); +// Sign out +async function signOut() { + await authClient.signOut(); // Reset UI state or reload } ``` +`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. + +### One-click OpenID sign-in + +To skip the Internet Identity authentication-method screen and send the user straight to a specific OpenID provider, pass `openIdProvider` to the constructor. Supported values are `'google'`, `'apple'`, and `'microsoft'`: + +```javascript +const authClient = new AuthClient({ + identityProvider: getIdentityProviderUrl(), + openIdProvider: "google", +}); +``` + +The rest of the flow (`signIn`, `getIdentity`, `signOut`) is unchanged. + ### Create an authenticated agent -After login, create an `HttpAgent` using the delegation identity. The agent signs all subsequent canister calls with the user's delegated key: +After sign-in, create an `HttpAgent` using the delegation identity. The agent signs all subsequent canister calls with the user's delegated key: ```javascript async function createAuthenticatedActor(identity, canisterId, idlFactory) { @@ -138,6 +149,84 @@ async function createAuthenticatedActor(identity, canisterId, idlFactory) { `safeGetCanisterEnv()` reads the `ic_env` cookie set by the asset 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. Your backend issues a nonce scoped to a specific action; the frontend requests the attributes during sign-in; the backend verifies the bundle when the user calls the protected method. + +**Why a backend-issued nonce?** Tying attributes to a canister-issued nonce prevents replay: an intercepted bundle cannot be reused for a different action, on a different user, or after that action expires. 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"; + +async function registerWithEmail() { + // 1. Backend issues a nonce scoped to this registration + const anonymousAgent = await HttpAgent.create(); + const backend = Actor.createActor(backendIdl, { + agent: anonymousAgent, + canisterId, + }); + const nonce = await backend.registerBegin(); + + // 2. Run sign-in and the attribute request in parallel. + // The user sees a single Internet Identity interaction. + const signInPromise = authClient.signIn(); + const attributesPromise = authClient.requestAttributes({ + keys: ["email"], + nonce, + }); + + const identity = await signInPromise; + const { data, signature } = await attributesPromise; + + // 3. Wrap the identity so the signed attributes travel with each call + const identityWithAttributes = new AttributesIdentity({ + inner: identity, + attributes: { data, signature }, + // The Internet Identity backend canister ID is the attribute signer + signer: { canisterId: Principal.fromText("rdmx6-jaaaa-aaaaa-aaadq-cai") }, + }); + + // 4. Call the protected method. The backend verifies the nonce, origin, + // and timestamp, then reads the email. + const agent = await HttpAgent.create({ identity: identityWithAttributes }); + const app = Actor.createActor(appIdl, { agent, canisterId }); + await app.registerFinish(); +} +``` + +Each signed attribute bundle carries three implicit fields the backend should verify: + +- `implicit:nonce`: matches the canister-issued nonce, preventing replay across actions and users. +- `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 after sign-in, for example to link an email to an existing account. The pattern is the same: the backend issues a nonce for that action, the frontend calls `requestAttributes`, and the backend verifies the result. + +#### 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: getIdentityProviderUrl(), + openIdProvider: "google", +}); + +const nonce = await backend.registerBegin(); +const signInPromise = authClient.signIn(); +// Requests name, email, and verified_email from the Google account +// linked to the user's Internet Identity. +const attributesPromise = authClient.requestAttributes({ + keys: scopedKeys({ openIdProvider: "google" }), + nonce, +}); +``` + ## 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. @@ -224,6 +313,141 @@ async fn protected_async_action() -> String { } ``` +### Read identity attributes + +When the frontend wraps an identity with `AttributesIdentity`, every call carries a verified attribute bundle. Read it on the backend with `msg_caller_info_data` (Rust) or `Prim.callerInfoData` (Motoko). Always verify the signer first: by itself the bundle is signed by *some* canister, and any canister could have signed an arbitrary one. Trust it only when the signer is the Internet Identity backend (`rdmx6-jaaaa-aaaaa-aaadq-cai`). + +The bundle is Candid-encoded as an [ICRC-3 Value](../../reference/internet-identity-spec.md) `Map` with three implicit fields plus the keys you requested: + +- `implicit:nonce`: must equal a nonce your canister issued for this user and action. +- `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 (e.g., `"email"`) for default-scope attributes; OpenID-scoped keys (e.g., `"openid:https://accounts.google.com:email"`) when the frontend used `scopedKeys`. + + + + +```motoko +import Prim "mo:prim"; +import Principal "mo:core/Principal"; +import Runtime "mo:core/Runtime"; + +persistent actor { + let iiPrincipal = Principal.fromText("rdmx6-jaaaa-aaaaa-aaadq-cai"); + + type Icrc3Value = { + #Nat : Nat; + #Int : Int; + #Blob : Blob; + #Text : Text; + #Array : [Icrc3Value]; + #Map : [(Text, Icrc3Value)]; + }; + + func lookupText(entries : [(Text, Icrc3Value)], key : Text) : ?Text { + for ((k, v) in entries.vals()) { + if (k == key) { + switch v { case (#Text t) { return ?t }; case _ {} }; + }; + }; + null; + }; + + // Returns the verified attribute map, trapping if the signer is not II. + func iiAttributes() : [(Text, Icrc3Value)] { + let signer = Prim.callerInfoSigner(); + if (signer.size() == 0 or Principal.fromBlob(signer) != iiPrincipal) { + Runtime.trap("Untrusted attribute signer"); + }; + let data = Prim.callerInfoData(); + let ?value : ?Icrc3Value = from_candid (data) else Runtime.trap("invalid attribute bundle"); + let #Map(entries) = value else Runtime.trap("expected attribute map"); + entries + }; + + public shared ({ caller }) func registerFinish() : async Text { + if (Principal.isAnonymous(caller)) Runtime.trap("Anonymous caller not allowed"); + let entries = iiAttributes(); + + let ?origin = lookupText(entries, "implicit:origin") else Runtime.trap("missing origin"); + if (origin != "https://your-app.icp0.io") Runtime.trap("Wrong origin"); + + // Compare implicit:nonce to the nonce you minted in registerBegin (omitted for brevity) + // and check implicit:issued_at_timestamp_ns is within your freshness window. + + let ?email = lookupText(entries, "email") else Runtime.trap("missing email"); + "Registered " # Principal.toText(caller) # " with email " # email + }; +}; +``` + + + + +```rust +use candid::{decode_one, CandidType, Deserialize, Principal}; +use ic_cdk::api::{msg_caller, msg_caller_info_data, msg_caller_info_signer}; +use ic_cdk::update; + +const II_PRINCIPAL: &str = "rdmx6-jaaaa-aaaaa-aaadq-cai"; + +#[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, + }) +} + +// Returns the verified attribute entries, trapping if the signer is not II. +fn ii_attributes() -> Vec<(String, Icrc3Value)> { + let trusted = Principal::from_text(II_PRINCIPAL).unwrap(); + if msg_caller_info_signer() != Some(trusted) { + ic_cdk::trap("Untrusted attribute signer"); + } + let bundle = msg_caller_info_data(); + let value: Icrc3Value = decode_one(&bundle).unwrap_or_else(|_| ic_cdk::trap("invalid attribute bundle")); + match value { + Icrc3Value::Map(entries) => entries, + _ => ic_cdk::trap("expected attribute map"), + } +} + +#[update] +fn register_finish() -> String { + let caller = msg_caller(); + if caller == Principal::anonymous() { ic_cdk::trap("Anonymous caller not allowed"); } + let entries = ii_attributes(); + + let origin = lookup_text(&entries, "implicit:origin") + .unwrap_or_else(|| ic_cdk::trap("missing origin")); + if origin != "https://your-app.icp0.io" { ic_cdk::trap("Wrong origin"); } + + // Compare implicit:nonce to the nonce you minted in register_begin (omitted for brevity) + // and check implicit:issued_at_timestamp_ns is within your freshness window. + + let email = lookup_text(&entries, "email") + .unwrap_or_else(|| ic_cdk::trap("missing email")); + format!("Registered {} with email {}", caller, email) +} +``` + + + + +:::tip[Storing the nonce] +Mint the nonce in your `registerBegin` (or equivalent) method and persist it in stable memory keyed by the user's principal and the action name. Mark it consumed in `registerFinish` so a bundle cannot be replayed. Use a short freshness window so abandoned attempts age out. +::: + ## Local development Start the local network and deploy. With `ii: true` in your `icp.yaml`, icp-cli deploys a local Internet Identity canister automatically: @@ -291,13 +515,12 @@ To keep principals consistent across your own custom domains, configure **altern ] ``` -3. **On the alternative origin (B):** Set the `derivationOrigin` in your login call to point back to the primary origin: +3. **On the alternative origin (B):** Set the `derivationOrigin` on the `AuthClient` constructor to point back to the primary origin: ```javascript - authClient.login({ + const authClient = new AuthClient({ identityProvider: "https://id.ai", derivationOrigin: "https://xxxxx.icp0.io", // primary origin A - onSuccess: () => { /* ... */ }, }); ``` @@ -309,11 +532,13 @@ For full details, see the [Internet Identity specification](../../reference/inte - **Using the wrong II URL per environment**: local development must point to `http://id.ai.localhost:8000`, mainnet to `https://id.ai`. Use the `getIdentityProviderUrl` helper (shown above) to switch based on hostname. - **`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. -- **Missing `onSuccess`/`onError` callbacks**: `authClient.login()` requires both. Without them, login failures are silently swallowed. +- **Not awaiting `signIn()` or skipping the `try`/`catch`**: `authClient.signIn()` returns a promise that rejects when the user closes the popup or authentication fails. Without `await` and a `catch`, those failures are silently swallowed. - **Delegation expiry too long**: the maximum is 30 days. Values above this are silently clamped, causing confusing session behavior. Use 8 hours for typical apps. - **Passing principal as a string argument**: the backend reads the caller automatically from the IC protocol. Do not pass it as a function parameter. - **Using `shouldFetchRootKey: true` in browser code**: pass `rootKey: canisterEnv?.IC_ROOT_KEY` from `safeGetCanisterEnv()` instead. `shouldFetchRootKey: true` fetches the root key from the replica at runtime, which lets a man-in-the-middle substitute a fake key on mainnet. For Node.js scripts targeting a local replica only, `await agent.fetchRootKey()` is acceptable: but never on mainnet. - **Creating multiple `AuthClient` instances**: create one on page load and reuse it. Multiple instances cause race conditions with session storage. +- **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` matches an action it actually started. +- **Reading attribute data without verifying the signer**: `msg_caller_info_data` (`Prim.callerInfoData` in Motoko) returns whatever bundle the caller provided. The IC system checks the signature, not the identity of the signer. If you skip the `msg_caller_info_signer` check (or compare it against the wrong principal), any canister can mint its own bundle and your method will read attacker-controlled values. Verify the signer matches `rdmx6-jaaaa-aaaaa-aaadq-cai` (Internet Identity) before trusting the bundle. ## Next steps @@ -325,4 +550,4 @@ For full details, see the [Internet Identity specification](../../reference/inte {/* TODO: Add Unity native app integration via deep links: see portal native-apps/unity_ii_* */} -{/* Upstream: informed by 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 */} +{/* Upstream: informed by 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-auth (README.md, src/client/auth-client.ts); dfinity/cdk-rs (ic-cdk/src/api.rs msg_caller_info_data/signer); caffeinelabs/motoko (src/prelude/prim.mo callerInfoData/Signer, test/run-drun/caller-info/caller-info.mo) */}