From 4b2c8f6b26c24bb192c8c6cfcb40d3ea6274a831 Mon Sep 17 00:00:00 2001 From: Marco Walz Date: Sat, 21 Mar 2026 01:58:47 +0100 Subject: [PATCH] fix(internet-identity): rewrite skill to fix agent hallucinations Closes #97. Supersedes #91. Mentions #95, #96, #99, #100. --- evaluations/internet-identity.json | 84 ++++++++ skills/internet-identity/SKILL.md | 329 ++++------------------------- 2 files changed, 130 insertions(+), 283 deletions(-) create mode 100644 evaluations/internet-identity.json diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json new file mode 100644 index 0000000..c7183c9 --- /dev/null +++ b/evaluations/internet-identity.json @@ -0,0 +1,84 @@ +{ + "skill": "internet-identity", + "description": "Evaluation cases for the internet-identity skill. Tests whether agents produce correct II auth integration code, avoid top-level await, and handle local vs mainnet environments correctly.", + + "output_evals": [ + { + "name": "No top-level await in frontend code", + "prompt": "Show me just the JavaScript module that initializes AuthClient and checks if the user is already authenticated on page load. I'm using Vite with default settings. Keep it minimal — no backend code, no icp.yaml, no deploy steps.", + "expected_behaviors": [ + "All await calls are inside async functions — no bare top-level await at module scope", + "Does NOT recommend changing build.target to 'esnext' or 'es2022' in Vite config", + "Uses AuthClient from '@icp-sdk/auth/client' (submodule import, not root '@icp-sdk/auth')", + "Shows an initialization pattern like async function init() or equivalent lifecycle hook" + ] + }, + { + "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", + "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" + ] + }, + { + "name": "icp.yaml configuration for II", + "prompt": "What do I need to add to my icp.yaml to enable Internet Identity on the local network? Just show the relevant YAML snippet.", + "expected_behaviors": [ + "Shows icp.yaml with networks config including ii: true", + "Uses 'icp' tooling references, NOT 'dfx'", + "Does NOT suggest deploying II as a named canister — ii: true handles it automatically" + ] + }, + { + "name": "Authenticated actor creation", + "prompt": "I already have AuthClient set up and login working. Show me just the function that takes an identity and creates an authenticated actor for my backend canister. Keep it minimal — just the function, no AuthClient setup.", + "expected_behaviors": [ + "Gets the identity from authClient.getIdentity()", + "Creates an HttpAgent with the identity", + "Creates an actor using Actor.createActor with the agent", + "All await calls are inside async functions — no bare top-level await", + "Uses rootKey from ic_env cookie (safeGetCanisterEnv) or host: window.location.origin — does NOT use shouldFetchRootKey or hardcoded host branching" + ] + }, + { + "name": "Debugging anonymous principal after login", + "prompt": "After II login, my backend says 'Anonymous principal not allowed' and the console shows principal 2vxsx-fae. What does that mean and what should I check? Keep it brief.", + "expected_behaviors": [ + "Identifies 2vxsx-fae as the anonymous principal", + "Explains that authentication silently failed or the identity was not properly extracted", + "Suggests checking that authClient.getIdentity() is called after login and passed to HttpAgent" + ] + }, + { + "name": "Adversarial: build target suggestion", + "prompt": "I'm getting 'Top-level await is not available in the configured target environment' when building my Vite frontend with II auth. How do I fix this?", + "expected_behaviors": [ + "Does NOT recommend build.target: 'esnext' as the fix", + "Recommends wrapping await calls inside async functions", + "Explains that top-level await requires ES2022+ and Vite defaults to es2020", + "Shows a concrete example of wrapping the auth initialization in an async function" + ] + } + ], + + "trigger_evals": { + "description": "Queries to test whether the skill activates correctly.", + "should_trigger": [ + "Add login to my ICP frontend app", + "How do I integrate Internet Identity?", + "I need passkey authentication for my dapp", + "Set up sign-in with Internet Identity", + "How does delegation work with II?", + "Add auth to my canister frontend" + ], + "should_not_trigger": [ + "Connect a wallet to my dapp", + "How do I deploy my canister?", + "Implement ICRC-1 token transfers", + "Set up inter-canister calls", + "How does stable memory work?", + "Add a frontend to my canister" + ] + } +} diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 7b46153..8ab41dc 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -16,9 +16,7 @@ Internet Identity (II) is the Internet Computer's native authentication system. ## Prerequisites -- Frontend: `@icp-sdk/auth`, `@icp-sdk/core` -- For Motoko: `mops` package manager, `core = "2.0.0"` in mops.toml -- For Rust: `ic-cdk >= 0.19` +- `@icp-sdk/auth` (>= 5.0.0), `@icp-sdk/core` (>= 5.0.0) ## Canister IDs @@ -28,50 +26,32 @@ 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.** Local development must point to `http://.localhost:8000` (this canister ID may be different from mainnet). Mainnet must use `https://id.ai`. Hardcoding one breaks the other. The local II canister ID is assigned dynamically when you run `icp deploy internet_identity` -- read it from the `ic_env` cookie using `safeGetCanisterEnv` from `@icp-sdk/core/agent/canister-env` (see the icp-cli skill for details on canister environment variables). +1. **Using the wrong II URL for the environment.** Local development must point to `http://rdmx6-jaaaa-aaaaa-aaadq-cai.localhost:8000`. Mainnet must use `https://id.ai`. Internet Identity has a well-known canister ID (`rdmx6-jaaaa-aaaaa-aaadq-cai`) that is the same on mainnet and local replicas -- hardcode it rather than doing a dynamic lookup. -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. +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. -4. **Not handling auth callbacks.** The `authClient.login()` call requires `onSuccess` and `onError` callbacks. Without them, login failures are silently swallowed. +3. **Not handling auth callbacks.** The `authClient.login()` call requires `onSuccess` and `onError` callbacks. Without them, login failures are silently swallowed. -5. **Defensive practice: bind `msg_caller()` before `.await` in Rust.** The current ic-cdk executor preserves the caller across `.await` points, but capturing it early guards against future executor changes. Always bind `let caller = ic_cdk::api::msg_caller();` at the top of async update functions. +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. -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. Use `shared(msg) { msg.caller }` in Motoko or `ic_cdk::api::msg_caller()` in Rust. +5. **Getting `2vxsx-fae` as the principal after login.** That is the anonymous principal -- it means authentication silently failed. Common causes: wrong `identityProvider` URL, missing `onSuccess` callback, or not extracting the identity from `authClient.getIdentity()` after login. -7. **Not calling `agent.fetchRootKey()` in local development.** Without this, certificate verification fails on localhost. Never call it in production -- it's a security risk on mainnet. - -8. **Storing auth state in `thread_local!` without stable storage (Rust)** -- `thread_local! { RefCell }` is heap memory, wiped on every canister upgrade. Use `StableCell` from `ic-stable-structures` for any state that must persist across upgrades, especially ownership/auth data. +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. ## Implementation ### icp.yaml Configuration -For local development, you just need to add the `ii` property to the local network to enable Internet Identity. - -Here's an example icp.yaml configuration (assume that the `frontend` canister is generated using `icp new` using the `static-website` template): +Add `ii: true` to the local network in your `icp.yaml` to enable Internet Identity locally: ```yaml -# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/icp-yaml-schema.json - -canisters: - - name: frontend - recipe: - type: "@dfinity/asset-canister@v2.1.0" - configuration: - build: - # Install the dependencies - # Eventually you might want to use `npm ci` to lock your dependencies - - npm install - - npm run build - dir: dist - networks: - name: local mode: managed ii: true ``` - +This tells icp-cli to pull and run the II canister automatically when you deploy. 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. ### Frontend: Vanilla JavaScript/TypeScript Login Flow @@ -80,30 +60,28 @@ This is framework-agnostic. Adapt the DOM manipulation to your framework. ```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"; + +// Module-scoped so login/logout/createAuthenticatedActor can access it. +let authClient; -// 1. Create the auth client -const authClient = await AuthClient.create(); +// Read the ic_env cookie (set by the asset canister or Vite dev server). +// Contains the root key and canister IDs — works in both local and production. +const canisterEnv = safeGetCanisterEnv(); -// 2. Determine II URL based on environment -// The local II canister gets a different canister ID each time you deploy it. -// Pass it via an environment variable at build time (e.g., Vite: import.meta.env.VITE_II_CANISTER_ID). +// Determine II URL based on environment. +// Internet Identity has a well-known canister ID (rdmx6-jaaaa-aaaaa-aaadq-cai) that is +// the same on mainnet and local replicas (icp-cli pulls the mainnet II wasm). function getIdentityProviderUrl() { const host = window.location.hostname; const isLocal = host === "localhost" || host === "127.0.0.1" || host.endsWith(".localhost"); if (isLocal) { - // icp-cli injects canister IDs via the ic_env cookie (set by the asset canister). - // Read it at runtime using @icp-sdk/core: - // import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; - // const canisterEnv = safeGetCanisterEnv(); - // const iiCanisterId = canisterEnv?.["PUBLIC_CANISTER_ID:internet_identity"]; - const iiCanisterId = canisterEnv?.["PUBLIC_CANISTER_ID:internet_identity"] - ?? "be2us-64aaa-aaaaa-qaabq-cai"; // fallback -- replace with your actual local II canister ID - return `http://${iiCanisterId}.localhost:8000`; + return "http://rdmx6-jaaaa-aaaaa-aaadq-cai.localhost:8000"; } return "https://id.ai"; } -// 3. Login +// Login async function login() { return new Promise((resolve, reject) => { authClient.login({ @@ -123,256 +101,41 @@ async function login() { }); } -// 4. Create an authenticated agent and actor -async function createAuthenticatedActor(identity, canisterId, idlFactory) { - const isLocal = window.location.hostname === "localhost" || - window.location.hostname === "127.0.0.1" || - window.location.hostname.endsWith(".localhost"); - - const agent = await HttpAgent.create({ - identity, - host: isLocal ? "http://localhost:8000" : "https://icp-api.io", - ...(isLocal && { shouldFetchRootKey: true, verifyQuerySignatures: false }), - }); - - return Actor.createActor(idlFactory, { agent, canisterId }); -} - -// 5. Logout +// Logout async function logout() { await authClient.logout(); // Optionally reload or reset UI state } -// 6. Check if already authenticated (on page load) -const isAuthenticated = await authClient.isAuthenticated(); -if (isAuthenticated) { - const identity = authClient.getIdentity(); - // Restore session -- create actor, update UI -} -``` - -### Backend: Motoko - -Requires installing the Mops [Motoko package manager](https://cli.mops.one/): - -```sh -npm install -g ic-mops -``` - -```motoko -import Principal "mo:core/Principal"; -import Runtime "mo:core/Runtime"; - -persistent actor { - // Owner/admin principal - var owner : ?Principal = null; - - // Helper: reject anonymous callers - func requireAuth(caller : Principal) : () { - if (Principal.isAnonymous(caller)) { - Runtime.trap("Anonymous principal not allowed. Please authenticate."); - }; - }; - - // Initialize the first authenticated caller as owner - public shared (msg) func initOwner() : async Text { - requireAuth(msg.caller); - switch (owner) { - case (null) { - owner := ?msg.caller; - "Owner set to " # Principal.toText(msg.caller); - }; - case (?_existing) { - "Owner already initialized"; - }; - }; - }; - - // Owner-only endpoint example - public shared (msg) func adminAction() : async Text { - requireAuth(msg.caller); - switch (owner) { - case (?o) { - if (o != msg.caller) { - Runtime.trap("Only the owner can call this function."); - }; - "Admin action performed"; - }; - case (null) { - Runtime.trap("Owner not set. Call initOwner first."); - }; - }; - }; - - // Public query: anyone can call, but returns different data for authenticated users - public shared query (msg) func whoAmI() : async Text { - if (Principal.isAnonymous(msg.caller)) { - "You are not authenticated (anonymous)"; - } else { - "Your principal: " # Principal.toText(msg.caller); - }; - }; - - // Getting caller principal in shared functions - // ALWAYS use `shared (msg)` or `shared ({ caller })` syntax: - public shared ({ caller }) func protectedEndpoint(data : Text) : async Bool { - requireAuth(caller); - // Use `caller` for authorization checks - true; - }; -}; -``` - -### Backend: Rust - -```toml -# Cargo.toml -[package] -name = "ii_backend" -version = "0.1.0" -edition = "2021" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -ic-cdk = "0.19" -candid = "0.10" -serde = { version = "1", features = ["derive"] } -ic-stable-structures = "0.7" -``` - -```rust -use candid::Principal; -use ic_cdk::{query, update}; -use ic_stable_structures::{DefaultMemoryImpl, StableCell}; -use std::cell::RefCell; - -thread_local! { - // Principal::anonymous() is used as the "not set" sentinel. - // Option does not implement Storable, so we store Principal directly. - static OWNER: RefCell> = RefCell::new( - StableCell::init(DefaultMemoryImpl::default(), Principal::anonymous()) - ); -} - -/// Reject anonymous principal. Call this at the top of every protected endpoint. -fn require_auth() -> Principal { - let caller = ic_cdk::api::msg_caller(); - if caller == Principal::anonymous() { - ic_cdk::trap("Anonymous principal not allowed. Please authenticate."); - } - caller -} - -#[update] -fn init_owner() -> String { - // Defensive: capture caller before any .await calls. - let caller = require_auth(); - - OWNER.with(|owner| { - let mut cell = owner.borrow_mut(); - let current = *cell.get(); - if current == Principal::anonymous() { - cell.set(caller); - format!("Owner set to {}", caller) - } else { - "Owner already initialized".to_string() - } - }) -} - -#[update] -fn admin_action() -> String { - let caller = require_auth(); - - OWNER.with(|owner| { - let cell = owner.borrow(); - let current = *cell.get(); - if current == Principal::anonymous() { - ic_cdk::trap("Owner not set. Call init_owner first."); - } else if current == caller { - "Admin action performed".to_string() - } else { - ic_cdk::trap("Only the owner can call this function."); - } - }) -} +// Create an authenticated agent and actor. +// Uses rootKey from the ic_env cookie — no shouldFetchRootKey or environment branching needed. +async function createAuthenticatedActor(identity, canisterId, idlFactory) { + const agent = await HttpAgent.create({ + identity, + host: window.location.origin, + rootKey: canisterEnv?.IC_ROOT_KEY, + }); -#[query] -fn who_am_i() -> String { - let caller = ic_cdk::api::msg_caller(); - if caller == Principal::anonymous() { - "You are not authenticated (anonymous)".to_string() - } else { - format!("Your principal: {}", caller) - } + return Actor.createActor(idlFactory, { agent, canisterId }); } -// For async functions, capture caller before await as defensive practice: -#[update] -async fn protected_async_action() -> String { - let caller = require_auth(); // Capture before any await - let _result = some_async_operation().await; - format!("Action completed by {}", caller) +// Initialization — wraps async setup in a function so this code works with +// any bundler target (Vite defaults to es2020 which lacks top-level await). +async function init() { + authClient = await AuthClient.create(); + + // Check if already authenticated (on page load) + const isAuthenticated = await authClient.isAuthenticated(); + if (isAuthenticated) { + const identity = authClient.getIdentity(); + const actor = await createAuthenticatedActor(identity, canisterId, idlFactory); + // Use actor to call backend methods + } } -``` - -**Rust defensive practice:** Bind `let caller = ic_cdk::api::msg_caller();` at the top of async update functions. The current ic-cdk executor preserves caller across `.await` points via protected tasks, but capturing it early guards against future executor changes. -## Deploy & Test - -### Local Deployment - -```bash -# Start the local network -icp network start -d - -# Deploy II canister and your backend -icp deploy internet_identity -icp deploy backend - -# Verify II is running -icp canister status internet_identity -``` - -### Mainnet Deployment - -```bash -# II is already on mainnet -- only deploy your canisters -icp deploy -e ic backend +init(); ``` -## Verify It Works +### Backend: Access Control -```bash -# 1. Check II canister is running -icp canister status internet_identity -# Expected: Status: Running - -# 2. Test anonymous rejection from CLI -icp canister call backend adminAction -# Expected: Error containing "Anonymous principal not allowed" - -# 3. Test whoAmI as anonymous -icp canister call backend whoAmI -# Expected: ("You are not authenticated (anonymous)") - -# 4. Test whoAmI as authenticated identity -icp canister call backend whoAmI -# Expected: ("Your principal: ") -# Note: icp CLI calls use the current identity, not anonymous, -# unless you explicitly use --identity anonymous - -# 5. Test with explicit anonymous identity -icp identity use anonymous -icp canister call backend adminAction -# Expected: Error containing "Anonymous principal not allowed" -icp identity use default # Switch back - -# 6. Open II in browser for local dev -# Visit: http://.localhost:8000 -``` -# You should see the Internet Identity login page -``` +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.