From a81fc0516b278b227da8cf3cef81b9bc20033db6 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 27 May 2026 16:49:13 -0400 Subject: [PATCH 1/3] feat: Update internet-identity skill for @icp-sdk/auth 7.0.0 --- skills/internet-identity/SKILL.md | 83 +++++++++++++++++-------------- 1 file changed, 45 insertions(+), 38 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 5d71196..97c7ce4 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -2,7 +2,7 @@ name: internet-identity description: "Integrate Internet Identity authentication. Covers passkey and OpenID sign-in flows, delegation handling, and principal-per-app isolation. Use when adding sign-in, login, auth, passkeys, or Internet Identity to a frontend or canister. Do NOT use for wallet integration or ICRC signer flows — use wallet-integration instead." license: Apache-2.0 -compatibility: "icp-cli >= 0.2.2, Node.js >= 22" +compatibility: "icp-cli >= 0.2.4, Node.js >= 22" metadata: title: Internet Identity category: Auth @@ -12,7 +12,7 @@ metadata: ## What This Is -Internet Identity (II) is the Internet Computer's native authentication system. Users authenticate into II-powered apps either with passkeys stored in their devices or thorugh OpenID accounts (e.g., Google, Apple, Microsoft) -- no usernames or passwords required. Each user gets a unique principal per app, preventing cross-app tracking. +Internet Identity (II) is the Internet Computer's native authentication system. Users authenticate into II-powered apps either with passkeys stored in their devices or through OpenID accounts (e.g., Google, Apple, Microsoft) -- no usernames or passwords required. Each user gets a unique principal per app, preventing cross-app tracking. ## Prerequisites @@ -28,42 +28,44 @@ Internet Identity (II) is the Internet Computer's native authentication system. ## Mistakes That Break Your Build -1. **Using the wrong II URL for the environment.** The identity provider URL must point to the **frontend** canister (`uqzsh-gqaaa-aaaaq-qaada-cai`), not the backend. Local development should use `http://id.ai.localhost:8000`. Mainnet must use `https://id.ai` (which resolves to the frontend canister). Both canister IDs are well-known and identical on mainnet and local replicas -- hardcode them rather than doing a dynamic lookup. +1. **Using the wrong II URL for the environment.** The identity provider URL must point to the **frontend** canister (`uqzsh-gqaaa-aaaaq-qaada-cai`), not the backend. Mainnet uses `https://id.ai/authorize`. Local-only II (when `ii: true` is set in `icp.yaml`) uses `http://id.ai.localhost:8000/authorize`. Both canister IDs are well-known and identical on mainnet and local replicas — hardcode them rather than doing a dynamic lookup. -2. **Setting delegation expiry too long.** Maximum delegation expiry is 30 days (2_592_000_000_000_000 nanoseconds). Longer values are silently clamped, which causes confusing session behavior. Use 8 hours for normal apps, 30 days maximum for "remember me" flows. +2. **Forgetting `/authorize` in the `identityProvider` URL.** In `@icp-sdk/auth` 7.x the URL is used verbatim; the client does **not** append `/authorize` for you (it did in 5.x). Passing `https://id.ai` opens the II home page in the popup and never returns a delegation — the login button appears to do nothing. Always include the `/authorize` path. -3. **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. +3. **Setting delegation expiry too long.** Maximum delegation expiry is 30 days (2_592_000_000_000_000 nanoseconds). Longer values are silently clamped, which causes confusing session behavior. Use 8 hours for normal apps, 30 days maximum for "remember me" flows. -4. **Using `shouldFetchRootKey` or `fetchRootKey()` instead of the `ic_env` cookie.** The `ic_env` cookie (set by the asset canister or the Vite dev server) already contains the root key as `IC_ROOT_KEY`. Pass it via the `rootKey` option to `HttpAgent.create()` — this works in both local and production environments without environment branching. See the icp-cli skill's `references/binding-generation.md` for the pattern. Never call `fetchRootKey()` — it fetches the root key from the replica at runtime, which lets a man-in-the-middle substitute a fake key on mainnet. +4. **Not awaiting `signIn()` or skipping the `try`/`catch`.** `authClient.signIn()` returns a promise that rejects when the user closes the popup or authentication fails. Without `await` and a `catch`, those failures are silently swallowed. -5. **Getting `2vxsx-fae` as the principal after sign-in.** That is the anonymous principal -- it means authentication silently failed. Common causes: wrong `identityProvider` URL passed to the `AuthClient` constructor, an unhandled rejection from `signIn()`, or reading `getIdentity()` before `signIn()` resolved. +5. **Using `shouldFetchRootKey` or `fetchRootKey()` instead of the `ic_env` cookie.** The `ic_env` cookie (set by the asset canister or the Vite dev server) already contains the root key as `IC_ROOT_KEY`. Pass it via the `rootKey` option to `HttpAgent.create()` — this works in both local and production environments without environment branching. See the icp-cli skill's `references/binding-generation.md` for the pattern. Never call `fetchRootKey()` — it fetches the root key from the replica at runtime, which lets a man-in-the-middle substitute a fake key on mainnet. -6. **Passing principal as string to backend.** The `AuthClient` gives you an `Identity` object. Backend canister methods receive the caller principal automatically via the IC protocol -- you do not pass it as a function argument. The caller principal is available on the backend via `shared(msg) { msg.caller }` in Motoko or `ic_cdk::api::msg_caller()` in Rust. For backend access control patterns, see the **canister-security** skill. +6. **Getting `2vxsx-fae` as the principal after sign-in.** That is the anonymous principal -- it means authentication silently failed. Common causes: wrong `identityProvider` URL passed to the `AuthClient` constructor (especially missing `/authorize`), an unhandled rejection from `signIn()`, or reading `getIdentity()` before `signIn()` resolved. -7. **Adding `derivationOrigin` or `ii-alternative-origins` to handle `icp0.io` vs `ic0.app`.** Internet Identity automatically rewrites `icp0.io` to `ic0.app` during delegation, so both domains produce the same principal. Do not add `derivationOrigin` or `ii-alternative-origins` configuration to handle this — it will break authentication. If a user reports getting a different principal, the cause is almost certainly a different passkey or device, not the domain. +7. **Passing principal as string to backend.** The `AuthClient` gives you an `Identity` object. Backend canister methods receive the caller principal automatically via the IC protocol -- you do not pass it as a function argument. The caller principal is available on the backend via `shared(msg) { msg.caller }` in Motoko or `ic_cdk::api::msg_caller()` in Rust. For backend access control patterns, see the **canister-security** skill. -8. **Generating the attribute nonce on the frontend.** The nonce passed to `requestAttributes` MUST come from a backend canister call. A frontend-generated nonce defeats replay protection: the canister cannot verify that the bundle's `implicit:nonce` matches an action it actually started. Have the backend mint and return the nonce from a `registerBegin`-style method, and check it against the bundle's implicit fields when the user calls the protected method. +8. **Adding `derivationOrigin` or `ii-alternative-origins` to handle `icp0.io` vs `ic0.app`.** Internet Identity automatically rewrites `icp0.io` to `ic0.app` during delegation, so both domains produce the same principal. Do not add `derivationOrigin` or `ii-alternative-origins` configuration to handle this — it will break authentication. If a user reports getting a different principal, the cause is almost certainly a different passkey or device, not the domain. -9. **Reading attribute data without verifying the signer.** The IC verifies the signature, not the identity of the signer — any canister can produce a valid bundle. The trusted signer is `rdmx6-jaaaa-aaaaa-aaadq-cai` (Internet Identity). The check looks different per language: +9. **Generating the attribute nonce on the frontend.** The nonce passed to `requestAttributes` MUST come from a backend canister call. A frontend-generated nonce defeats replay protection: the canister cannot verify that the bundle's `implicit:nonce` matches an action it actually started. Have the backend mint and return the nonce from a `registerBegin`-style method, and check it against the bundle's implicit fields when the user calls the protected method. + +10. **Reading attribute data without verifying the signer.** The IC verifies the signature, not the identity of the signer — any canister can produce a valid bundle. The trusted signer is `rdmx6-jaaaa-aaaaa-aaadq-cai` (Internet Identity). The check looks different per language: - **Motoko**: prefer `mo:core/CallerAttributes`. `CallerAttributes.getAttributes()` returns `?Blob` and traps if the signer isn't listed in the canister's `trusted_attribute_signers` env var. Configure that env var in `icp.yaml` (see "Backend: Reading Identity Attributes"). Don't roll your own check on top of `Prim.callerInfoSigner` unless you have a reason to. - **Rust**: there is no CDK wrapper yet. Always check `msg_caller_info_signer()` against the trusted issuer principal before reading `msg_caller_info_data()`. Skipping this lets an attacker canister forge attributes like `email = "admin@you.com"`. -10. **Substituting `{tid}` in the Microsoft scoped-key prefix.** The `microsoft` OpenID provider URL is the literal string `https://login.microsoftonline.com/{tid}/v2.0` — `{tid}` is part of the URL, not a tenant-ID placeholder you fill in. Bundle keys returned by `scopedKeys({ openIdProvider: 'microsoft' })` look like `openid:https://login.microsoftonline.com/{tid}/v2.0:email` exactly, and the backend must look up that literal key. Replacing `{tid}` with a tenant GUID will silently miss every attribute lookup. +11. **Substituting `{tid}` in the Microsoft scoped-key prefix.** The `microsoft` OpenID provider URL is the literal string `https://login.microsoftonline.com/{tid}/v2.0` — `{tid}` is part of the URL, not a tenant-ID placeholder you fill in. Bundle keys returned by `scopedKeys({ openIdProvider: 'microsoft' })` look like `openid:https://login.microsoftonline.com/{tid}/v2.0:email` exactly, and the backend must look up that literal key. Replacing `{tid}` with a tenant GUID will silently miss every attribute lookup. -11. **Treating `email` as verified.** `email` and `verified_email` are distinct keys. +12. **Treating `email` as verified.** `email` and `verified_email` are distinct keys. - `email` is the raw email string from the user's II-linked account. II does not check it. Treat it as user-supplied input. - `verified_email` is the same email as `email`, but only present when the source OpenID provider (e.g., Google) marked it as verified and II surfaced that signal through. Use `verified_email` for any access gating (admin allowlists, capability checks). Use `email` only for soft uses like contact info or mailing lists. Request both for fallback behaviour: both are returned with the same value when the source provider marked the email as verified, only `email` when it didn't. ## Using II during local development -You have two choices for local development: -* Starting with `icp-cli >= 0.2.4` the local network can validate signatures from https://id.ai, so you can use mainnet identities with the local network. -* You can configure your local network to deploy internet identity to the local network which makes it accessible at http://id.ai.localhost:8000 by default. +**Default: use mainnet II from your local network.** Starting with `icp-cli >= 0.2.4`, the local network (pocket-ic, launched by `icp-cli-network-launcher`) is configured to trust the mainnet subnet's BLS signatures. Delegations signed by `https://id.ai` are accepted by your local replica, so both the sign-in flow *and* authenticated calls to a locally-deployed backend just work — no extra config in `icp.yaml`, no local II canister to manage, and the UI is the real one your users will see. + +Point your frontend at `https://id.ai/authorize` unconditionally and you're done. -### icp.yaml Configuration +### Fallback: deploy II locally -Add `ii: true` to the local network in your `icp.yaml` to enable Internet Identity locally: +Only use this if you need fully-offline dev or want to test against a specific II build. Add `ii: true` to the local network in your `icp.yaml`: ```yaml networks: @@ -72,9 +74,7 @@ networks: ii: true ``` -This deploys the II canisters automatically when the local network is started. By default, the II frontend will be available at http://id.ai.localhost:8000 -No canister entry needed — II is not part of your project's canisters. -For the full `icp.yaml` canister configuration, see the **icp-cli** and **asset-canister** skills. +This deploys the II canisters automatically when the local network is started. The II frontend will be available at `http://id.ai.localhost:8000`, and the `identityProvider` URL becomes `http://id.ai.localhost:8000/authorize`. No canister entry is needed in your project — II is not part of your project's canisters. For the full `icp.yaml` canister configuration, see the **icp-cli** and **asset-canister** skills. ### Frontend: Vanilla JavaScript/TypeScript Sign-In Flow @@ -89,24 +89,16 @@ import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; // Contains the root key and canister IDs — works in both local and production. const canisterEnv = safeGetCanisterEnv(); -// Determine II URL based on environment. -// The identity provider URL points to the frontend canister which gets mapped to http://id.ai.localhost, -// not the backend (rdmx6-jaaaa-aaaaa-aaadq-cai). Both are well-known IDs, identical on -// mainnet and local replicas. -function getIdentityProviderUrl() { - const host = window.location.hostname; - const isLocal = host === "localhost" || host === "127.0.0.1" || host.endsWith(".localhost"); - if (isLocal) { - return "http://id.ai.localhost:8000"; - } - return "https://id.ai"; -} - // Construct once — identityProvider (and optionally derivationOrigin or // openIdProvider for one-click sign-in: 'google' | 'apple' | 'microsoft') -// are configured at construction time, not per sign-in. +// are configured at construction time, not per sign-in. Always include the +// `/authorize` path — the client uses the URL verbatim in 7.x. +// +// Use mainnet II even from local dev: pocket-ic (icp-cli >= 0.2.4) trusts +// mainnet subnet signatures. Override to http://id.ai.localhost:8000/authorize +// only if you have `ii: true` in icp.yaml and want fully-offline dev. const authClient = new AuthClient({ - identityProvider: getIdentityProviderUrl(), + identityProvider: "https://id.ai/authorize", }); // Sign in: signIn() returns the new Identity directly and rejects if the user @@ -195,7 +187,7 @@ import { Principal } from "@icp-sdk/core/principal"; async function registerWithEmail(authClient, backendCanisterId, backendIdl, appCanisterId, appIdl) { // 1. The backend issues a nonce scoped to this registration. - // Frontend-generated nonces defeat replay protection — see Mistake #8. + // Frontend-generated nonces defeat replay protection — see Mistake #9. const anonymousAgent = await HttpAgent.create(); const backend = Actor.createActor(backendIdl, { agent: anonymousAgent, @@ -248,7 +240,7 @@ import { HttpAgent, Actor } from "@icp-sdk/core/agent"; import { Principal } from "@icp-sdk/core/principal"; const authClient = new AuthClient({ - identityProvider: getIdentityProviderUrl(), + identityProvider: "https://id.ai/authorize", openIdProvider: "google", }); @@ -493,3 +485,18 @@ fn register_finish() -> String { ### Backend: Access Control Backend access control (anonymous principal rejection, role guards, caller binding in async functions) is not II-specific — the same patterns apply regardless of authentication method. See the **canister-security** skill for complete Motoko and Rust examples. + +## 5.x API notes + +If you are pinned to `@icp-sdk/auth` 5.x, the same flow uses a different (callback-based) API: + +- `await AuthClient.create({...})` instead of `new AuthClient({...})` +- `identityProvider` passed per-call to `login({...})` rather than at construction +- `authClient.login({ onSuccess, onError })` — promise wrapper required around it +- `authClient.logout()` instead of `authClient.signOut()` +- `await authClient.isAuthenticated()` (async) instead of sync +- `authClient.getIdentity()` (sync) instead of async +- 5.x auto-appends `/authorize` to the `identityProvider` URL, so you can pass just `https://id.ai`. In 7.x the path is required. +- No `requestAttributes` / `AttributesIdentity` support — the identity-attributes flow above requires 7.x. + +Upgrade to 7.x when you can — the promise-based API is harder to misuse and the callback variant has been removed. From 8c0eca087cbbac60a176206a9bf14573c1bad8ba Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 27 May 2026 19:45:44 -0400 Subject: [PATCH 2/3] use correct name for the local canister --- evaluations/internet-identity.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json index 20d64ab..65b4daf 100644 --- a/evaluations/internet-identity.json +++ b/evaluations/internet-identity.json @@ -17,7 +17,7 @@ "name": "Local II URL", "prompt": "My Internet Identity login works on mainnet but fails locally — the popup never opens. What URL should I use for the local identityProvider? Just the URL and a one-line explanation.", "expected_behaviors": [ - "Local URL uses the well-known II canister ID: http://rdmx6-jaaaa-aaaaa-aaadq-cai.localhost:8000", + "Local URL uses the well-known II canister ID: http://id.ai.localhost:8000", "Does NOT suggest dynamic lookup via ic_env cookie, environment variables, or dfx commands for the II canister ID — it is a well-known, hardcoded ID" ] }, From c17a334e3aa3e72c93b7f27fcb064f205533cda1 Mon Sep 17 00:00:00 2001 From: Raymond Khalife Date: Wed, 27 May 2026 19:54:08 -0400 Subject: [PATCH 3/3] add a test for local vs mainnet --- evaluations/internet-identity.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json index 65b4daf..035a7eb 100644 --- a/evaluations/internet-identity.json +++ b/evaluations/internet-identity.json @@ -109,6 +109,15 @@ "Advises NOT to add derivationOrigin or ii-alternative-origins for this", "Suggests the different principal is likely caused by a different passkey or device" ] + }, + { + "name": "local vs mainnet II usage", + "prompt": "When deploying to the local network, how do I integrate with II?", + "expected_behaviors": [ + "Suggest using mainnet's id.ai because the network launcher after icp-cli 0.2.4 supports verifying mainnet signatures", + "Explains that a local instance of internet identity can be deployed by setting `ii: true` in icp.yaml", + "Explains that the local instance of internet identity is accessible at http://id.ai.localhost:8000" + ] } ],