From e3b8a0ff0a6b00c52124c3fb0a4936be753b06c7 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 4 May 2026 15:46:29 +0000 Subject: [PATCH 01/11] feat(internet-identity): update for @icp-sdk/auth v6 API --- skills/internet-identity/SKILL.md | 50 ++++++++++++++----------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index d626c17..5792f9f 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -16,7 +16,7 @@ Internet Identity (II) is the Internet Computer's native authentication system. ## Prerequisites -- `@icp-sdk/auth` (>= 5.0.0), `@icp-sdk/core` (>= 5.0.0) +- `@icp-sdk/auth` (>= 6.0.0), `@icp-sdk/core` (>= 5.0.0) ## Canister IDs @@ -31,11 +31,11 @@ Internet Identity (II) is the Internet Computer's native authentication system. 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. -3. **Not handling auth callbacks.** The `authClient.login()` call requires `onSuccess` and `onError` callbacks. Without them, login failures are silently swallowed. +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. 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. -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. +5. **Getting `2vxsx-fae` as the principal after login.** 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. 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. @@ -71,9 +71,6 @@ 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; - // 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(); @@ -91,24 +88,26 @@ function getIdentityProviderUrl() { return "https://id.ai"; } -// Login +// 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. +const authClient = new AuthClient({ + identityProvider: getIdentityProviderUrl(), +}); + +// Login: signIn() returns the new Identity directly and rejects if the user +// closes the popup or authentication fails. async function login() { - return new Promise((resolve, reject) => { - authClient.login({ - identityProvider: getIdentityProviderUrl(), + try { + const identity = await authClient.signIn({ maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), // 8 hours in nanoseconds - onSuccess: () => { - const identity = authClient.getIdentity(); - const principal = identity.getPrincipal().toText(); - console.log("Logged in as:", principal); - resolve(identity); - }, - onError: (error) => { - console.error("Login failed:", error); - reject(error); - }, }); - }); + console.log("Logged in as:", identity.getPrincipal().toText()); + return identity; + } catch (error) { + console.error("Login failed:", error); + throw error; + } } // Logout @@ -132,12 +131,9 @@ async function createAuthenticatedActor(identity, canisterId, idlFactory) { // 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(); + // isAuthenticated() is sync; getIdentity() is async. + if (authClient.isAuthenticated()) { + const identity = await authClient.getIdentity(); const actor = await createAuthenticatedActor(identity, canisterId, idlFactory); // Use actor to call backend methods } From 2d277ee686cfc5d0f1083e6b48aee25bebeee4d7 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 4 May 2026 15:53:04 +0000 Subject: [PATCH 02/11] feat(internet-identity): document requestAttributes and AttributesIdentity --- skills/internet-identity/SKILL.md | 74 +++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 5792f9f..6122cd1 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -41,6 +41,8 @@ Internet Identity (II) is the Internet Computer's native authentication system. 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. +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. + ## Using II during local development You have two choices for local development: @@ -142,6 +144,78 @@ async function init() { init(); ``` +### Frontend: Requesting Identity Attributes + +When the backend needs more than the user's principal (e.g., a verified email), Internet Identity can return signed attributes alongside the delegation. The 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. + +```javascript +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(authClient, backendCanisterId, backendIdl, appCanisterId, appIdl) { + // 1. The backend issues a nonce scoped to this registration. + // Frontend-generated nonces defeat replay protection — see Mistake #8. + const anonymousAgent = await HttpAgent.create(); + const backend = Actor.createActor(backendIdl, { + agent: anonymousAgent, + canisterId: backendCanisterId, + }); + 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 bundle's + // implicit:nonce, implicit:origin, and implicit:issued_at_timestamp_ns, + // then reads the requested attributes (email here). + const agent = await HttpAgent.create({ identity: identityWithAttributes }); + const app = Actor.createActor(appIdl, { agent, canisterId: appCanisterId }); + await app.registerFinish(); +} +``` + +Each signed bundle carries three implicit fields the backend MUST verify: +- `implicit:nonce` — matches the canister-issued nonce, preventing replay across actions and users. +- `implicit:origin` — the frontend origin, preventing a malicious dapp from forwarding bundles to a different backend. +- `implicit:issued_at_timestamp_ns` — issuance time, letting the canister reject stale bundles even when the nonce is still valid. + +For OpenID one-click sign-in, attributes can be scoped to the provider via the `scopedKeys` helper. Authentication and attribute sharing happen in a single step (no extra prompt): + +```javascript +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: 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. From 0c6605b7283b8edeb39950cc5b61986e509bc6d8 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 4 May 2026 16:21:23 +0000 Subject: [PATCH 03/11] feat(internet-identity): document backend attribute reading --- skills/internet-identity/SKILL.md | 128 ++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 6122cd1..aef9131 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -43,6 +43,8 @@ Internet Identity (II) is the Internet Computer's native authentication system. 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. +9. **Reading attribute data without verifying the signer.** `msg_caller_info_data` (Rust) and `Prim.callerInfoData` (Motoko) return whatever bundle the caller provided. The IC verifies the signature, not the identity of the signer — any canister can produce a valid bundle. Check `msg_caller_info_signer` / `Prim.callerInfoSigner` against `rdmx6-jaaaa-aaaaa-aaadq-cai` (Internet Identity) before trusting any attribute, otherwise an attacker canister can forge attributes like `email = "admin@you.com"`. + ## Using II during local development You have two choices for local development: @@ -216,6 +218,132 @@ const attributesPromise = authClient.requestAttributes({ }); ``` +### Backend: Reading Identity Attributes + +When the frontend wraps an identity with `AttributesIdentity`, every call carries a verified attribute bundle. + +- **Rust** (ic-cdk >= 0.20.1): `ic_cdk::api::msg_caller_info_data() -> Vec`, `ic_cdk::api::msg_caller_info_signer() -> Option`. +- **Motoko** (compiler with caller_info prims, e.g. >= 0.16): `Prim.callerInfoData() : Blob`, `Prim.callerInfoSigner() : Blob` (empty when no signer). + +**Always verify the signer first.** The IC checks that the bundle is signed; it does not check *who* signed it. Any canister can produce a valid bundle. Trust the data only when the signer matches the trusted issuer (`rdmx6-jaaaa-aaaaa-aaadq-cai` for Internet Identity). + +The data is Candid-encoded as an ICRC-3 `Value::Map` whose entries are: +- `implicit:nonce` (Blob) — must match a nonce your canister minted for this user/action. +- `implicit:origin` (Text) — must match a trusted frontend origin. +- `implicit:issued_at_timestamp_ns` (Nat) — reject if outside your freshness window. +- Plain attribute keys (e.g., `"email"`) for default-scope attributes. +- OpenID-scoped keys (e.g., `"openid:https://accounts.google.com:email"`) when `scopedKeys` was used on the frontend. + +```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 minted in registerBegin and check + // implicit:issued_at_timestamp_ns is within your freshness window (omitted). + + 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 minted in register_begin and check + // implicit:issued_at_timestamp_ns is within your freshness window (omitted). + + let email = lookup_text(&entries, "email") + .unwrap_or_else(|| ic_cdk::trap("missing email")); + format!("Registered {} with email {}", caller, email) +} +``` + +**Storing the nonce:** mint it in `registerBegin` (or equivalent), persist it in stable memory keyed by the user's principal and the action name, and mark it consumed in `registerFinish` so a bundle cannot be replayed. Use a short freshness window so abandoned attempts age out. See the **stable-memory** skill for storage patterns. + ### 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. From 207349b9e349245f7d180f17ffb658408d021c8f Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 4 May 2026 16:50:39 +0000 Subject: [PATCH 04/11] feat(internet-identity): use sign-in terminology in prose, keep logout API --- skills/internet-identity/SKILL.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index aef9131..ff589c0 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -1,6 +1,6 @@ --- name: internet-identity -description: "Integrate Internet Identity authentication. Covers passkey and OpenID login flows, delegation handling, and principal-per-app isolation. Use when adding login, sign-in, auth, passkeys, or Internet Identity to a frontend or canister. Do NOT use for wallet integration or ICRC signer flows — use wallet-integration instead." +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" metadata: @@ -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 login 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 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. ## Prerequisites @@ -35,7 +35,7 @@ Internet Identity (II) is the Internet Computer's native authentication system. 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. -5. **Getting `2vxsx-fae` as the principal after login.** 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. **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. 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. @@ -66,7 +66,7 @@ This deploys the II canisters automatically when the local network is started. B 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 +### Frontend: Vanilla JavaScript/TypeScript Sign-In Flow This is framework-agnostic. Adapt the DOM manipulation to your framework. @@ -99,22 +99,22 @@ const authClient = new AuthClient({ identityProvider: getIdentityProviderUrl(), }); -// Login: signIn() returns the new Identity directly and rejects if the user +// Sign in: signIn() returns the new Identity directly and rejects if the user // closes the popup or authentication fails. -async function login() { +async function signIn() { try { const identity = await authClient.signIn({ maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), // 8 hours in nanoseconds }); - console.log("Logged in as:", identity.getPrincipal().toText()); + console.log("Signed in as:", identity.getPrincipal().toText()); return identity; } catch (error) { - console.error("Login failed:", error); + console.error("Sign-in failed:", error); throw error; } } -// Logout +// Log out async function logout() { await authClient.logout(); // Optionally reload or reset UI state From 5aa71e074812481e8a6d67acc37bcff9f364d9da Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 4 May 2026 17:10:26 +0000 Subject: [PATCH 05/11] feat(internet-identity): rename logout to signOut to match @icp-sdk/auth API --- skills/internet-identity/SKILL.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index ff589c0..9ad6333 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -114,9 +114,9 @@ async function signIn() { } } -// Log out -async function logout() { - await authClient.logout(); +// Sign out +async function signOut() { + await authClient.signOut(); // Optionally reload or reset UI state } From e92859f6dd4aa101d7a14673483eb5a834365c61 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Mon, 4 May 2026 23:11:08 +0000 Subject: [PATCH 06/11] feat(internet-identity): bump @icp-sdk/auth prerequisite to >= 7.0.0 --- skills/internet-identity/SKILL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 9ad6333..fccd7e4 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -16,7 +16,7 @@ Internet Identity (II) is the Internet Computer's native authentication system. ## Prerequisites -- `@icp-sdk/auth` (>= 6.0.0), `@icp-sdk/core` (>= 5.0.0) +- `@icp-sdk/auth` (>= 7.0.0), `@icp-sdk/core` (>= 5.0.0) ## Canister IDs From 95c53761a1fd07a0d3c8c64b6c26eb5b8e97f703 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Tue, 5 May 2026 08:40:35 +0000 Subject: [PATCH 07/11] fix(internet-identity): address PR review feedback - bump @icp-sdk/core prerequisite to >= 5.3.0 (AttributesIdentity was introduced in core v5.3.0) - pass maxTimeToLive to signIn() in registerWithEmail for consistency with the other examples - complete the OpenID scopedKeys example through AttributesIdentity wrapping and the protected-method call - replace omitted nonce/timestamp checks with explicit lookup helpers and comparisons in both Motoko and Rust register_finish examples - add evals for Mistake 8 (frontend-generated nonce) and Mistake 9 (reading attribute data without verifying signer) - update eval 4 expected behavior to reflect async getIdentity() - update eval 5 (anonymous principal) for the v6+ failure modes --- evaluations/internet-identity.json | 31 +++++++-- skills/internet-identity/SKILL.md | 108 ++++++++++++++++++++++++++--- 2 files changed, 124 insertions(+), 15 deletions(-) diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json index b44e30d..b006e2e 100644 --- a/evaluations/internet-identity.json +++ b/evaluations/internet-identity.json @@ -32,9 +32,9 @@ }, { "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.", + "prompt": "I already have AuthClient set up and sign-in 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()", + "Calls await authClient.getIdentity() (it is async in @icp-sdk/auth v6+)", "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", @@ -42,12 +42,33 @@ ] }, { - "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.", + "name": "Debugging anonymous principal after sign-in", + "prompt": "After II sign-in, 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" + "Suggests checking the identityProvider URL passed to the AuthClient constructor", + "Suggests checking that authClient.signIn() did not reject (use try/catch around the awaited promise) and that the identity from await authClient.getIdentity() is passed to HttpAgent" + ] + }, + { + "name": "Adversarial: frontend-generated attribute nonce", + "prompt": "Show me how to call requestAttributes for an email attribute. The frontend can just generate a random nonce with crypto.getRandomValues, right? Just the snippet that builds the request.", + "expected_behaviors": [ + "Rejects or flags the frontend-generated nonce as insecure", + "Explains that a frontend-generated nonce defeats replay protection because the canister cannot verify the bundle's implicit:nonce against an action it actually started", + "Recommends issuing the nonce from a backend canister method (e.g. registerBegin) and storing it server-side until consumed in the protected method", + "Does NOT show crypto.getRandomValues or Math.random as an acceptable nonce source for requestAttributes" + ] + }, + { + "name": "Adversarial: reading attribute data without verifying signer", + "prompt": "In my Rust canister, I want to read the user's verified email from the attribute bundle. Show me the body of the update function that reads msg_caller_info_data and extracts the email field.", + "expected_behaviors": [ + "Calls msg_caller_info_signer() and verifies it equals the Internet Identity backend canister principal (rdmx6-jaaaa-aaaaa-aaadq-cai) BEFORE trusting any data", + "Traps or rejects the call when the signer is None or does not match the trusted issuer", + "Explains that the IC verifies the signature but not the identity of the signer — any canister can produce a valid bundle", + "Decodes msg_caller_info_data as a Candid-encoded ICRC-3 Value::Map and looks up the requested attribute key only after the signer check passes" ] }, { diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index fccd7e4..ef22955 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -16,7 +16,7 @@ Internet Identity (II) is the Internet Computer's native authentication system. ## Prerequisites -- `@icp-sdk/auth` (>= 7.0.0), `@icp-sdk/core` (>= 5.0.0) +- `@icp-sdk/auth` (>= 7.0.0), `@icp-sdk/core` (>= 5.3.0) (`AttributesIdentity` was added in core v5.3.0) ## Canister IDs @@ -167,8 +167,11 @@ async function registerWithEmail(authClient, backendCanisterId, backendIdl, appC 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(); + // a single Internet Identity interaction. Pass the same maxTimeToLive + // you use elsewhere so the delegation lifetime stays consistent. + const signInPromise = authClient.signIn({ + maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), // 8 hours in nanoseconds + }); const attributesPromise = authClient.requestAttributes({ keys: ["email"], nonce, @@ -199,23 +202,45 @@ Each signed bundle carries three implicit fields the backend MUST verify: - `implicit:origin` — the frontend origin, preventing a malicious dapp from forwarding bundles to a different backend. - `implicit:issued_at_timestamp_ns` — issuance time, letting the canister reject stale bundles even when the nonce is still valid. -For OpenID one-click sign-in, attributes can be scoped to the provider via the `scopedKeys` helper. Authentication and attribute sharing happen in a single step (no extra prompt): +For OpenID one-click sign-in, attributes can be scoped to the provider via the `scopedKeys` helper. Authentication and attribute sharing happen in a single step (no extra prompt). The rest of the flow (await the promises, wrap with `AttributesIdentity`, call the protected method) is identical to `registerWithEmail` above: ```javascript import { AuthClient, scopedKeys } 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 authClient = new AuthClient({ identityProvider: getIdentityProviderUrl(), openIdProvider: "google", }); + const nonce = await backend.registerBegin(); -const signInPromise = authClient.signIn(); + +const signInPromise = authClient.signIn({ + maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), +}); // Requests name, email, and verified_email from the Google account -// linked to the user's Internet Identity. +// linked to the user's Internet Identity. The keys returned by +// scopedKeys() arrive in the bundle as e.g. +// "openid:https://accounts.google.com:email". const attributesPromise = authClient.requestAttributes({ keys: scopedKeys({ openIdProvider: "google" }), nonce, }); + +const identity = await signInPromise; +const { data, signature } = await attributesPromise; + +const identityWithAttributes = new AttributesIdentity({ + inner: identity, + attributes: { data, signature }, + signer: { canisterId: Principal.fromText("rdmx6-jaaaa-aaaaa-aaadq-cai") }, +}); + +const agent = await HttpAgent.create({ identity: identityWithAttributes }); +const app = Actor.createActor(appIdl, { agent, canisterId: appCanisterId }); +await app.registerFinish(); ``` ### Backend: Reading Identity Attributes @@ -258,6 +283,25 @@ persistent actor { null; }; + func lookupBlob(entries : [(Text, Icrc3Value)], key : Text) : ?Blob { + for ((k, v) in entries.vals()) { + if (k == key) { switch v { case (#Blob b) { return ?b }; case _ {} } }; + }; + null; + }; + + func lookupNat(entries : [(Text, Icrc3Value)], key : Text) : ?Nat { + for ((k, v) in entries.vals()) { + if (k == key) { switch v { case (#Nat n) { return ?n }; case _ {} } }; + }; + null; + }; + + // Pending nonces minted in registerBegin, keyed by caller. See the + // "Storing the nonce" note below for storage patterns. + // type PendingNonces = Map; + // var pendingNonces : PendingNonces = ...; + // Returns the verified attribute map, trapping if the signer is not II. func iiAttributes() : [(Text, Icrc3Value)] { let signer = Prim.callerInfoSigner(); @@ -276,8 +320,16 @@ persistent actor { 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 minted in registerBegin and check - // implicit:issued_at_timestamp_ns is within your freshness window (omitted). + + // Verify implicit:nonce matches a nonce we minted for this caller, and consume it. + let ?nonce = lookupBlob(entries, "implicit:nonce") else Runtime.trap("missing nonce"); + let ?expected = pendingNonces.remove(caller) else Runtime.trap("no pending registration for caller"); + if (nonce != expected) Runtime.trap("Nonce mismatch"); + + // Verify implicit:issued_at_timestamp_ns is within a 5-minute freshness window. + let ?issuedAt = lookupNat(entries, "implicit:issued_at_timestamp_ns") else Runtime.trap("missing timestamp"); + let nowNs = Nat64.toNat(Prim.time()); + if (nowNs > issuedAt + 300_000_000_000) Runtime.trap("Bundle too old"); let ?email = lookupText(entries, "email") else Runtime.trap("missing email"); "Registered " # Principal.toText(caller) # " with email " # email @@ -309,6 +361,20 @@ fn lookup_text<'a>(entries: &'a [(String, Icrc3Value)], key: &str) -> Option<&'a }) } +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, + }) +} + // 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(); @@ -324,6 +390,14 @@ fn ii_attributes() -> Vec<(String, Icrc3Value)> { } } +// Pending nonces minted in register_begin, keyed by caller. See the +// "Storing the nonce" note below for storage patterns. Provided by +// the canister's stable-memory state (e.g. a StableBTreeMap). +fn consume_pending_nonce(_caller: Principal) -> Option> { + // pendingNonces.remove(&caller) + unimplemented!("see Storing the nonce") +} + #[update] fn register_finish() -> String { let caller = msg_caller(); @@ -333,8 +407,22 @@ fn register_finish() -> String { 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 minted in register_begin and check - // implicit:issued_at_timestamp_ns is within your freshness window (omitted). + + // Verify implicit:nonce matches a nonce we minted for this caller, and consume it. + let nonce = lookup_blob(&entries, "implicit:nonce") + .unwrap_or_else(|| ic_cdk::trap("missing nonce")); + let expected = consume_pending_nonce(caller) + .unwrap_or_else(|| ic_cdk::trap("no pending registration for caller")); + if nonce != expected.as_slice() { ic_cdk::trap("Nonce mismatch"); } + + // Verify implicit:issued_at_timestamp_ns is within a 5-minute freshness window. + let issued_at_ns: u64 = lookup_nat(&entries, "implicit:issued_at_timestamp_ns") + .unwrap_or_else(|| ic_cdk::trap("missing timestamp")) + .0.clone().try_into() + .unwrap_or_else(|_| ic_cdk::trap("timestamp out of range")); + if ic_cdk::api::time() > issued_at_ns + 300_000_000_000 { + ic_cdk::trap("Bundle too old"); + } let email = lookup_text(&entries, "email") .unwrap_or_else(|| ic_cdk::trap("missing email")); From 65a1dd77beec54b2fb3d22a625a56a4d1c5275df Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Tue, 5 May 2026 09:08:07 +0000 Subject: [PATCH 08/11] fix(internet-identity): document available attribute keys and email vs verified_email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - list the unscoped attribute keys (name, email, verified_email) with guidance on when to use each: email for soft uses (mailing lists, contact), verified_email for access gating (admin allowlists) - document scopedKeys() resolution including the literal Microsoft provider URL (the {tid} segment is part of the URL, not a tenant placeholder) - add Mistake 10: substituting {tid} into the Microsoft URL silently breaks attribute lookups - add Mistake 11: treating email as verified — only verified_email carries the source provider's verification signal - new evals: Microsoft tid substitution, email-vs-verified_email for access gating --- evaluations/internet-identity.json | 19 ++++++++++++++++++ skills/internet-identity/SKILL.md | 31 ++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json index b006e2e..0817fe1 100644 --- a/evaluations/internet-identity.json +++ b/evaluations/internet-identity.json @@ -71,6 +71,25 @@ "Decodes msg_caller_info_data as a Candid-encoded ICRC-3 Value::Map and looks up the requested attribute key only after the signer check passes" ] }, + { + "name": "Adversarial: substituting tid in the Microsoft scoped-key URL", + "prompt": "I'm using scopedKeys({ openIdProvider: 'microsoft' }) on the frontend. My Azure tenant ID is 11111111-2222-3333-4444-555555555555. How do I update the {tid} placeholder in the URL my backend looks for?", + "expected_behaviors": [ + "Tells the user NOT to substitute the tenant GUID into the URL", + "Explains that the {tid} part of https://login.microsoftonline.com/{tid}/v2.0 is a literal segment, not a placeholder, and that bundle keys arrive as openid:https://login.microsoftonline.com/{tid}/v2.0: exactly", + "Tells the backend to look up the literal key (e.g. openid:https://login.microsoftonline.com/{tid}/v2.0:email) without modification" + ] + }, + { + "name": "Adversarial: using email instead of verified_email for access gating", + "prompt": "I have an admin allowlist of email addresses. I want to gate a sensitive update method on the caller's email being in that list. Show me how to request the email from II and check it on the backend.", + "expected_behaviors": [ + "Requests verified_email (not just email) for access-gating use cases", + "Explains that email is whatever the user's II-linked account reports while verified_email is only present when the source OpenID provider (e.g. Google) marked the email as verified and II surfaced that signal — only verified_email is trustworthy for authorisation", + "On the backend, looks up implicit:nonce, implicit:origin, signer (against rdmx6-jaaaa-aaaaa-aaadq-cai), and the verified_email attribute key (or the openid::verified_email scoped variant) before the allowlist check", + "Does NOT recommend reading the email field as a substitute for verified_email" + ] + }, { "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?", diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index ef22955..4db9d85 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -45,6 +45,10 @@ Internet Identity (II) is the Internet Computer's native authentication system. 9. **Reading attribute data without verifying the signer.** `msg_caller_info_data` (Rust) and `Prim.callerInfoData` (Motoko) return whatever bundle the caller provided. The IC verifies the signature, not the identity of the signer — any canister can produce a valid bundle. Check `msg_caller_info_signer` / `Prim.callerInfoSigner` against `rdmx6-jaaaa-aaaaa-aaadq-cai` (Internet Identity) before trusting any attribute, otherwise an attacker canister can 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. **Treating `email` as verified.** `email` and `verified_email` are distinct keys. `email` is whatever the user's II-linked account reports; `verified_email` is only present when the source OpenID provider (e.g., Google) marked the email as verified, and II surfaces 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: @@ -150,6 +154,33 @@ init(); When the backend needs more than the user's principal (e.g., a verified email), Internet Identity can return signed attributes alongside the delegation. The 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. +#### Available attribute keys + +`requestAttributes({ keys })` accepts the following keys: + +| Key | Meaning | When to use | +|---|---|---| +| `name` | The user's display name. | Personalisation in the UI. | +| `email` | The user's email as reported by their II linked account. | Mailing-list signups, contact email, anything where you don't gate access on the email. | +| `verified_email` | Same value as `email`, but only present when the source OpenID provider (e.g., Google) marked the email as verified, and II surfaces that signal. | Access gating (e.g. an admin allowlist by email). Treat this as the only trustworthy email for authorisation. | + +Request both `email` and `verified_email` if you want fallback behaviour: when the source provider marked the email as verified, both keys are present with the same value; when it didn't, only `email` is returned. + +`scopedKeys({ openIdProvider, keys? })` rewrites the keys above into provider-scoped keys of the form `openid::`, so II returns the values from the linked OpenID account directly (with implicit consent, no extra prompt). Provider URLs: + +| Provider | URL prefix in the bundle keys | +|---|---| +| `'google'` | `openid:https://accounts.google.com:` | +| `'apple'` | `openid:https://appleid.apple.com:` | +| `'microsoft'` | `openid:https://login.microsoftonline.com/{tid}/v2.0:` (the `{tid}` part is literal: do not substitute a tenant ID into it) | + +`keys` defaults to `['name', 'email', 'verified_email']`. Examples: + +- `scopedKeys({ openIdProvider: 'google' })` → `['openid:https://accounts.google.com:name', 'openid:https://accounts.google.com:email', 'openid:https://accounts.google.com:verified_email']` +- `scopedKeys({ openIdProvider: 'google', keys: ['email'] })` → `['openid:https://accounts.google.com:email']` + +The same `email` vs `verified_email` rule applies to scoped keys: use the verified variant when the email gates access. + ```javascript import { AuthClient } from "@icp-sdk/auth/client"; import { AttributesIdentity } from "@icp-sdk/core/identity"; From fc65fef336203a9b4794ffbdedaf9578591d043f Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Tue, 5 May 2026 09:27:42 +0000 Subject: [PATCH 09/11] fix(internet-identity): tighten email vs verified_email guidance - table column renamed to "What it IS" and rows lead with the explicit definition (raw vs verified) so agents echo the distinction in their answers - Mistake 11 split into two bullets that define each key independently - eval 9 expected behaviour split: agents now have to articulate the email definition AND the verified_email definition separately eval 9 now passes 5/5 with-skill (was 3/4) vs 1/5 baseline --- evaluations/internet-identity.json | 3 ++- skills/internet-identity/SKILL.md | 13 ++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json index 0817fe1..33b989c 100644 --- a/evaluations/internet-identity.json +++ b/evaluations/internet-identity.json @@ -85,7 +85,8 @@ "prompt": "I have an admin allowlist of email addresses. I want to gate a sensitive update method on the caller's email being in that list. Show me how to request the email from II and check it on the backend.", "expected_behaviors": [ "Requests verified_email (not just email) for access-gating use cases", - "Explains that email is whatever the user's II-linked account reports while verified_email is only present when the source OpenID provider (e.g. Google) marked the email as verified and II surfaced that signal — only verified_email is trustworthy for authorisation", + "Explains that the email key is the raw value from the user's II-linked account and is NOT checked by II (treat as user-supplied input)", + "Explains that verified_email is only present when the source OpenID provider (e.g. Google) marked the email as verified and II surfaced that signal — that is what makes verified_email trustworthy for authorisation", "On the backend, looks up implicit:nonce, implicit:origin, signer (against rdmx6-jaaaa-aaaaa-aaadq-cai), and the verified_email attribute key (or the openid::verified_email scoped variant) before the allowlist check", "Does NOT recommend reading the email field as a substitute for verified_email" ] diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 4db9d85..078b264 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -47,7 +47,10 @@ Internet Identity (II) is the Internet Computer's native authentication system. 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. **Treating `email` as verified.** `email` and `verified_email` are distinct keys. `email` is whatever the user's II-linked account reports; `verified_email` is only present when the source OpenID provider (e.g., Google) marked the email as verified, and II surfaces 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. +11. **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 @@ -158,11 +161,11 @@ When the backend needs more than the user's principal (e.g., a verified email), `requestAttributes({ keys })` accepts the following keys: -| Key | Meaning | When to use | +| Key | What it IS | When to use | |---|---|---| -| `name` | The user's display name. | Personalisation in the UI. | -| `email` | The user's email as reported by their II linked account. | Mailing-list signups, contact email, anything where you don't gate access on the email. | -| `verified_email` | Same value as `email`, but only present when the source OpenID provider (e.g., Google) marked the email as verified, and II surfaces that signal. | Access gating (e.g. an admin allowlist by email). Treat this as the only trustworthy email for authorisation. | +| `name` | The user's display name from the II-linked account. | Personalisation in the UI. | +| `email` | The raw email string from the user's II-linked account. **II does not check it.** Treat as user-supplied input. | Mailing-list signups, contact email, anything where you don't gate access on the email. | +| `verified_email` | 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. **The provider's verification is what makes it trustworthy.** | Access gating (e.g. an admin allowlist by email). Treat this as the only trustworthy email for authorisation. | Request both `email` and `verified_email` if you want fallback behaviour: when the source provider marked the email as verified, both keys are present with the same value; when it didn't, only `email` is returned. From 80cb2219e136b4a99f255ca758c3597b7b2d36e9 Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Tue, 5 May 2026 09:37:49 +0000 Subject: [PATCH 10/11] fix(internet-identity): clarify that requestAttributes keys is required --- skills/internet-identity/SKILL.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 078b264..1791e3f 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -159,7 +159,7 @@ When the backend needs more than the user's principal (e.g., a verified email), #### Available attribute keys -`requestAttributes({ keys })` accepts the following keys: +`requestAttributes({ keys, nonce })` requires both `keys` and `nonce`: there is no default key set, you must pass an explicit list. The keys II currently accepts are: | Key | What it IS | When to use | |---|---|---| @@ -177,7 +177,7 @@ Request both `email` and `verified_email` if you want fallback behaviour: when t | `'apple'` | `openid:https://appleid.apple.com:` | | `'microsoft'` | `openid:https://login.microsoftonline.com/{tid}/v2.0:` (the `{tid}` part is literal: do not substitute a tenant ID into it) | -`keys` defaults to `['name', 'email', 'verified_email']`. Examples: +The `keys` argument to `scopedKeys` is optional and defaults to `['name', 'email', 'verified_email']`. (`requestAttributes` itself has no default; the `scopedKeys` helper just builds the array you then pass to it.) Examples: - `scopedKeys({ openIdProvider: 'google' })` → `['openid:https://accounts.google.com:name', 'openid:https://accounts.google.com:email', 'openid:https://accounts.google.com:verified_email']` - `scopedKeys({ openIdProvider: 'google', keys: ['email'] })` → `['openid:https://accounts.google.com:email']` From 0cae0aeb4da0dea6b941d72297ea418d4715863a Mon Sep 17 00:00:00 2001 From: sea-snake <104725312+sea-snake@users.noreply.github.com> Date: Wed, 6 May 2026 12:30:43 +0000 Subject: [PATCH 11/11] fix(internet-identity): use mo:core/CallerAttributes on the Motoko path Replaces the manual Prim.callerInfoSigner / Prim.callerInfoData dance with CallerAttributes.getAttributes() from mo:core (>= 2.5.0). The wrapper bakes in the trusted-signer check via the canister's trusted_attribute_signers env var, so the example no longer hardcodes the II principal in code: it moves to icp.yaml as deploy-time config. Notable changes: - Motoko example now imports mo:core/CallerAttributes (no more mo:prim) and reads time via mo:core/Time (Time.now() : Int) instead of the broken Nat64.toNat(Prim.time()) which had no Nat64 import. - consumePendingNonce stub mirrors the Rust register_finish pattern so the example compiles standalone. - New "Configuring trusted_attribute_signers" subsection shows the icp.yaml settings.environment_variables snippet. - Mistake #9 split per language: Motoko points at the env-var-based check, Rust still requires explicit msg_caller_info_signer. - Prerequisites bumps mo:core minimum to >= 2.5.0. - OpenID scopedKeys example wrapped in an async function to avoid bare top-level await at module scope (fixes the same Vite es2020 failure mode eval #6 already covers). - Eval #9 expected behavior accepts either the explicit Rust signer check or the Motoko env-var check. Rust path is unchanged: there is no ic-cdk wrapper yet. --- evaluations/internet-identity.json | 2 +- skills/internet-identity/SKILL.md | 106 ++++++++++++++++++----------- 2 files changed, 66 insertions(+), 42 deletions(-) diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json index 33b989c..20d64ab 100644 --- a/evaluations/internet-identity.json +++ b/evaluations/internet-identity.json @@ -87,7 +87,7 @@ "Requests verified_email (not just email) for access-gating use cases", "Explains that the email key is the raw value from the user's II-linked account and is NOT checked by II (treat as user-supplied input)", "Explains that verified_email is only present when the source OpenID provider (e.g. Google) marked the email as verified and II surfaced that signal — that is what makes verified_email trustworthy for authorisation", - "On the backend, looks up implicit:nonce, implicit:origin, signer (against rdmx6-jaaaa-aaaaa-aaadq-cai), and the verified_email attribute key (or the openid::verified_email scoped variant) before the allowlist check", + "On the backend, verifies the bundle signer is the Internet Identity backend canister (`rdmx6-jaaaa-aaaaa-aaadq-cai`) — either via an explicit `msg_caller_info_signer()` check (Rust) or via `mo:core/CallerAttributes` with the `trusted_attribute_signers` env var configured (Motoko) — and looks up implicit:nonce, implicit:origin, and the verified_email attribute key (or the openid::verified_email scoped variant) before the allowlist check", "Does NOT recommend reading the email field as a substitute for verified_email" ] }, diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index 1791e3f..5d71196 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -17,6 +17,7 @@ Internet Identity (II) is the Internet Computer's native authentication system. ## Prerequisites - `@icp-sdk/auth` (>= 7.0.0), `@icp-sdk/core` (>= 5.3.0) (`AttributesIdentity` was added in core v5.3.0) +- For the Motoko backend example: `mo:core` >= 2.5.0 (the `CallerAttributes` module that wraps the caller-info primitives behind a single trusted-signer-aware call) ## Canister IDs @@ -43,7 +44,9 @@ Internet Identity (II) is the Internet Computer's native authentication system. 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. -9. **Reading attribute data without verifying the signer.** `msg_caller_info_data` (Rust) and `Prim.callerInfoData` (Motoko) return whatever bundle the caller provided. The IC verifies the signature, not the identity of the signer — any canister can produce a valid bundle. Check `msg_caller_info_signer` / `Prim.callerInfoSigner` against `rdmx6-jaaaa-aaaaa-aaadq-cai` (Internet Identity) before trusting any attribute, otherwise an attacker canister can forge attributes like `email = "admin@you.com"`. +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: + - **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. @@ -249,42 +252,64 @@ const authClient = new AuthClient({ openIdProvider: "google", }); -const nonce = await backend.registerBegin(); +// Wrap the flow in an async function so this code works with any bundler +// target (Vite defaults to es2020 which lacks top-level await). +async function registerWithGoogle(backend, appCanisterId, appIdl) { + const nonce = await backend.registerBegin(); -const signInPromise = authClient.signIn({ - maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), -}); -// Requests name, email, and verified_email from the Google account -// linked to the user's Internet Identity. The keys returned by -// scopedKeys() arrive in the bundle as e.g. -// "openid:https://accounts.google.com:email". -const attributesPromise = authClient.requestAttributes({ - keys: scopedKeys({ openIdProvider: "google" }), - nonce, -}); + const signInPromise = authClient.signIn({ + maxTimeToLive: BigInt(8) * BigInt(3_600_000_000_000), + }); + // Requests name, email, and verified_email from the Google account + // linked to the user's Internet Identity. The keys returned by + // scopedKeys() arrive in the bundle as e.g. + // "openid:https://accounts.google.com:email". + const attributesPromise = authClient.requestAttributes({ + keys: scopedKeys({ openIdProvider: "google" }), + nonce, + }); -const identity = await signInPromise; -const { data, signature } = await attributesPromise; + const identity = await signInPromise; + const { data, signature } = await attributesPromise; -const identityWithAttributes = new AttributesIdentity({ - inner: identity, - attributes: { data, signature }, - signer: { canisterId: Principal.fromText("rdmx6-jaaaa-aaaaa-aaadq-cai") }, -}); + const identityWithAttributes = new AttributesIdentity({ + inner: identity, + attributes: { data, signature }, + signer: { canisterId: Principal.fromText("rdmx6-jaaaa-aaaaa-aaadq-cai") }, + }); -const agent = await HttpAgent.create({ identity: identityWithAttributes }); -const app = Actor.createActor(appIdl, { agent, canisterId: appCanisterId }); -await app.registerFinish(); + const agent = await HttpAgent.create({ identity: identityWithAttributes }); + const app = Actor.createActor(appIdl, { agent, canisterId: appCanisterId }); + await app.registerFinish(); +} ``` ### Backend: Reading Identity Attributes When the frontend wraps an identity with `AttributesIdentity`, every call carries a verified attribute bundle. -- **Rust** (ic-cdk >= 0.20.1): `ic_cdk::api::msg_caller_info_data() -> Vec`, `ic_cdk::api::msg_caller_info_signer() -> Option`. -- **Motoko** (compiler with caller_info prims, e.g. >= 0.16): `Prim.callerInfoData() : Blob`, `Prim.callerInfoSigner() : Blob` (empty when no signer). +- **Rust** (ic-cdk >= 0.20.1): `ic_cdk::api::msg_caller_info_data() -> Vec`, `ic_cdk::api::msg_caller_info_signer() -> Option`. There is no CDK wrapper for the trusted-signer check yet; do it explicitly in your code. +- **Motoko** (mo:core >= 2.5.0): `CallerAttributes.getAttributes() : ?Blob` from `mo:core/CallerAttributes`. The wrapper returns `null` when no attributes are attached and **traps** when the signer isn't listed in the canister's `trusted_attribute_signers` env var, so you don't write the signer check yourself. Underlying primitives `Prim.callerInfoData` / `Prim.callerInfoSigner` are still exposed by the compiler but the wrapper is preferred. + +**Always verify the signer.** The IC checks that the bundle is signed; it does not check *who* signed it. Any canister can produce a valid bundle. Trust the data only when the signer matches the trusted issuer (`rdmx6-jaaaa-aaaaa-aaadq-cai` for Internet Identity). Motoko handles this for you via the env var; Rust requires an explicit `msg_caller_info_signer()` check. -**Always verify the signer first.** The IC checks that the bundle is signed; it does not check *who* signed it. Any canister can produce a valid bundle. Trust the data only when the signer matches the trusted issuer (`rdmx6-jaaaa-aaaaa-aaadq-cai` for Internet Identity). +#### Configuring `trusted_attribute_signers` (Motoko path) + +`CallerAttributes.getAttributes` reads the trusted signer list from the canister's `trusted_attribute_signers` environment variable (a comma-separated list of principal texts). Set it in your `icp.yaml` so `icp deploy` configures the canister automatically: + +```yaml +canisters: + - name: backend + settings: + environment_variables: + # Mainnet II principal. List both the mainnet principal and your local II + # canister principal if your tests run against a locally deployed II. + trusted_attribute_signers: "rdmx6-jaaaa-aaaaa-aaadq-cai" +``` + +If the env var is unset, `getAttributes` traps with `"trusted_attribute_signers environment variable is not set"`. That trap is the right behavior: an unconfigured canister should not trust attribute bundles. + +#### Reading the bundle The data is Candid-encoded as an ICRC-3 `Value::Map` whose entries are: - `implicit:nonce` (Blob) — must match a nonce your canister minted for this user/action. @@ -294,13 +319,12 @@ The data is Candid-encoded as an ICRC-3 `Value::Map` whose entries are: - OpenID-scoped keys (e.g., `"openid:https://accounts.google.com:email"`) when `scopedKeys` was used on the frontend. ```motoko -import Prim "mo:prim"; +import CallerAttributes "mo:core/CallerAttributes"; import Principal "mo:core/Principal"; import Runtime "mo:core/Runtime"; +import Time "mo:core/Time"; persistent actor { - let iiPrincipal = Principal.fromText("rdmx6-jaaaa-aaaaa-aaadq-cai"); - type Icrc3Value = { #Nat : Nat; #Int : Int; @@ -332,17 +356,17 @@ persistent actor { }; // Pending nonces minted in registerBegin, keyed by caller. See the - // "Storing the nonce" note below for storage patterns. - // type PendingNonces = Map; - // var pendingNonces : PendingNonces = ...; + // "Storing the nonce" note below for storage patterns. Provided by + // the canister's stable-memory state (e.g. a Map). + func consumePendingNonce(_caller : Principal) : ?Blob { + // pendingNonces.remove(caller) + Runtime.trap("see Storing the nonce"); + }; - // Returns the verified attribute map, trapping if the signer is not II. + // Returns the verified attribute map. Traps when the signer is not + // listed in the canister's trusted_attribute_signers env var. 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 ?data = CallerAttributes.getAttributes() else Runtime.trap("no trusted attributes"); let ?value : ?Icrc3Value = from_candid (data) else Runtime.trap("invalid attribute bundle"); let #Map(entries) = value else Runtime.trap("expected attribute map"); entries @@ -357,13 +381,13 @@ persistent actor { // Verify implicit:nonce matches a nonce we minted for this caller, and consume it. let ?nonce = lookupBlob(entries, "implicit:nonce") else Runtime.trap("missing nonce"); - let ?expected = pendingNonces.remove(caller) else Runtime.trap("no pending registration for caller"); + let ?expected = consumePendingNonce(caller) else Runtime.trap("no pending registration for caller"); if (nonce != expected) Runtime.trap("Nonce mismatch"); // Verify implicit:issued_at_timestamp_ns is within a 5-minute freshness window. + // Time.now() is Int (nanoseconds); Nat <: Int so the comparison works directly. let ?issuedAt = lookupNat(entries, "implicit:issued_at_timestamp_ns") else Runtime.trap("missing timestamp"); - let nowNs = Nat64.toNat(Prim.time()); - if (nowNs > issuedAt + 300_000_000_000) Runtime.trap("Bundle too old"); + if (Time.now() > issuedAt + 300_000_000_000) Runtime.trap("Bundle too old"); let ?email = lookupText(entries, "email") else Runtime.trap("missing email"); "Registered " # Principal.toText(caller) # " with email " # email