diff --git a/evaluations/internet-identity-attributes.json b/evaluations/internet-identity-attributes.json new file mode 100644 index 00000000..b69d88f5 --- /dev/null +++ b/evaluations/internet-identity-attributes.json @@ -0,0 +1,84 @@ +{ + "skill": "internet-identity-attributes", + "description": "Evaluation cases for the internet-identity-attributes skill. Tests whether agents verify an attribute bundle before trusting it: backend-issued nonce, trusted signer, scoped-key prefixes, and verified_email rather than email.", + "output_evals": [ + { + "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": [ + "Recommends the identity-attributes crate (the #[identity_attributes] attribute), which runs the signer, origin, freshness, and nonce checks", + "In hand-written code, 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" + ] + }, + { + "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 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 II established the user controls the address: either an OpenID provider (e.g. Google) marked it verified, or the user linked and verified that email with II directly. That is what makes verified_email trustworthy for authorisation", + "On the backend, verifies the bundle through the identity-attributes library with trusted_attribute_signers and frontend_origins configured, and runs the allowlist check on the value the library resolved (which it populates from verified_email) rather than on the raw email key", + "Does NOT recommend reading the email field as a substitute for verified_email" + ] + }, + { + "name": "Motoko backend uses the identity-attributes library", + "prompt": "In my Motoko backend, how do I verify Internet Identity attribute bundles and read the user verified email? Show the backend setup, including icp.yaml.", + "expected_behaviors": [ + "Recommends the mo:identity-attributes mixin (include IdentityAttributes({ onVerified ... })), not a hand-rolled ICRC-3 decode", + "Does NOT hand-write lookupText/lookupBlob helpers or manually decode the ICRC-3 Value::Map in Motoko", + "Configures both trusted_attribute_signers AND frontend_origins environment variables in icp.yaml", + "Explains the mixin injects _internet_identity_sign_in_start and _internet_identity_sign_in_finish and runs onVerified only on a bundle that passed signer, origin, nonce, and freshness checks", + "Reads the email from the resolved attrs (sourced from the verified_email key), and requests verified_email on the frontend" + ] + }, + { + "name": "Adversarial: verified_email under an SSO scope", + "prompt": "My app signs users in with new AuthClient({ ssoDomain: 'acme.com' }). Which attribute key should I request to gate admin access on the user's email? Just the requestAttributes call and one sentence of explanation.", + "expected_behaviors": [ + "States that there is no verified_email under sso: (an organization's SSO asserts the email itself, so II cannot vouch for it)", + "Requests sso:acme.com:email (for example via scopedKeys({ ssoDomain: 'acme.com', keys: ['email'] }))", + "Says to trust that email only for SSO domains the backend explicitly trusts (e.g. trusted_sso_domains in identity-attributes)", + "Does NOT request sso:acme.com:verified_email" + ] + } + ], + "trigger_evals": { + "description": "Queries to test whether the skill activates correctly.", + "should_trigger": [ + "Read the signed-in user's verified email in my canister", + "My backend needs the user's display name from Internet Identity", + "Gate a canister method on an email address the user provably controls", + "How do I verify the attribute bundle Internet Identity sends with the delegation?", + "Set up identity-attributes in a Rust canister" + ], + "should_not_trigger": [ + "Add Internet Identity login to my frontend", + "Send a notification to a signed-in user", + "Let the user type their email address into a form", + "How do I deploy my canister?" + ] + } +} diff --git a/evaluations/internet-identity-notifications.json b/evaluations/internet-identity-notifications.json new file mode 100644 index 00000000..f57b4ff5 --- /dev/null +++ b/evaluations/internet-identity-notifications.json @@ -0,0 +1,54 @@ +{ + "skill": "internet-identity-notifications", + "description": "Evaluation cases for the internet-identity-notifications skill. Tests whether agents read the consent record correctly, publish the sender list, and leave delivery to Internet Identity.", + "output_evals": [ + { + "name": "Notification consent returns a record, not a boolean", + "prompt": "Add a 'Notify me' button to my ICP app that asks Internet Identity for permission to send notifications, and show the user whether it was allowed. Just the handler, no backend.", + "expected_behaviors": [ + "Destructures the result: `const { granted } = await authClient.requestNotificationConsent()` — does NOT assign the call directly to a boolean", + "Treats `granted: false` as the user's answer and renders it, rather than throwing or logging an error", + "Asks from a click handler, not on page load or at module scope", + "Does NOT invent a browser Notification.requestPermission() or Push API call — permission is asked through Internet Identity", + "Does not claim VAPID keys, a push service, or a service worker are needed in the app" + ] + }, + { + "name": "The sender list is required before any send works", + "prompt": "My canister calls the identity-notifications send function and it returns fine, but no notification ever arrives. The user definitely allowed notifications. What's wrong?", + "expected_behaviors": [ + "Names `/.well-known/ii-notification-senders` on the app's origin as the thing most likely missing", + "Shows the document shape: an object with a `senders` array of canister id strings", + "Explains that Internet Identity refuses a send from a canister the origin has not named, which is why nothing errors at the send site", + "Mentions the cache (roughly an hour) so a just-added canister is not accepted instantly", + "Does NOT suggest the problem is a missing service worker or a browser permission prompt" + ] + }, + { + "name": "Delivery is not the app's to control", + "prompt": "I removed a user from my app's chat room so they stop getting notified, but they still got a notification. How do I unsubscribe their browser from my app?", + "expected_behaviors": [ + "Says the app addresses a user and does not control where or how the notification reaches them: that is Internet Identity's and the user's to manage", + "Says the app's own recipient list governs only what the app sends from now on", + "Does NOT suggest the app call a browser unsubscribe API, clear a service worker registration, or manipulate a push subscription", + "Does NOT invent an Internet Identity method for removing a device or subscription on the app's behalf" + ] + } + ], + "trigger_evals": { + "description": "Queries to test whether the skill activates correctly.", + "should_trigger": [ + "Send a web push notification to a user from my ICP canister", + "Ask the user for permission to notify them with Internet Identity", + "Notify a signed-in user while my app is closed", + "Add a notifications on/off toggle to my ICP app", + "My canister sends notifications but nothing ever arrives" + ], + "should_not_trigger": [ + "Add Internet Identity login to my frontend", + "Show a toast inside my app when a transfer completes", + "Send a transactional email to a user", + "How does stable memory work?" + ] + } +} diff --git a/evaluations/internet-identity-shared-sessions.json b/evaluations/internet-identity-shared-sessions.json new file mode 100644 index 00000000..19744d1a --- /dev/null +++ b/evaluations/internet-identity-shared-sessions.json @@ -0,0 +1,65 @@ +{ + "skill": "internet-identity-shared-sessions", + "description": "Evaluation cases for the internet-identity-shared-sessions skill. Tests whether agents build a shared sign-in on one primary origin, use /reauth with hint and the redirect transport, and avoid redirecting a page the user is working on.", + "output_evals": [ + { + "name": "Shared sign-in across sibling subdomains", + "prompt": "Two apps, chat.example.com and hr.example.com: a user who signs in on one should be signed in on the other with @icp-sdk/auth. Show five things and nothing else: the shared client options, the well-known documents this needs and which origin serves each, the /reauth route, the check every page runs on load, and what happens if the status changes while a page is already open. Code with short comments, no prose sections, no backend, no deploy steps.", + "expected_behaviors": [ + "Requires one shared derivationOrigin across both apps, listed in that origin's /.well-known/ii-alternative-origins, and does not present a shared cookie as sufficient on its own", + "Uses CookieStateStorage with the parent domain (example.com) as stateStorage on both apps", + "Acquires a credential on the origin that reads signed-in-elsewhere, with a second AuthClient built with prompt: 'none'", + "Passes hint set to the principal the shared record names on that silent request", + "Uses transport: 'redirect' for the silent request rather than the default window transport", + "Does NOT pass resumable explicitly, since CookieStateStorage already asks for a resumable session", + "Every page, not only protected ones, checks getStatus() on load and redirects signed-in-elsewhere to the re-auth route, passing the current path as a next parameter it returns to", + "Handles the status turning signed-in-elsewhere after load through subscribe() by prompting the user with a button that runs the redirect, rather than redirecting the open page automatically", + "Serves /.well-known/ii-auth-callbacks on each app origin listing that origin's own /reauth URL, as the prerequisite for the redirect transport, rather than only the alternative-origins document on the derivation origin" + ] + }, + { + "name": "Adversarial: shared cookie but different principals per subdomain", + "prompt": "I set CookieStateStorage with domain 'example.com' on both my subdomains, but each one still signs the user in as a different principal, and my re-auth page keeps bouncing them back. What is wrong and what do I do? Three sentences, no code.", + "expected_behaviors": [ + "Identifies that principals are derived per origin and that one shared derivationOrigin across both subdomains is what makes them the same", + "Says that derivation origin has to authorize the subdomains by listing them in its alternative-origins document", + "Connects the bouncing to the shared record naming an account this origin can never hold a credential for", + "Does NOT blame cookie attributes, SameSite, Secure, or the cookie domain itself as the cause" + ] + }, + { + "name": "Adversarial: silent re-issue without hint", + "prompt": "Review this: on hr.example.com I detect signed-in-elsewhere and then run `await new AuthClient({ ...clientOptions, prompt: 'none' }).signIn()` on page load. Anything wrong? Two sentences, no rewrite.", + "expected_behaviors": [ + "Flags the missing hint and says a provider holding more than one session refuses the silent request rather than answering for another account", + "Flags that a silent request on page load has no user gesture, so it needs transport: 'redirect' rather than the default window transport", + "Does NOT claim prompt: 'none' is wrong or unsupported", + "Does NOT claim the missing hint signs the user in as a different account" + ] + }, + { + "name": "Adversarial: redirecting an already-open page to /reauth", + "prompt": "In my subdomain-shared setup I subscribe to the auth client and call location.replace('/reauth?next=...') on every change to signed-in-elsewhere, including while the user is filling in a form. Is that right? Three sentences, no code.", + "expected_behaviors": [ + "Says redirecting a page that is already open throws away what the user is doing, so it is wrong for the subscribe path", + "Says an already-open page should prompt the user, for example a banner or dialog whose button runs the redirect", + "Keeps the automatic redirect for the check a page runs on load, rather than calling the whole redirect approach wrong" + ] + } + ], + "trigger_evals": { + "description": "Queries to test whether the skill activates correctly.", + "should_trigger": [ + "Share one Internet Identity sign-in across my subdomains", + "Signing in on app.example.com doesn't sign the user in on chat.example.com", + "My /reauth route keeps bouncing the user back and forth", + "How do I sign a user out of every app on my domain at once?" + ], + "should_not_trigger": [ + "Add Internet Identity login to a single app", + "Register a custom domain for my canister", + "Read the user's verified email in my canister", + "Connect a wallet to my dapp" + ] + } +} diff --git a/evaluations/internet-identity-sso.json b/evaluations/internet-identity-sso.json new file mode 100644 index 00000000..449bb7e9 --- /dev/null +++ b/evaluations/internet-identity-sso.json @@ -0,0 +1,54 @@ +{ + "skill": "internet-identity-sso", + "description": "Evaluation cases for the internet-identity-sso skill. Tests whether agents configure an organization's discovery file correctly: whole-file rejection on one bad value, exact-origin app_clients keys, and the stable identifier claim per provider.", + "output_evals": [ + { + "name": "Adversarial: one bad value rejects the whole discovery file", + "prompt": "We want sessions through our Internet Identity SSO domain to last as long as the provider allows, so I set session_max_age_seconds to 0 in our /.well-known/ii-openid-configuration meaning no limit. Our users can no longer sign in with the company domain at all. Is that related?", + "expected_behaviors": [ + "States that 0 is rejected, not treated as unlimited, and that the valid range is above 0 up to 2592000 seconds (30 days)", + "Explains that Internet Identity rejects the ENTIRE file on one bad value, so SSO becomes unavailable for the whole domain rather than falling back to the default", + "Says the value is rejected rather than clamped", + "Mentions that getSsoStatus() reads unavailable for the domain while the file is invalid", + "Does NOT suggest the provider or the OIDC client is misconfigured as the first explanation" + ] + }, + { + "name": "Adversarial: app_clients key must be the app's exact origin", + "prompt": "I added \"https://payroll.acme.com/\": \"0oaPAYROLL\" to app_clients in our ii-openid-configuration so payroll gets its own OIDC client with its own group assignment. Staff who are not in the payroll group can still sign in to it. What did I get wrong?", + "expected_behaviors": [ + "Identifies the trailing slash as the problem: the key must be the app's exact origin, with no trailing slash, no path and the same scheme", + "Explains that a key that does not match is not an error: the app silently falls back to the organization's default client_id, which is why the group assignment has no effect", + "Shows the corrected entry keyed by https://payroll.acme.com", + "Mentions gate_all_apps as the way to refuse apps that are not listed, if that is the intent", + "Does NOT claim the provider ignores group assignment or that per-app clients are unsupported" + ] + }, + { + "name": "Adversarial: stable identifier claim on Entra ID", + "prompt": "We're configuring Internet Identity SSO against Microsoft Entra ID and giving two of our apps their own OIDC clients. Should I set stable_identifier_claim, or is the default fine?", + "expected_behaviors": [ + "Says stable_identifier_claim must be set to \"oid\" for Entra ID, because its sub differs per client", + "Explains the consequence of leaving the default sub with per-app clients: the same person keys onto a different identity in each app", + "Notes that changing stable_identifier_claim on a live domain is a migration, not a config tweak, because it changes how identities key onto anchors", + "Does NOT recommend email or preferred_username as the stable identifier" + ] + } + ], + "trigger_evals": { + "description": "Queries to test whether the skill activates correctly.", + "should_trigger": [ + "Set up Internet Identity sign-in for our staff against our Okta tenant", + "Publish the ii-openid-configuration file for our company domain", + "Give one of our apps its own OIDC client in the Internet Identity SSO config", + "Our Internet Identity SSO domain reads unavailable and we published the file", + "We changed our discovery file and Internet Identity is still using the old one" + ], + "should_not_trigger": [ + "Let my app send users to their company's SSO", + "Add Internet Identity login to my frontend", + "Sign in with a Google account on Internet Identity", + "Read the user's verified email in my canister" + ] + } +} diff --git a/evaluations/internet-identity.json b/evaluations/internet-identity.json index c0a2c86d..492c0f3c 100644 --- a/evaluations/internet-identity.json +++ b/evaluations/internet-identity.json @@ -1,6 +1,6 @@ { "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.", + "description": "Evaluation cases for the internet-identity skill. Tests whether agents produce correct II sign-in code, avoid top-level await, handle local vs mainnet environments, and elect a primary origin before an app has users.", "output_evals": [ { "name": "No top-level await in frontend code", @@ -54,47 +54,6 @@ "Suggests checking that the identity from await authClient.getIdentity() is the one 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": [ - "Recommends the identity-attributes crate (the #[identity_attributes] attribute), which runs the signer, origin, freshness, and nonce checks", - "In hand-written code, 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" - ] - }, - { - "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 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 II established the user controls the address: either an OpenID provider (e.g. Google) marked it verified, or the user linked and verified that email with II directly. That is what makes verified_email trustworthy for authorisation", - "On the backend, verifies the bundle signer is the Internet Identity backend canister (`rdmx6-jaaaa-aaaaa-aaadq-cai`) — via the identity-attributes library (the `#[identity_attributes]` attribute in Rust, the `mo:identity-attributes` mixin in Motoko) with `trusted_attribute_signers` and `frontend_origins` configured, or an explicit `msg_caller_info_signer()` check in hand-written Rust — 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" - ] - }, { "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?", @@ -123,17 +82,6 @@ "Explains that the local instance of internet identity is accessible at http://id.ai.localhost:8000" ] }, - { - "name": "Motoko backend uses the identity-attributes library", - "prompt": "In my Motoko backend, how do I verify Internet Identity attribute bundles and read the user verified email? Show the backend setup, including icp.yaml.", - "expected_behaviors": [ - "Recommends the mo:identity-attributes mixin (include IdentityAttributes({ onVerified ... })), not a hand-rolled ICRC-3 decode", - "Does NOT hand-write lookupText/lookupBlob helpers or manually decode the ICRC-3 Value::Map in Motoko", - "Configures both trusted_attribute_signers AND frontend_origins environment variables in icp.yaml", - "Explains the mixin injects _internet_identity_sign_in_start and _internet_identity_sign_in_finish and runs onVerified only on a bundle that passed signer, origin, nonce, and freshness checks", - "Reads the email from the resolved attrs (sourced from the verified_email key), and requests verified_email on the frontend" - ] - }, { "name": "Adversarial: alternative origins on the static-site recipe", "prompt": "IC frontend on the @dfinity/static-site recipe, adding a custom domain, need Internet Identity to keep the same principals across both origins. List ONLY the files I add under my build dir and their exact contents. No DNS, no prose, no explanation.", @@ -212,50 +160,6 @@ "Does NOT describe dispose() as signing the user out or ending the session" ] }, - { - "name": "Shared sign-in across sibling subdomains", - "prompt": "Two apps, chat.example.com and hr.example.com: a user who signs in on one should be signed in on the other with @icp-sdk/auth. Show five things and nothing else: the shared client options, the well-known documents this needs and which origin serves each, the /reauth route, the check every page runs on load, and what happens if the status changes while a page is already open. Code with short comments, no prose sections, no backend, no deploy steps.", - "expected_behaviors": [ - "Requires one shared derivationOrigin across both apps, listed in that origin's /.well-known/ii-alternative-origins, and does not present a shared cookie as sufficient on its own", - "Uses CookieStateStorage with the parent domain (example.com) as stateStorage on both apps", - "Acquires a credential on the origin that reads signed-in-elsewhere, with a second AuthClient built with prompt: 'none'", - "Passes hint set to the principal the shared record names on that silent request", - "Uses transport: 'redirect' for the silent request rather than the default window transport", - "Does NOT pass resumable explicitly, since CookieStateStorage already asks for a resumable session", - "Every page, not only protected ones, checks getStatus() on load and redirects signed-in-elsewhere to the re-auth route, passing the current path as a next parameter it returns to", - "Handles the status turning signed-in-elsewhere after load through subscribe() by prompting the user with a button that runs the redirect, rather than redirecting the open page automatically", - "Serves /.well-known/ii-auth-callbacks on each app origin listing that origin's own /reauth URL, as the prerequisite for the redirect transport, rather than only the alternative-origins document on the derivation origin" - ] - }, - { - "name": "Adversarial: shared cookie but different principals per subdomain", - "prompt": "I set CookieStateStorage with domain 'example.com' on both my subdomains, but each one still signs the user in as a different principal, and my re-auth page keeps bouncing them back. What is wrong and what do I do? Three sentences, no code.", - "expected_behaviors": [ - "Identifies that principals are derived per origin and that one shared derivationOrigin across both subdomains is what makes them the same", - "Says that derivation origin has to authorize the subdomains by listing them in its alternative-origins document", - "Connects the bouncing to the shared record naming an account this origin can never hold a credential for", - "Does NOT blame cookie attributes, SameSite, Secure, or the cookie domain itself as the cause" - ] - }, - { - "name": "Adversarial: silent re-issue without hint", - "prompt": "Review this: on hr.example.com I detect signed-in-elsewhere and then run `await new AuthClient({ ...clientOptions, prompt: 'none' }).signIn()` on page load. Anything wrong? Two sentences, no rewrite.", - "expected_behaviors": [ - "Flags the missing hint and says a provider holding more than one session refuses the silent request rather than answering for another account", - "Flags that a silent request on page load has no user gesture, so it needs transport: 'redirect' rather than the default window transport", - "Does NOT claim prompt: 'none' is wrong or unsupported", - "Does NOT claim the missing hint signs the user in as a different account" - ] - }, - { - "name": "Adversarial: redirecting an already-open page to /reauth", - "prompt": "In my subdomain-shared setup I subscribe to the auth client and call location.replace('/reauth?next=...') on every change to signed-in-elsewhere, including while the user is filling in a form. Is that right? Three sentences, no code.", - "expected_behaviors": [ - "Says redirecting a page that is already open throws away what the user is doing, so it is wrong for the subscribe path", - "Says an already-open page should prompt the user, for example a banner or dialog whose button runs the redirect", - "Keeps the automatic redirect for the check a page runs on load, rather than calling the whole redirect approach wrong" - ] - }, { "name": "Adversarial: @icp-sdk/auth major must pair with the core major", "prompt": "My dapp's package.json already pins \"@icp-sdk/core\": \"^5\". Give me the npm install command to add Internet Identity sign-in. No integration code.", @@ -311,16 +215,6 @@ "States that signIn() proceeds in every SSO state, invalid included, because II shows its own loading and error screens" ] }, - { - "name": "Adversarial: verified_email under an SSO scope", - "prompt": "My app signs users in with new AuthClient({ ssoDomain: 'acme.com' }). Which attribute key should I request to gate admin access on the user's email? Just the requestAttributes call and one sentence of explanation.", - "expected_behaviors": [ - "States that there is no verified_email under sso: (an organization's SSO asserts the email itself, so II cannot vouch for it)", - "Requests sso:acme.com:email (for example via scopedKeys({ ssoDomain: 'acme.com', keys: ['email'] }))", - "Says to trust that email only for SSO domains the backend explicitly trusts (e.g. trusted_sso_domains in identity-attributes)", - "Does NOT request sso:acme.com:verified_email" - ] - }, { "name": "Adversarial: switching an existing client to another SSO domain", "prompt": "With @icp-sdk/auth 11 I keep one AuthClient for the page and want it to follow the SSO domain the user types, so I tried `client.ssoDomain = input.value` and `client.signIn({ ssoDomain: input.value })`. Neither works. What should I do instead? Just the code and a short explanation.", @@ -330,38 +224,6 @@ "Calls dispose() on the superseded client", "Does NOT assign to a client property or pass ssoDomain to signIn()" ] - }, - { - "name": "Notification consent returns a record, not a boolean", - "prompt": "Add a 'Notify me' button to my ICP app that asks Internet Identity for permission to send notifications, and show the user whether it was allowed. Just the handler, no backend.", - "expected_behaviors": [ - "Destructures the result: `const { granted } = await authClient.requestNotificationConsent()` — does NOT assign the call directly to a boolean", - "Treats `granted: false` as the user's answer and renders it, rather than throwing or logging an error", - "Asks from a click handler, not on page load or at module scope", - "Does NOT invent a browser Notification.requestPermission() or Push API call — permission is asked through Internet Identity", - "Does not claim VAPID keys, a push service, or a service worker are needed in the app" - ] - }, - { - "name": "The sender list is required before any send works", - "prompt": "My canister calls the identity-notifications send function and it returns fine, but no notification ever arrives. The user definitely allowed notifications. What's wrong?", - "expected_behaviors": [ - "Names `/.well-known/ii-notification-senders` on the app's origin as the thing most likely missing", - "Shows the document shape: an object with a `senders` array of canister id strings", - "Explains that Internet Identity refuses a send from a canister the origin has not named, which is why nothing errors at the send site", - "Mentions the cache (roughly an hour) so a just-added canister is not accepted instantly", - "Does NOT suggest the problem is a missing service worker or a browser permission prompt" - ] - }, - { - "name": "Delivery is not the app's to control", - "prompt": "I removed a user from my app's chat room so they stop getting notified, but they still got a notification. How do I unsubscribe their browser from my app?", - "expected_behaviors": [ - "Says the app addresses a user and does not control where or how the notification reaches them: that is Internet Identity's and the user's to manage", - "Says the app's own recipient list governs only what the app sends from now on", - "Does NOT suggest the app call a browser unsubscribe API, clear a service worker registration, or manipulate a push subscription", - "Does NOT invent an Internet Identity method for removing a device or subscription on the app's behalf" - ] } ], "trigger_evals": { @@ -376,8 +238,7 @@ "How do I get my app's name and logo to show on the Internet Identity sign-in screen?", "Let users sign in with their company's SSO in my ICP app", "Check whether the domain a user typed supports Internet Identity SSO", - "Send a web push notification to a user from my ICP canister", - "Ask the user for permission to notify them with Internet Identity" + "My app is getting a custom domain and I don't want to lose the existing accounts" ], "should_not_trigger": [ "Connect a wallet to my dapp", diff --git a/skills/internet-identity-attributes/SKILL.md b/skills/internet-identity-attributes/SKILL.md new file mode 100644 index 00000000..a69adcf3 --- /dev/null +++ b/skills/internet-identity-attributes/SKILL.md @@ -0,0 +1,232 @@ +--- +name: internet-identity-attributes +description: "Trust a signed-in user's verified email, display name or organization SSO domain in a canister, rather than only their principal. Internet Identity signs an attribute bundle alongside the delegation, and the backend verifies the signer, the frontend origin and a nonce it issued itself before believing any of it. Use when a canister must know something about the caller beyond their principal, when gating access or an allowlist on an address the user provably controls, when showing a name the user never typed into your app, or when the backend rejects a bundle the frontend has just obtained. Covers the frontend request, scoping attributes to the account signed in with, and the identity-attributes library for Motoko and Rust. Assumes sign-in already works: load internet-identity first. Do NOT use for the sign-in flow itself, for an email address the user typed in (that is ordinary app input, which II has not checked), or for an agent or CLI acting as a user (agent-web-identity)." +license: Apache-2.0 +compatibility: "moc >= 1.6.0, ic-cdk >= 0.20.1" +metadata: + title: Internet Identity Attributes + category: Auth +--- + +# Internet Identity Attributes + +## Prerequisites + +This skill assumes a working sign-in built with `@icp-sdk/auth`: load the `internet-identity` skill first. The attribute bundle travels alongside a delegation, so there is nothing here to request without one. + +The `frontend_origins` value configured below must be the origin identities are derived for, the **primary origin** where the app has pinned one, not the origin the user happens to be visiting. A suite gets this wrong by default. + +- For the Motoko backend: `mo:identity-attributes` >= 0.4.0 (mops), the mixin that injects the two sign-in methods and verifies the attribute bundle. It pulls in `mo:core` >= 2.5.0 and requires `moc` >= 1.6.0 for `include`. +- For the Rust backend: the `identity-attributes` crate and `ic-cdk` >= 0.20.1. + +## Identity attributes + +When the backend needs more than the principal (e.g. a verified email), II returns a signed attribute bundle alongside the delegation. The backend exposes two methods: `_internet_identity_sign_in_start` issues a nonce, `_internet_identity_sign_in_finish` verifies the bundle. The `identity-attributes` library adds both: the `mo:identity-attributes` mixin in Motoko, and the `#[identity_attributes]` attribute in Rust (see "Backend: Rust" below). Both return the same Candid types, so the frontend is the same against either. + +## Keys + +`requestAttributes({ keys, nonce })` requires both; there is no default key set. + +| Key | Meaning | Use for | +|---|---|---| +| `name` | Display name from the user's II-linked account | Personalisation | +| `email` | Raw address from the II-linked account; **II does not check it** | Contact info, mailing lists | +| `verified_email` | Present only when II established the user controls the address (an OpenID provider marked it verified, or the user verified it with II) | Access gating; the only email to authorise on | + +Request both `email` and `verified_email` for fallback: when verified, both carry the same value; otherwise only `email` arrives. + +`scopedKeys` scopes keys to the account the user signs in with, so they are shared in the same step with no extra prompt: + +| Call | Keys | Default `keys` | +|---|---|---| +| `scopedKeys({ openIdProvider: 'google' })` | `openid:https://accounts.google.com:` | `name`, `email`, `verified_email` | +| `scopedKeys({ openIdProvider: 'apple' })` | `openid:https://appleid.apple.com:` | same | +| `scopedKeys({ openIdProvider: 'microsoft' })` | `openid:https://login.microsoftonline.com/{tid}/v2.0:` (`{tid}` literal) | same | +| `scopedKeys({ ssoDomain: 'acme.com' })` | `sso:acme.com:` | `name`, `email` (no `verified_email`) | + +Pass `keys` to narrow, e.g. `scopedKeys({ openIdProvider: 'google', keys: ['name', 'verified_email'] })`. + +## Frontend + +```javascript +import { AuthClient } from "@icp-sdk/auth/client"; +import { AttributesIdentity } from "@icp-sdk/core/identity"; +import { HttpAgent, Actor } from "@icp-sdk/core/agent"; +import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; +import { Principal } from "@icp-sdk/core/principal"; + +const II_PRINCIPAL = "rdmx6-jaaaa-aaaaa-aaadq-cai"; +const rootKey = safeGetCanisterEnv()?.IC_ROOT_KEY; + +async function signInWithAttributes(authClient, canisterId, idl) { + // Anonymous handle, used only to ask for the nonce. + const anonymousActor = Actor.createActor(idl, { agent: await HttpAgent.create({ rootKey }), canisterId }); + + // In parallel, one interaction for the user. `nonce` is a function the client + // calls when it needs the value, so the request is in flight while II opens. + const signInPromise = authClient.signIn(); + const attributesPromise = authClient.requestAttributes({ + keys: ["name", "verified_email"], + nonce: () => anonymousActor._internet_identity_sign_in_start(), + }); + + const identity = await signInPromise; + const attributes = await attributesPromise; + + // The bundle travels as sender_info on each call made with this identity. + const verifiedAgent = await HttpAgent.create({ + identity: new AttributesIdentity({ + inner: identity, + attributes, + signer: { canisterId: Principal.fromText(II_PRINCIPAL) }, + }), + rootKey, + }); + const verifiedActor = Actor.createActor(idl, { agent: verifiedAgent, canisterId }); + + const result = await verifiedActor._internet_identity_sign_in_finish(); + if ("err" in result) { + throw new Error(`Attribute verification failed: ${JSON.stringify(result.err)}`); + } + return identity; +} +``` + +For one-click sign-in, build the client with `openIdProvider` or `ssoDomain`, add `scopedKeys` to the `@icp-sdk/auth/client` import, and request `scopedKeys({ openIdProvider: "google", keys: ["name", "verified_email"] })` or `scopedKeys({ ssoDomain: "acme.com" })` instead; the rest is unchanged. + +Every bundle carries three implicit fields the backend MUST verify: `implicit:nonce` (one it issued and has not consumed), `implicit:origin` (a trusted frontend origin), `implicit:issued_at_timestamp_ns` (fresh, typically within five minutes). + +## Backend: Motoko + +```toml +[dependencies] +identity-attributes = "0.4.1" +core = "2.5.0" + +[toolchain] +moc = "1.6.0" +``` + +`include IdentityAttributes({ onVerified })` injects both methods and runs `onVerified` only on a bundle that passes the signer, origin, nonce, and freshness checks. It resolves the bundle to `{ name : ?Text; email : ?Text; sso : ?Text }`: + +- OpenID and unscoped sources: `email` comes from `verified_email` (or `openid::verified_email`), which is why the frontend requests `verified_email`. +- SSO sources: `name` and `email` come from `sso::name` / `sso::email`, accepted only for a domain in `trusted_sso_domains`, and `sso` is that domain. Otherwise `sso` is `null`. + +```motoko +import IdentityAttributes "mo:identity-attributes"; +import Map "mo:core/Map"; +import Principal "mo:core/Principal"; + +persistent actor { + type Profile = { name : ?Text; email : ?Text; sso : ?Text }; + + let profiles = Map.empty(); + + include IdentityAttributes({ + onVerified = func(caller, attrs) { + profiles.add(caller, attrs); + }; + }); + + public query func getProfile(userId : Principal) : async ?Profile { + profiles.get(userId) + }; +}; +``` + +```yaml +canisters: + - name: backend + settings: + environment_variables: + # Required. A local II (`ii: true`) has the same principal. + trusted_attribute_signers: "rdmx6-jaaaa-aaaaa-aaadq-cai" + # Required, comma-separated. The origin identities are DERIVED for. + frontend_origins: "https://your-app.icp.net" + # Optional, comma-separated; omit to reject all sso:* keys. + trusted_sso_domains: "your-org.com" +``` + +Unset `trusted_attribute_signers` rejects every bundle as untrusted; unset `frontend_origins` makes `_internet_identity_sign_in_finish` return `#err(#FrontendOriginsNotConfigured)`. The error variants (`#NoAttributes`, `#MalformedCandid`, `#FrontendOriginMismatch`, `#Stale`, `#UnknownNonce`, `#AmbiguousAttribute`, `#UntrustedSsoSource`, `#MixedSsoSources`) tell the frontend whether to retry with a fresh nonce or surface a bug. + +## Backend: Rust + +The `identity-attributes` crate is the Rust side of the `mo:identity-attributes` Motoko package: the same checks, the same environment variables, and the same Candid types, so one frontend works against either backend. + +```toml +[dependencies] +candid = "0.10" +ic-cdk = "0.20.1" +identity-attributes = "0.1" +``` + +```yaml +canisters: + - name: backend + settings: + environment_variables: + trusted_attribute_signers: "rdmx6-jaaaa-aaaaa-aaadq-cai" # required + frontend_origins: "https://your-app.icp.net" # required, comma-separated + trusted_sso_domains: "acme.com" # optional, comma-separated; omit to reject all sso: keys +``` + +`#[identity_attributes]` marks the function that receives the caller and their verified attributes, called only for a bundle that passes every check, and adds `_internet_identity_sign_in_start` and `_internet_identity_sign_in_finish`. The function cannot be `async` or generic: + +```rust +use candid::Principal; +use ic_cdk::query; +use identity_attributes::{identity_attributes, IdentityAttributes}; +use std::cell::RefCell; +use std::collections::BTreeMap; + +thread_local! { + // On the heap to keep the example short: an upgrade clears it. + static PROFILES: RefCell> = + const { RefCell::new(BTreeMap::new()) }; +} + +#[identity_attributes] +fn on_verified(caller: Principal, attributes: IdentityAttributes) { + PROFILES.with_borrow_mut(|profiles| profiles.insert(caller, attributes)); +} + +#[query] +fn get_profile(user_id: Principal) -> Option { + PROFILES.with_borrow(|profiles| profiles.get(&user_id).cloned()) +} + +ic_cdk::export_candid!(); +``` + +`IdentityAttributes` is `{ name: Option, email: Option, sso: Option }`: + +- `email` comes from `verified_email` (or `openid::verified_email`), and for an SSO sign-in from `sso::email`. The unverified `email` key of other sources is never read. +- `sso` is the organization's domain when the values came from `sso:` keys, and `None` otherwise. + +What the finish method checks, in order, and the `err` it returns: + +| Check | Error | +|---|---| +| A bundle is attached | `NoAttributes` | +| It decodes to an ICRC-3 `Value::Map` | `MalformedCandid` | +| `frontend_origins` is set | `FrontendOriginsNotConfigured` | +| `implicit:origin` is one of `frontend_origins` | `FrontendOriginMismatch { expected, got }` | +| `implicit:issued_at_timestamp_ns` is at most five minutes old | `Stale { ageNs }` | +| `implicit:nonce` was issued here and not consumed | `UnknownNonce` | +| A required implicit field is present | `MissingField` | +| Every `sso::*` key's domain is trusted | `UntrustedSsoSource { domain }` | +| Name and email come from `sso:` keys or from others, never both | `MixedSsoSources { ssoKeys, otherKeys }` | +| Each field comes from one key, and `sso:` keys from one domain | `AmbiguousAttribute { field, sources }` | + +The result is Candid `variant { ok; err : Error }`, so the frontend checks `"err" in result`. A call carrying a bundle traps when `trusted_attribute_signers` is unset or does not list the bundle's signer. + +A nonce does not survive an upgrade: a sign-in that was in flight across one fails with `UnknownNonce`, and the frontend's remedy is to start again from `_internet_identity_sign_in_start`. The same error answers a nonce older than five minutes, or one already spent. Treat `UnknownNonce` as "retry with a fresh nonce" rather than as a bug. + +`identity_attributes::sign_in_start()` and `identity_attributes::sign_in_finish(on_verified)` are the functions behind the two methods, for a canister that defines them itself. + +## Mistakes That Break Your Build + +1. **Generating the attribute nonce on the frontend.** The nonce passed to `requestAttributes` MUST come from the backend (`_internet_identity_sign_in_start`), or the canister cannot check that the bundle's `implicit:nonce` is one it issued, and replay protection is gone. +2. **Reading attribute data without verifying the signer.** The IC verifies the signature, not who signed: any canister can produce a valid bundle. The trusted signer is `rdmx6-jaaaa-aaaaa-aaadq-cai`. Use the `identity-attributes` library with `trusted_attribute_signers` and `frontend_origins` configured. Hand-written Rust must check `msg_caller_info_signer()` before reading `msg_caller_info_data()`, or an attacker canister can forge `email = "admin@you.com"`. +3. **Substituting `{tid}` in the Microsoft scoped-key prefix.** The `microsoft` provider URL is the literal `https://login.microsoftonline.com/{tid}/v2.0`; keys look like `openid:https://login.microsoftonline.com/{tid}/v2.0:email` exactly. Filling in a tenant ID misses every lookup. +4. **Treating `email` as verified.** `email` is the raw address from the user's II-linked account; II does not check it, so treat it as user input. `verified_email` is present only when II established that the user controls the address: an OpenID provider marked it verified, or the user verified it with II. Gate access on `verified_email` only. There is **no `verified_email` under `sso:`**: an organization's SSO cannot produce one; its `sso::email` is asserted by that organization's own provider, so trust it only for domains you list (`trusted_sso_domains`). What an organization can and cannot assert about its own staff is the `internet-identity-sso` skill. diff --git a/skills/internet-identity/references/notifications.md b/skills/internet-identity-notifications/SKILL.md similarity index 57% rename from skills/internet-identity/references/notifications.md rename to skills/internet-identity-notifications/SKILL.md index aee757b4..cd9f068a 100644 --- a/skills/internet-identity/references/notifications.md +++ b/skills/internet-identity-notifications/SKILL.md @@ -1,6 +1,24 @@ -# Notifications +--- +name: internet-identity-notifications +description: "Notify a signed-in Internet Identity user from your canister while the app is closed, with web push that II owns end to end, so there is no push service, no VAPID keys and no device tokens in the app. Covers consent in the frontend (requestNotificationConsent, getNotificationConsentStatus, revokeNotificationConsent, needing @icp-sdk/auth 11.1.0), the /.well-known/ii-notification-senders document, and sending with the identity-notifications library from Motoko or Rust. Use when an app must reach a user who is not looking at it, when adding web push or a notifications toggle, or when consent was granted and nothing arrives. Assumes sign-in already works and the app's primary origin is settled: load internet-identity first. Do NOT use for in-app notification UI or email, or for reaching anyone who is not a signed-in user of this app." +license: Apache-2.0 +compatibility: "@icp-sdk/auth >= 11.1.0, moc >= 2.0.0" +metadata: + title: Internet Identity Notifications + category: Auth +--- -Web push to a signed-in user while the app is closed. II owns the subscriptions: no push service, no VAPID keys, no device tokens in the app. +# Internet Identity Notifications + +## Prerequisite + +Load the `internet-identity` skill first. Two things from it are load-bearing here: the sender list is served from the app's **primary origin**, the same origin `ii-app-metadata` is read from, and that metadata is what names the app on its own notifications. + +A suite shares one sender list and one 10-canister cap across every app derived from that origin. + +## Notifications + +Your canister can notify a signed-in user while the app is closed. II owns the push subscriptions, so there is no push service, no VAPID keys, and no device tokens in the app. Three pieces, all required. Missing any one of them produces silence rather than an error at the point you left out. @@ -29,6 +47,31 @@ await authClient.revokeNotificationConsent(); - Asking a second time when consent is already held returns `{ granted: true }` without showing a screen, because the answer is read back from the canister rather than reported from the ceremony. - `getNotificationConsentStatus` is a query, so the answer is uncertified. Paint a toggle from it; do not gate anything on it. - Ask from a click. The ceremony renders, so it cannot run under `prompt: 'none'`. +- Consent is recorded against the identity, and the browser the user answered on is the one registered for delivery. Someone who wants your notifications on a second machine answers again there. The status call reports the consent alone and does not speak for the device in the user's hand. + +### Asking with the sign-in + +The sign-in button is already a click, so that is the cheapest place to ask. Run both from it and the user signs in and answers in one visit to II rather than two: + +```javascript +const [identity, { granted }] = await Promise.all([ + authClient.signIn(), + authClient.requestNotificationConsent(), +]); +``` + +Asking at any later moment needs a control of its own. + +### A toggle, later + +To show the user whether your app may notify them, and let them turn it off: + +```javascript +const { granted } = await authClient.getNotificationConsentStatus(); +await authClient.revokeNotificationConsent(); +``` + +Revoking is scoped to your app. It drops your origin's consent and whatever you still had queued for that user, and leaves their device subscriptions and every other app's consent alone. So dropping someone from your own recipient table revokes nothing, and revoking is not an unsubscribe. Errors worth branching on: @@ -40,7 +83,7 @@ Errors worth branching on: ## 2. The senders document -Serve from the **derivation origin**, the same origin as `/.well-known/ii-app-metadata`: +Serve from the **primary origin**, the same origin as `/.well-known/ii-app-metadata`: ```json { "senders": ["ryjl3-tyaaa-aaaaa-aaaba-cai"] } @@ -50,6 +93,8 @@ Serve from the **derivation origin**, the same origin as `/.well-known/ii-app-me - II caches what it reads, so a canister added is not accepted instantly and one removed keeps working for a while afterwards. - Without this document naming the sending canister, every send is refused. This is the most common cause of "nothing arrives and nothing errors". +A suite shares one sender list and one cap of 10 across every app derived from that origin. + ## 3. Sending Library: `identity-notifications`, same `send` in both languages, different wiring. Rust is on crates.io, Motoko on Mops (requires moc 2.0.0 or later). @@ -128,7 +173,7 @@ canisters: | `expiresAt` / `expires_at` | a short window | How long this is still worth sending. Set past the default for anything that matters later; short for anything worthless once the moment passes. | | `funnel` | none | Which funnel to count it in. A label per notification is an unbounded set of rows: use one per campaign or kind, not per send. | -### Choosing `urgency` and `expiresAt` +## Choosing `urgency` and `expiresAt` Different questions: `urgency` is where this sits against the origin's **other traffic**, `expiresAt` is whether it is still worth showing when it could be. Pick both together. @@ -144,7 +189,7 @@ Different questions: `urgency` is where this sits against the origin's **other t A short expiry does not make something urgent, and `High` does not make it last longer. `High` on everything removes the ordering it was reached for. -### `key` +## `key` The id is `hash(recipient, key)`, so a key reaches only that recipient's own entry: it cannot touch another user's notification or another app's. What it can do is collide with this app's other pending notification to the same recipient, and the later send wins. Derive `key` from the thing the notification is about, such as the conversation, rather than from whatever a caller passed in. @@ -152,15 +197,26 @@ The id is `hash(recipient, key)`, so a key reaches only that recipient's own ent ## What the app does and does not own -- **The app addresses a user, nothing finer.** Where and how a notification reaches them is II's and the user's to manage, and an app that reasons about it is coupling itself to something it cannot see. -- **II composes what is shown**, so the app's name is not in the app's own payload. Publishing `/.well-known/ii-app-metadata` is what names the app on its notifications as well as on the sign-in screen: `references/app-metadata.md`. +- **The app addresses a user, nothing finer.** II holds a queue per browser that identity has registered, so one `send` can surface on a laptop and a phone at once. Where and how a notification reaches them is II's and the user's to manage, and an app that reasons about it is coupling itself to something it cannot see. +- **II composes what is shown.** The user reads your app's name, then the title your canister sent. The name comes from `/.well-known/ii-app-metadata`, and falls back to your origin's hostname where you publish none, so do not put your app's name in `title` yourself: it arrives twice. Publishing that document is what brands the notifications as well as the sign-in screen: see "App metadata on the sign-in screen" in the `internet-identity` skill. - **Sending cannot fail the call it is made from.** `send` neither traps nor awaits, so a recipient who cannot be notified costs the endpoint nothing and returns nothing to branch on. What became of a notification is in `metrics()`. - **iOS needs II added to the Home Screen** for web push. The consent ceremony walks the user through it; the app does nothing. ## Upgrades -**Motoko: nothing to do.** The mixin keeps its own state and re-arms itself. +**Motoko: nothing to do.** There is no post-upgrade call to make and no ids to pass back in. + +**Rust: call `init` again from `post_upgrade`.** No timer survives a canister upgrade, so without that second call anything already queued stays queued and is never sent. It is not optional and it is not a no-op on a canister that was running a moment ago. + +Pass the same `MemoryId`s every time. Renumbering them on a running canister points the library at empty memories instead of the ones holding the queue, and whatever was waiting is gone. -**Rust: call `init` again from `post_upgrade`.** The library's state lives in the stable memories handed over, so an upgrade neither serialises nor restores any of it, and no timer survives an upgrade, so that second `init` is what arms a flush for anything still waiting. +## Mistakes That Break Your Build -Renumbering those `MemoryId`s on a canister already running abandons the old memories rather than migrating them, which discards whatever was waiting. +1. **Asking without a click.** The ceremony has to render, so it needs a user gesture: the sign-in button, or a control of your own. See "1. Consent, in the frontend". +2. **Asking under `prompt: 'none'`.** Same reason, and it raises `InteractionRequiredError` rather than answering a refusal. Ask again from a click. +3. **Treating a refusal as an error.** `granted: false` is the user's answer. Render it, and offer the control again later. +4. **Reading the answer as a boolean.** All three calls answer a record, so `const granted = await authClient.requestNotificationConsent()` type-checks and is always truthy. +5. **Leaving the senders document out.** Without `/.well-known/ii-notification-senders` naming the sending canister, every send is refused and nothing anywhere reports it. See "2. The senders document". +6. **Putting your app's name in `title`.** II puts it in front of the title you sent, so the user reads it twice. See "What the app does and does not own". +7. **Deriving `key` from whatever a caller passed in.** Two of your own sends under one key collide in that recipient's tray and the later one wins. See "`key`". +8. **Skipping `init` in `post_upgrade`, in Rust.** Anything queued stays queued and is never sent. The Motoko mixin needs no such call. See "Upgrades". diff --git a/skills/internet-identity/references/shared-sessions.md b/skills/internet-identity-shared-sessions/SKILL.md similarity index 51% rename from skills/internet-identity/references/shared-sessions.md rename to skills/internet-identity-shared-sessions/SKILL.md index 62c2ce19..6930af32 100644 --- a/skills/internet-identity/references/shared-sessions.md +++ b/skills/internet-identity-shared-sessions/SKILL.md @@ -1,10 +1,26 @@ -# Shared sessions across subdomains +--- +name: internet-identity-shared-sessions +description: "Share one Internet Identity sign-in across sibling subdomains, so signing in on one app signs the user in on the others without a second visit to II, and signing out on one signs them out everywhere. Covers the shared record with CookieStateStorage, the /reauth route that silently acquires each app's own credential under transport: 'redirect' and prompt: 'none', the per-origin /.well-known/ii-auth-callbacks document, reading signed-in-elsewhere on load and offering it mid-session, and what ends a shared session. Use when two or more apps under one domain must share one signed-in user, when moving between them asks the user to sign in again, or when /reauth bounces in a loop. Every app must already derive from one elected primary origin: load internet-identity and settle that first. Do NOT use for a single app on one origin, which needs none of this, or for apps on unrelated domains, which cannot share a cookie." +license: Apache-2.0 +compatibility: "@icp-sdk/auth >= 11.0.0" +metadata: + title: Internet Identity Shared Sessions + category: Auth +--- -`chat.example.com` and `hr.example.com` can share one sign-in: sign in on one and the others are signed in without a second visit to Internet Identity, and signing out on one signs the user out on all of them. +# Internet Identity Shared Sessions + +## Prerequisite + +Load the `internet-identity` skill first and settle "Your app's identity is its primary origin". Everything here assumes one elected primary origin whose `ii-alternative-origins` already authorizes every sibling. + +That is not a courtesy link. Subdomains share a sign-in only when they share a principal, and a shared cookie does not produce one. Build this on top of separate principals and every app reads a record naming an account it can never hold, which is the `/reauth` loop in that skill's mistakes list. -## 0. One derivation origin first +## What this gets you -Principals are per origin, so every app must derive from **one** derivation origin, listed in that origin's `/.well-known/ii-alternative-origins` (see "Serving an app at more than one origin" in SKILL.md). Without it each subdomain gets its own principal and there is nothing to share; a shared cookie does not change that. +`chat.example.com` and `hr.example.com` can share one sign-in: sign in on one and the others are signed in without a second visit to Internet Identity, and signing out on one signs the user out on all of them. + +Principals are per origin, so every app must derive from **one** primary origin, listed in that origin's `/.well-known/ii-alternative-origins`. Without it each subdomain gets its own principal and there is nothing to share. ## 1. Share the record @@ -14,7 +30,7 @@ Every app builds its client with the same derivation origin and the same cookie import { AuthClient, CookieStateStorage, InteractionRequiredError } from "@icp-sdk/auth/client"; const clientOptions = { - derivationOrigin: "https://auth.example.com", + derivationOrigin: "https://.icp.net", stateStorage: new CookieStateStorage({ domain: "example.com" }), }; ``` @@ -59,7 +75,7 @@ async function reauth() { reauth(); ``` -Without `hint`, a provider holding more than one session refuses rather than guessing: `InteractionRequiredError` with `reason` `account_selection_required`. A mint for an unexpected account is rejected client-side as `AccountMismatchError`. +Without `hint`, a provider holding more than one session refuses rather than guessing: `InteractionRequiredError` with `reason` `account_selection_required`. If what comes back is for a different account than `hint` asked for, the client refuses it and throws `AccountMismatchError`. ## 3. Declare the callback on every app origin @@ -110,6 +126,28 @@ authClient.subscribe(() => { ## What ends a shared session -- `signOut()` ends the session at Internet Identity, clears this app's credentials, and removes the shared record. Siblings keep a delegation of their own until it expires (at most five minutes), then their next mint is refused and they read `signed-out`. +**Every state change travels the same way: through the shared record.** Whatever happens to the session, on this app or a sibling, the record is what carries it, each app's `subscribe()` fires, and `getStatus()` answers the new state. There is no second mechanism and no waiting period to account for, so the three cases below differ in what they mean, not in how they reach the other apps. + +- `signOut()` ends the session at Internet Identity, clears this app's credentials, and removes the shared record. Every sibling reads `signed-out`. - `maxTimeToIdle` on `signIn()` ends the session when nobody uses any of the apps; omitted, Internet Identity applies seven days. -- A sign-in elsewhere replaces the browser's session: a sibling holding the old chain drops it on its next mint, leaves the shared record alone, and goes through `/reauth` silently. +- A sign-in elsewhere replaces the session for the whole domain. The record now names a different account, so a sibling that has no credential for it reads `signed-in-elsewhere` and goes through `/reauth` silently, exactly as on a first sign-in. + +### Render from the subscription, not from a value read at load + +A tab finds out as soon as its browser can tell it. Reading a cookie raises no event, so where the browser has the Cookie Store API that is immediate, and otherwise it is the next time that page is shown or its window regains focus. + +So there is a window where one tab knows and another does not, and it is the ordinary case rather than an edge case: + +- The user has `hr.example.com` open in one tab and `chat.example.com` in another. +- They sign out in the HR tab. The shared record is gone immediately. +- The chat tab is in the background and has not been told yet. It is still showing the signed-in screen, with the user's name in the corner and their chats on it. + +That tab fixes itself only if it re-reads the state when it hears. So call `getStatus()` inside the `subscribe()` callback and render from that, rather than from a status read once when the page loaded: + +```javascript +// Re-read on every change, including a sibling's sign-out. +authClient.subscribe(() => render(authClient.getStatus())); +render(authClient.getStatus()); // and once for the initial paint +``` + +A page that reads the status only at load keeps showing a signed-in screen to a user who is signed out, until something else happens to reload it. diff --git a/skills/internet-identity/references/enterprise-sso.md b/skills/internet-identity-sso/SKILL.md similarity index 59% rename from skills/internet-identity/references/enterprise-sso.md rename to skills/internet-identity-sso/SKILL.md index 48c8828f..11c8dfa6 100644 --- a/skills/internet-identity/references/enterprise-sso.md +++ b/skills/internet-identity-sso/SKILL.md @@ -1,6 +1,20 @@ -# Enterprise SSO: the organization's side +--- +name: internet-identity-sso +description: "Set up an organization's own single sign-on for Internet Identity, so staff sign in to II-powered apps against Okta, Entra ID, Google Workspace or Auth0. Nothing registers with II: it discovers the configuration from /.well-known/ii-openid-configuration on the organization's domain. Covers the OIDC client, the discovery file and every field, per-app clients including salted-hash keys, gate_all_apps, stable_identifier_claim, the limits that reject the whole file, and II's caching windows. Use when an organization wants its employees to sign in with its own identity provider, when II offers no SSO for a domain that has published the file, or when an edit to that file has not taken effect yet. This is the organization's side, done by whoever administers the domain. For an app sending users there with ssoDomain, load internet-identity. Do NOT use for signing in to II with a personal Google, Apple or Microsoft account, which is ordinary OpenID and needs none of this." +license: Apache-2.0 +compatibility: "an OIDC provider (Okta, Entra ID, Google Workspace or Auth0), HTTPS hosting for the organization's domain" +metadata: + title: Internet Identity Enterprise SSO + category: Auth +--- -Internet Identity authenticates an organization's staff against its own OpenID Connect provider (Okta, Entra ID, Google Workspace, Auth0). Nothing is registered with Internet Identity: it discovers the configuration from one file on the organization's domain. Apps then send users there with `new AuthClient({ ssoDomain: "acme.com" })` (see "One-click sign-in" in SKILL.md). +# Internet Identity Enterprise SSO + +## What this is, and who does it + +Internet Identity authenticates an organization's staff against its own OpenID Connect provider (Okta, Entra ID, Google Workspace, Auth0). Nothing is registered with Internet Identity: it discovers the configuration from one file on the organization's domain. + +This is work an organization's IT administrator does once, on the organization's own domain and identity provider. It is not app code, and no app can do it. The app side is one constructor option, `new AuthClient({ ssoDomain: "acme.com" })`, documented in the `internet-identity` skill along with the `getSsoStatus()` check that tells an app whether a domain has SSO at all. ## 1. Register an OIDC client @@ -34,8 +48,8 @@ Serve it over HTTPS at exactly `https:///.well-known/ii-openid-configura | Field | Required | Default | Meaning | |---|---|---|---| -| `client_id` | yes | | The client from step 1 | -| `openid_configuration` | yes | | The provider's OIDC discovery URL | +| `client_id` | yes | | The client from step 1 | +| `openid_configuration` | yes | | The provider's OIDC discovery URL | | `name` | no | the domain | Label on the sign-in screen, and `available.name` from `getSsoStatus()` | | `session_max_age_seconds` | no | `28800` (8 hours) | How long a sign-in through this domain stays valid; also caps an app's own session length | | `app_clients` | no | none | Maps an app's exact origin, or a salted hash of it, to that app's own client | @@ -65,13 +79,13 @@ Cleartext and hashed keys can be mixed in one file. Changing `stable_identifier_ Internet Identity rejects the entire file, not just the offending field, when any of these is exceeded. SSO is then unavailable for the whole domain (`getSsoStatus()` reads `unavailable`): -- the file larger than 64 KiB (the same limit applies to the provider's OIDC discovery document) -- more than 100 `app_clients` entries -- an `app_clients` key or client id longer than 255 bytes -- `app_clients` keys and client ids together longer than 16 KiB -- `client_id`, `name`, or `stable_identifier_claim` longer than 255 bytes -- `session_max_age_seconds` of `0` or above `2592000` (30 days); it is rejected, not clamped -- in the OIDC discovery document: `issuer`, `jwks_uri`, or `authorization_endpoint` longer than 255 bytes +- the file larger than 64 KiB (the same limit applies to the provider's OIDC discovery document); +- more than 100 `app_clients` entries; +- an `app_clients` key or client id longer than 255 bytes; +- `app_clients` keys and client ids together longer than 16 KiB; +- `client_id`, `name`, or `stable_identifier_claim` longer than 255 bytes; +- `session_max_age_seconds` of `0` or above `2592000` (30 days); it is rejected, not clamped; +- in the OIDC discovery document: `issuer`, `jwks_uri`, or `authorization_endpoint` longer than 255 bytes. ## When changes take effect @@ -82,3 +96,11 @@ Internet Identity caches each domain's discovery result: - a refresh that fails stops serving the old result 1 hour past freshness, then backs off (60 s, doubling) before retrying. So removing an `app_clients` entry or enabling `gate_all_apps` applies after the next refresh, normally within an hour of active use; the first sign-in after a quiet period is checked against the cached result while the refresh runs. The provider's signing keys (JWKS) are cached separately for at most 1 hour past freshness, so key rotation is picked up within that window. + +## Two more things an admin needs to know + +**No `verified_email` under `sso:`.** An organization's SSO cannot produce a verified email. Its `sso::email` is asserted by the organization's own provider, so an app trusts it only for domains it lists. + +**Older `@icp-sdk/auth` majors checked the domain from the browser.** `isValidSsoDomain()` in 10.x and earlier fetched the discovery file cross-origin and needed `Access-Control-Allow-Origin: *` on it. 11.x checks through II server-side, so the header is no longer required, but leave it in place while any app on an older major still signs in through this domain. + +The second is the one most likely to bite: an admin reading only the current documentation would remove a header that older clients still depend on. diff --git a/skills/internet-identity/SKILL.md b/skills/internet-identity/SKILL.md index c14df7b6..1f7a3713 100644 --- a/skills/internet-identity/SKILL.md +++ b/skills/internet-identity/SKILL.md @@ -1,8 +1,8 @@ --- name: internet-identity -description: "Integrate Internet Identity authentication. Covers passkey, OpenID (Google, Apple, Microsoft), and organization SSO sign-in with @icp-sdk/auth, the SSO domain check (getSsoStatus), identity attributes (verified email, name) and their backend verification, sessions shared across subdomains, alternative origins, the /.well-known/ii-app-metadata document, and web push notifications (consent, the sender list, and sending from a Motoko or Rust canister). Use when adding sign-in, login, auth, passkeys, SSO, notifications, web push, or Internet Identity to a frontend or canister. Do NOT use for wallet integration or ICRC signer flows (use wallet-integration) or for an agent or CLI signing in as a user (use agent-web-identity)." +description: "Add Internet Identity sign-in to a frontend or canister: passkey and OpenID (Google, Apple, Microsoft) sign-in with @icp-sdk/auth, sending users to an organization's SSO with ssoDomain, the client lifecycle, and local development. It also covers the primary origin an app's principals are permanently tied to: electing it, setting derivationOrigin, the ii-alternative-origins and ii-derivation-origin documents, and the ii-app-metadata document that names the app on the sign-in screen. Use when adding sign-in, login, auth, passkeys or Internet Identity to an app, when an app gains a custom domain or a sibling app, or when the same user arrives as a different principal on two origins. For a verified email or name in the backend use internet-identity-attributes; for web push use internet-identity-notifications; to set up an organization's own SSO use internet-identity-sso. Do NOT use for wallet integration or ICRC signer flows (use wallet-integration) or for an agent or CLI signing in as a user (use agent-web-identity)." license: Apache-2.0 -compatibility: "icp-cli >= 0.2.4, Node.js >= 22, moc >= 1.6.0" +compatibility: "icp-cli >= 0.2.4, Node.js >= 22" metadata: title: Internet Identity category: Auth @@ -14,25 +14,15 @@ metadata: Internet Identity (II) is the Internet Computer's native authentication system. Users sign in to II-powered apps with passkeys, with an OpenID account (Google, Apple, Microsoft), or through their organization's own SSO; no usernames or passwords. Each user gets a unique principal per app origin, preventing cross-app tracking. -Reference material for this skill lives in `references/`: - -- `references/shared-sessions.md`: sharing one sign-in across sibling subdomains, end to end. -- `references/app-metadata.md`: every validation rule for `/.well-known/ii-app-metadata`. -- `references/enterprise-sso.md`: the organization's side of SSO (OIDC client, discovery file, limits, caching). -- `references/rust-identity-attributes.md`: the Rust backend for identity attributes, with the `identity-attributes` crate. -- `references/notifications.md`: notifying a signed-in user, end to end: consent, the sender list, sending, and delivery semantics. -- `references/older-api.md`: how `@icp-sdk/auth` 10.x and older differ. - ## Prerequisites - `@icp-sdk/auth@^11` with `@icp-sdk/core@^6`. **Pin both majors together.** auth 11 and 10 peer `@icp-sdk/core@^6`; auth 9 peers `^5`. -- For the Motoko backend: `mo:identity-attributes` >= 0.4.0 (mops), the mixin that injects the two sign-in methods and verifies the attribute bundle. It pulls in `mo:core` >= 2.5.0 and requires `moc` >= 1.6.0 for `include`. ## Canister IDs | Canister | ID | URL | Purpose | -|----------|----|-----|---------| -| Internet Identity (backend) | `rdmx6-jaaaa-aaaaa-aaadq-cai` | | Mints delegations, signs attribute bundles | +|---|---|---|---| +| Internet Identity (backend) | `rdmx6-jaaaa-aaaaa-aaadq-cai` | | What sign-in runs against; signs attribute bundles | | Internet Identity (frontend) | `uqzsh-gqaaa-aaaaq-qaada-cai` | `https://id.ai` | Serves the sign-in UI; `authorizeUrl` points here | Both IDs are identical on mainnet and on a local network. Hardcode them. @@ -40,56 +30,18 @@ Both IDs are identical on mainnet and on a local network. Hardcode them. ## Mistakes That Break Your Build 1. **Wrong II URL for the environment.** `authorizeUrl` points at the **frontend** canister: `https://id.ai/authorize` on mainnet, `http://id.ai.localhost:8000/authorize` for a local II (`ii: true`). The URL is used verbatim, so include `/authorize`: `https://id.ai` opens the II home page and never returns a delegation. - 2. **Passing `identityProvider` as a string, or half of it.** It is an object, `{ authorizeUrl, canisterId }`, both required together; a string or `URL` throws a `TypeError`. Omit the option entirely to get mainnet II, which is what most apps want. - -3. **Top-level `await` in frontend code.** Vite 6 and earlier target `es2020`-era browsers by default, which have no top-level await, so the build fails; Vite 7 allows it. Wrap the setup in an `async function init()` (or a framework lifecycle hook). Code written that way builds on every version. Do NOT change `build.target` to `esnext` to make it compile. - +3. **Top-level `await` in frontend code.** Vite 6 and earlier target `es2020`-era browsers by default, which have no top-level await, so the build fails; Vite 7 allows it. Wrap the setup in an `async function init()` (or a framework lifecycle hook). Do NOT change `build.target` to `esnext` to make it compile. 4. **Treating `maxTimeToLive` as the lifetime of the signing key.** It bounds the **session** at II, and `maxTimeToIdle` ends a session nobody uses; the delegation your calls are signed with is short-lived and replaced by the client. Leave both unset unless the app has its own policy: II applies seven days idle and thirty days in total. - 5. **Not awaiting `signIn()` or skipping the `try`/`catch`.** `signIn()` rejects when the user closes the popup or authentication fails. - 6. **`shouldFetchRootKey` or `fetchRootKey()` in browser code.** The `ic_env` cookie (set by the frontend canister or the Vite dev server) carries the root key as `IC_ROOT_KEY`: pass it as `rootKey` to `HttpAgent.create()` and leave `host` unset. Fetching the root key at runtime lets a man-in-the-middle substitute a fake one on mainnet. - 7. **Getting `2vxsx-fae` as the principal.** That is the anonymous principal: authentication silently failed. Usual causes: a wrong `authorizeUrl` (especially missing `/authorize`), an unhandled `signIn()` rejection, or reading the identity before `signIn()` resolved. `getIdentity()` throws `SessionNotHeldError` (not an anonymous identity) when a sign-in exists that this origin holds no credential for. - -8. **Passing the principal as an argument.** The backend reads the caller from the IC protocol: `msg.caller` in Motoko, `ic_cdk::api::msg_caller()` in Rust. For access-control patterns see the **canister-security** skill. - -9. **Adding `derivationOrigin` or `ii-alternative-origins` for the gateway domains.** II canonicalizes `ic0.app`, `icp0.io`, and `icp.net` to one form, so a canister served at any of them gets the same principal. Adding the configuration for that breaks authentication. A different principal there means a different passkey or device. A genuine second origin (a custom domain) does need it: see "Serving an app at more than one origin". - -10. **Generating the attribute nonce on the frontend.** The nonce passed to `requestAttributes` MUST come from the backend (`_internet_identity_sign_in_start`), or the canister cannot check that the bundle's `implicit:nonce` is one it issued, and replay protection is gone. - -11. **Reading attribute data without verifying the signer.** The IC verifies the signature, not who signed: any canister can produce a valid bundle. The trusted signer is `rdmx6-jaaaa-aaaaa-aaadq-cai`. Use the `identity-attributes` library (the `mo:identity-attributes` mixin in Motoko, the `#[identity_attributes]` attribute in Rust) with `trusted_attribute_signers` and `frontend_origins` configured. Hand-written Rust must check `msg_caller_info_signer()` before reading `msg_caller_info_data()`, or an attacker canister can forge `email = "admin@you.com"`. - -12. **Substituting `{tid}` in the Microsoft scoped-key prefix.** The `microsoft` provider URL is the literal `https://login.microsoftonline.com/{tid}/v2.0`; keys look like `openid:https://login.microsoftonline.com/{tid}/v2.0:email` exactly. Filling in a tenant ID misses every lookup. - -13. **Treating `email` as verified.** `email` is the raw address from the user's II-linked account; II does not check it, so treat it as user input. `verified_email` is present only when II established that the user controls the address: either an OpenID provider (e.g. Google) marked it verified, or the user linked and verified that email with II directly. Gate access on `verified_email` only. There is **no `verified_email` under `sso:`**: an organization's SSO cannot produce one; its `sso::email` is asserted by that organization's own provider, so trust it only for domains you list. - -14. **Sharing a cookie domain without a shared `derivationOrigin`.** Subdomains only share a sign-in when they share a principal. Without one derivation origin authorized for all of them, the shared record names an account the reading origin can never hold, and `/reauth` bounces the user forever. See `references/shared-sessions.md`. - -15. **A silent re-issue without `hint`, on the default transport, or to an undeclared callback.** `prompt: 'none'` without `hint` fails with `InteractionRequiredError` (`reason` `account_selection_required`) when II holds more than one session. It runs on page load with no user gesture, so it needs `transport: 'redirect'` on a route of its own, declared in the origin's `/.well-known/ii-auth-callbacks`, or the redirect never comes back. - -16. **Serving `/.well-known/ii-app-metadata` on the wrong origin, without CORS, or with one bad field.** II reads it from the derivation origin (when set) or the request origin, the document and the logo both need CORS, and one invalid field invalidates the whole document. See `references/app-metadata.md`. - -17. **Installing majors that do not pair.** auth 11 peers `@icp-sdk/core@^6`; pinning core to `^5` out of habit gives: - - ```text - npm error ERESOLVE unable to resolve dependency tree - npm error Found: @icp-sdk/core@5.4.0 - npm error peer @icp-sdk/core@"^6" from @icp-sdk/auth@11.0.0 - ``` - - Do not clear it with `--legacy-peer-deps` or `--force`. Pin `@icp-sdk/auth@^11` with `@icp-sdk/core@^6`, or stay on `@icp-sdk/auth@^9` if something else holds you on core 5. - -18. **Signing in against a local II without `agentOptions`.** The client mints delegations by calling the II canister through an agent that verifies against the mainnet root key by default. With a local II the ceremony completes and the mint then fails with `TrustError: Certificate verification error` (`"Invalid signature"`). Pass `agentOptions: { rootKey }` from the `ic_env` cookie and leave `host` unset. A local II from an older network launcher lacks the minting methods: run `icp network update` and restart the network. - -19. **Scheduling a logout from the delegation's expiration.** The delegation `getIdentity()` signs with is short-lived and replaced as it ages, so a timer from `identity.getDelegation()`'s expiration signs the user out after minutes. The session's end arrives as an `expired` status: subscribe, and leave the signed-in view when `isAuthenticated()` turns false. - -20. **Checking an SSO domain from the browser.** Do not `fetch('https:///.well-known/ii-openid-configuration')` and do not look for `isValidSsoDomain` (removed in 11.x). Build the client with `ssoDomain` and read `getSsoStatus()`: II checks the domain against the same rules it signs in with, and the organization needs no CORS header. - -21. **Awaiting the SSO check before `signIn()`, or wrapping the constructor in `try`/`catch` for the domain.** `signIn()` must be called synchronously in the click handler, or Safari blocks the popup. It proceeds in every SSO state, `invalid` included (II's own screens show progress and errors). In every state it rejects when the user closes the popup or authentication fails, so attach a rejection handler. The constructor never throws for a malformed domain: it reports `invalid`. - -22. **Changing `ssoDomain` or `openIdProvider` on an existing client.** Both are constructor-only, like every other option; there is no setter and no per-`signIn()` override. For a new domain, build a new client and `dispose()` the superseded one. +8. **Passing the principal as an argument.** The backend reads the caller from the IC protocol: `msg.caller` in Motoko, `ic_cdk::api::msg_caller()` in Rust. An argument is whatever the caller typed and proves nothing. Role guards and anonymous-principal rejection are in the `canister-security` skill. +9. **Letting the first origin with users be one you can lose.** Decided in "Your app's identity is its primary origin" below, before writing any sign-in code. The origin an account is created under is permanent: repointing it hands every returning user a new, empty account, so whether this app will have siblings has to be settled *before* it has users. What is *not* a mistake: setting `derivationOrigin` to the origin you are already served at is a validated no-op, not an error. II compares the two in canonical form and skips validation entirely, which is also why `ic0.app`, `icp0.io` and `icp.net` are interchangeable and a single-origin app needs no configuration at all. +10. **Sharing a cookie domain without a shared `derivationOrigin`.** Elect the primary origin first ("Your app's identity is its primary origin" below), then build the rest from the `internet-identity-shared-sessions` skill. Subdomains share a sign-in only when they share a principal, and a cookie does not produce one: without one primary origin authorized for all of them, the shared record names an account the reading origin can never hold, and `/reauth` bounces the user forever. +11. **A silent re-issue without `hint`, on the default transport, or to an undeclared callback.** All three are the `/reauth` route of a shared sign-in, which the `internet-identity-shared-sessions` skill builds end to end. `prompt: 'none'` without `hint` fails with `InteractionRequiredError` (`reason` `account_selection_required`) when II holds more than one session. It runs on page load with no user gesture, so it needs `transport: 'redirect'` on a route of its own, declared in the origin's `/.well-known/ii-auth-callbacks`, or the redirect never comes back. +12. **Serving `/.well-known/ii-app-metadata` on the wrong origin, without CORS, or with one bad field.** Every rule is in "App metadata on the sign-in screen" below, including which characters and sizes are rejected. In short: II reads the document from the primary origin (when set) or the request origin, the document and the logo both need CORS, and one invalid field invalidates the whole thing rather than just that field. +13. **Installing majors that do not pair.** auth 11 peers `@icp-sdk/core@^6`; pinning core to `^5` out of habit breaks the build. ## Using II during local development @@ -187,12 +139,12 @@ A client built with `ssoDomain` checks its own domain through II as soon as it i ```typescript type SsoStatus = | { state: "checking" } - | { state: "available"; name?: string } // name from the org's file: "Continue with Acme Corp" - | { state: "invalid"; reason: string } // not a domain: show reason, the user fixes the input - | { state: "unavailable"; retryAfter?: Date; reason?: string }; // no SSO there, or failing; reason is II's, when given + | { state: "available"; name?: string } // name from the org's file + | { state: "invalid"; reason: string } // not a domain: the user fixes the input + | { state: "unavailable"; retryAfter?: Date; reason?: string }; ``` -Checking while the user types: +Checking while the user types, one client per pause rather than per keystroke. A superseded client is disposed before it can publish, so stale results never render: ```javascript let client; @@ -203,7 +155,7 @@ input.addEventListener("input", () => { client = undefined; // Continue never signs in to the old domain showSpinner(); - clearTimeout(timer); // debounce: one client per pause, not per keystroke + clearTimeout(timer); // debounce timer = setTimeout(() => { const next = new AuthClient({ ssoDomain: input.value }); // never throws for the domain next.subscribe(() => render(next.getSsoStatus())); @@ -212,213 +164,202 @@ input.addEventListener("input", () => { }, 300); }); -function render(sso) { - switch (sso.state) { - case "checking": return showSpinner(); - case "available": return enableContinue(sso.name); - case "invalid": return showNotADomain(sso.reason); - case "unavailable": return showUnavailable(sso.retryAfter, sso.reason); - } -} - // Synchronous in the click handler: never await the check first. continueButton.addEventListener("click", () => { client?.signIn().then(showApp, showSignInError); }); ``` -- A superseded client is disposed before it can publish, so stale results never render. Debounce the input as above: building a client per keystroke churns clients and checks. -- `refreshSsoStatus()` re-runs the check for a "Try again" button. While `retryAfter` is in the future, II answers `unavailable` again at once with no new fetch, so disable the button and show a countdown ("Try again in 2 min"). It does nothing on an `invalid` client: the user fixes the input, which builds a new client. A client never re-checks by itself. -- `signIn()` proceeds in every state: II resolves the domain itself and shows its own loading and error screens, for an `invalid` value too. A closed popup or failed authentication rejects in any state. -- `invalid` follows II's own rules: at least two labels of 1 to 63 letters, digits, or hyphens, no hyphen at either end, no `--` in a label's third and fourth places (punycode `xn--` aside), at most 253 characters, and a port only on a loopback host (`localhost`, `127.0.0.1`, or a name under `.localhost`). Its `reason` names every problem found. +- `refreshSsoStatus()` re-runs the check for a "Try again" button. While `retryAfter` is in the future, II answers `unavailable` again at once with no new fetch, so disable the button and show a countdown. It does nothing on an `invalid` client, and a client never re-checks by itself. +- `signIn()` proceeds in every state: II resolves the domain itself and shows its own loading and error screens, for an `invalid` value too. +- `invalid` follows II's own rules: at least two labels of 1 to 63 letters, digits, or hyphens, no hyphen at either end, no `--` in a label's third and fourth places (punycode `xn--` aside), at most 253 characters, and a port only on a loopback host. Its `reason` names every problem found. - The check warms II's cache, so `signIn()` on the same client starts fast. -How the organization sets up its side (OIDC client, the `ii-openid-configuration` file, per-app access, the limits that reject the file, caching): `references/enterprise-sso.md`. +The organization's own side of this, which is the OIDC client, the `ii-openid-configuration` file, per-app access, the limits that reject the file and the caching windows, is the `internet-identity-sso` skill. -## Identity attributes +## Your app's identity is its primary origin -When the backend needs more than the principal (e.g. a verified email), II returns a signed attribute bundle alongside the delegation. The backend exposes two methods: `_internet_identity_sign_in_start` mints a nonce, `_internet_identity_sign_in_finish` verifies the bundle. The `identity-attributes` library adds both: the `mo:identity-attributes` mixin in Motoko, and the `#[identity_attributes]` attribute in Rust (`references/rust-identity-attributes.md`). Both return the same Candid types, so the frontend is the same against either. +Internet Identity derives a principal per origin. Nothing in the sign-in flow above sets one, and that is correct: with no `derivationOrigin`, principals derive from the origin you are served at, which for a new app is its canister address. You already have an app identity, and it is already anchored on something nobody can take away. -### Keys +You reach for `derivationOrigin` only when that stops being enough, which happens in exactly two situations: the app gains a second origin, usually a custom domain, or it gains a sibling app whose users have to be the same people. Both are below. -`requestAttributes({ keys, nonce })` requires both; there is no default key set. +### Decide before your first sign-in -| Key | Meaning | Use for | -|---|---|---| -| `name` | Display name from the user's II-linked account | Personalisation | -| `email` | Raw address from the II-linked account; **II does not check it** | Contact info, mailing lists | -| `verified_email` | Present only when II established the user controls the address (an OpenID provider marked it verified, or the user verified it with II) | Access gating; the only email to authorise on | +The origin an account is created under cannot be changed afterwards: repointing it orphans every account made there. So the question to settle before shipping is not "do I have a custom domain yet" but **is this one app, or the first of several?** -Request both `email` and `verified_email` for fallback: when verified, both carry the same value; otherwise only `email` arrives. +- **One app.** Nothing to do. The canister address is the identity, now and later. Adding a domain later is handled below and costs nothing. +- **The first of several.** Create the canister that will serve the primary origin before the first app ships, and point that app at it from its first deploy. -`scopedKeys` scopes keys to the account the user signs in with, so they are shared in the same step with no extra prompt: +You do not need to know what the suite will contain. The `ii-alternative-origins` list grows as each app ships; what cannot change is which origin the list lives on. -| Call | Keys | Default `keys` | -|---|---|---| -| `scopedKeys({ openIdProvider: 'google' })` | `openid:https://accounts.google.com:` | `name`, `email`, `verified_email` | -| `scopedKeys({ openIdProvider: 'apple' })` | `openid:https://appleid.apple.com:` | same | -| `scopedKeys({ openIdProvider: 'microsoft' })` | `openid:https://login.microsoftonline.com/{tid}/v2.0:` (`{tid}` literal) | same | -| `scopedKeys({ ssoDomain: 'acme.com' })` | `sso:acme.com:` | `name`, `email` (no `verified_email`) | +### Adding a domain -Pass `keys` to narrow, e.g. `scopedKeys({ openIdProvider: 'google', keys: ['name', 'verified_email'] })`. +A custom domain is a second origin, so left alone it is a second set of users. Keep the identity on the canister address and let the domain derive against it. -### Frontend +1. The domain passes `derivationOrigin`; the canister origin does not: ```javascript -import { AuthClient } from "@icp-sdk/auth/client"; -import { AttributesIdentity } from "@icp-sdk/core/identity"; -import { HttpAgent, Actor } from "@icp-sdk/core/agent"; -import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env"; -import { Principal } from "@icp-sdk/core/principal"; - -const II_PRINCIPAL = "rdmx6-jaaaa-aaaaa-aaadq-cai"; -const rootKey = safeGetCanisterEnv()?.IC_ROOT_KEY; - -async function signInWithAttributes(authClient, canisterId, idl) { - // Anonymous handle, used only to mint the nonce. - const anonymousActor = Actor.createActor(idl, { agent: await HttpAgent.create({ rootKey }), canisterId }); - - // In parallel, one interaction for the user. `nonce` is a function the client - // calls when it needs the value, so the request is in flight while II opens. - const signInPromise = authClient.signIn(); - const attributesPromise = authClient.requestAttributes({ - keys: ["name", "verified_email"], - nonce: () => anonymousActor._internet_identity_sign_in_start(), - }); - - const identity = await signInPromise; - const attributes = await attributesPromise; - - // The bundle travels as sender_info on each call made with this identity. - const verifiedAgent = await HttpAgent.create({ - identity: new AttributesIdentity({ - inner: identity, - attributes, - signer: { canisterId: Principal.fromText(II_PRINCIPAL) }, - }), - rootKey, - }); - const verifiedActor = Actor.createActor(idl, { agent: verifiedAgent, canisterId }); - - const result = await verifiedActor._internet_identity_sign_in_finish(); - if ("err" in result) { - throw new Error(`Attribute verification failed: ${JSON.stringify(result.err)}`); - } - return identity; -} +const authClient = new AuthClient({ derivationOrigin: "https://.icp.net" }); ``` -For one-click sign-in, build the client with `openIdProvider` or `ssoDomain`, add `scopedKeys` to the `@icp-sdk/auth/client` import, and request `scopedKeys({ openIdProvider: "google", keys: ["name", "verified_email"] })` or `scopedKeys({ ssoDomain: "acme.com" })` instead; the rest is unchanged. +2. The canister origin serves `/.well-known/ii-alternative-origins`: -Every bundle carries three implicit fields the backend MUST verify: `implicit:nonce` (one it issued and has not consumed), `implicit:origin` (a trusted frontend origin), `implicit:issued_at_timestamp_ns` (fresh, typically within five minutes). +```json +{ "alternativeOrigins": ["https://shop.example.com"] } +``` -### Backend: Motoko +At most **100** entries, origins only (no trailing slash, no path). Over the cap II rejects the **entire list**, so every alternative origin stops authenticating, not just the extras. II canonicalizes `ic0.app`, `icp0.io` and `icp.net` to one form, so one entry per origin covers all three gateway spellings. -```toml -[dependencies] -identity-attributes = "0.4.1" -core = "2.5.0" +3. With `@dfinity/static-site`, add a `_headers` block (the file has no extension, and certified assets set no CORS header by default): -[toolchain] -moc = "1.6.0" +``` +/.well-known/ii-alternative-origins + Content-Type: application/json + Access-Control-Allow-Origin: * ``` -`include IdentityAttributes({ onVerified })` injects both methods and runs `onVerified` only on a bundle that passes the signer, origin, nonce, and freshness checks. It resolves the bundle to `{ name : ?Text; email : ?Text; sso : ?Text }`: +Pointing the derivation origin at the domain instead works, since II validates it like any other origin, but is not recommended. A domain can lapse, be sold or be rebranded, and every account derived from it goes with it. A canister address cannot be taken away. -- OpenID and unscoped sources: `email` comes from `verified_email` (or `openid::verified_email`), which is why the frontend requests `verified_email`. -- SSO sources: `name` and `email` come from `sso::name` / `sso::email`, accepted only for a domain in `trusted_sso_domains`, and `sso` is that domain. Otherwise `sso` is `null`. +### A suite that shares one sign-in -```motoko -import IdentityAttributes "mo:identity-attributes"; -import Map "mo:core/Map"; -import Principal "mo:core/Principal"; +Apps that share a sign-in must share a principal, so they must all derive from one origin, and someone has to elect it. Elect the origin that represents the whole product rather than one app inside it, such as an app switcher, a landing page or an account portal. Failing that, elect a canister that exists only to serve the well-known documents. -persistent actor { - type Profile = { name : ?Text; email : ?Text; sso : ?Text }; +Three properties make a good primary origin: - let profiles = Map.empty(); +- **It is the front door.** `ii-app-metadata` is read from the primary origin, so the elected canister names the *whole suite* on every app's sign-in screen. Elect one product canister and every sibling signs in under that product's name. +- **It deploys first.** Nothing can derive against an origin that does not exist yet. +- **It is never retired.** It holds the suite's identity for as long as the accounts do. Principals derive from the origin, so the canister keeps its code and state at your discretion (an upgrade or a reinstall changes nothing, as long as it goes on serving the well-known documents); what it can never do is hand the role to a different canister id, because that is a different origin. - include IdentityAttributes({ - onVerified = func(caller, attrs) { - profiles.add(caller, attrs); - }; - }); +The user sees their own app's origin, not the primary one: II shows the origin being signed in to and reads only the name, description and logo from the primary origin. - public query func getProfile(userId : Principal) : async ?Profile { - profiles.get(userId) - }; -}; -``` +Electing the origin is the whole of the decision; the rest of a shared sign-in is a build. The cookie record, the `/reauth` route and the per-app `ii-auth-callbacks` document are in the `internet-identity-shared-sessions` skill, which assumes this section is already settled. -```yaml -canisters: - - name: backend - settings: - environment_variables: - # Required. A local II (`ii: true`) has the same principal. - trusted_attribute_signers: "rdmx6-jaaaa-aaaaa-aaadq-cai" - # Required, comma-separated. - frontend_origins: "https://your-app.icp.net" - # Optional, comma-separated; omit to reject all sso:* keys. - trusted_sso_domains: "your-org.com" -``` +### Per environment -Unset `trusted_attribute_signers` rejects every bundle as untrusted; unset `frontend_origins` makes `_internet_identity_sign_in_finish` return `#err(#FrontendOriginsNotConfigured)`. The error variants (`#NoAttributes`, `#MalformedCandid`, `#FrontendOriginMismatch`, `#Stale`, `#UnknownNonce`, `#AmbiguousAttribute`, `#UntrustedSsoSource`, `#MixedSsoSources`) tell the frontend whether to retry with a fresh nonce or surface a bug. +Canister ids differ per environment, so the primary origin does too. Bake it at build time from the environment's manifest rather than hardcoding one. Principals differ across environments as a result, which is expected. -## Serving an app at more than one origin +### Both directions -II derives a principal per **origin**, so `https://.icp.net` and `https://shop.example.com` are two different users. Pick **one** derivation origin (the canister address: custom domains can change, the canister address cannot) and list the others as alternative origins. Pin it before an origin has users: repointing an origin with existing sign-ins orphans every account made under it. +Two well-known documents describe the same relation from opposite ends, and they have different readers: -1. The alternative origin passes `derivationOrigin`; the primary origin does not: +| Document | Served at | Says | Read by | +|---|---|---|---| +| `ii-alternative-origins` | the primary origin | who may derive against me | Internet Identity, validating `derivationOrigin` | +| `ii-derivation-origin` | every app origin | which origin I derive from | an agent, which has no constructor to read | - ```js - const authClient = new AuthClient({ derivationOrigin: "https://.icp.net" }); - ``` +II never reads the second: a browser passes `derivationOrigin` directly. It exists because an agent acting for the user has only a URL. Its body is the canonical `https://host` on one line, it is omitted when an app derives for its own visible origin, and **it must agree with what the client passes.** A missing or stale file hands an agent a different principal than the browser gives the same user, with no error. -2. The derivation origin serves `/.well-known/ii-alternative-origins` (in the static-site build, `dir/.well-known/ii-alternative-origins`): +Do not read `ii-alternative-origins` to discover the derivation origin. It is the inverse relation and there is no reverse lookup; used backwards it yields a plausible wrong principal rather than a failure. - ```json - { "alternativeOrigins": ["https://shop.example.com"] } - ``` +### What the election also decides - At most **100** entries, origins only (no trailing slash, no path). Over the cap, II rejects the **entire list** ("has too many entries: To prevent misuse at most 100 alternative origins are allowed"): every alternative origin stops authenticating, not just the extras. +Choosing the primary origin settles more than principals: -3. With `@dfinity/static-site`, add a `_headers` block (the file has no extension, and certified assets set no CORS header by default): - - ``` - /.well-known/ii-alternative-origins - Content-Type: application/json - Access-Control-Allow-Origin: * - ``` +- the name, description and logo on every sign-in screen (`ii-app-metadata`); +- which canisters may notify, and the 10-sender cap they share (`ii-notification-senders`); +- the 100-entry budget in `ii-alternative-origins`, one per app origin. -Sharing one sign-in across sibling subdomains builds on this: `references/shared-sessions.md`. +`ii-auth-callbacks` is the exception: every app serves its own. ## App metadata on the sign-in screen -Serve `/.well-known/ii-app-metadata` (`{ "name", "description", "logo" }`, all optional) on the derivation origin, with CORS on the document and the logo, and II shows your app's name, tagline, and logo next to the origin. `name` at most 40 and `description` at most 120 code points; `logo` a relative URL to a same-origin raster image (no SVG). One invalid field invalidates the whole document. Full rules: `references/app-metadata.md`. +By default the sign-in screens identify an app by its **origin** alone. To also show a name, a short tagline and a logo, serve a JSON document at `/.well-known/ii-app-metadata`. Publishing it is permissionless: there is no list to join and no approval step. A valid document replaces the curated entry II still ships for a small set of known apps. -## Notifications +```json +{ + "name": "Example App", + "description": "A short tagline shown on the sign-in screen", + "logo": "/logo.png" +} +``` -Your canister can notify a signed-in user while the app is closed. II owns the push subscriptions, so there is no push service, no VAPID keys, and no device tokens in the app. Three pieces, all required; leaving one out produces silence rather than an error. +### Which origin serves it -**Consent**, in the frontend. Needs `@icp-sdk/auth` 11.1.0 or later: +II fetches it from the origin identities are derived for: the validated `derivationOrigin` when the auth request sets one, and the request's own origin otherwise. Publish it once on the primary origin and every alternative origin that origin certifies shows the same name, description and logo. A copy served only on the alternative origin the user visits is never read. + +This also names the app on the notifications it sends, not only on the sign-in screen, and `ii-notification-senders` is read from the same origin by the same rule. + +### Validation rules + +All three fields are optional and unknown fields are ignored, so a document stays valid as II adds fields. + +- `name` is at most 40 characters and `description` at most 120, counted in **Unicode code points on the value as served**, before whitespace collapsing. Collapsing is display-only and never rescues an over-long value. +- Each field must contain at least one visible character. A field holding only whitespace or invisible characters is rejected, not treated as absent. +- Rejected characters: control characters (other than the ASCII whitespace `\t`, `\n`, `\v`, `\f`, `\r`), `U+FEFF`, and the bidirectional embeddings and overrides `U+202A` to `U+202E`. Accepted: the marks `U+200E`, `U+200F`, `U+061C`, the isolates `U+2066` to `U+2069`, and the zero-width characters `U+200B` to `U+200D`. Isolates must be **balanced**: a field closes every isolate it opens and none it did not open. +- **One bad field invalidates the whole document.** None of it is applied (not half of it); II falls back to its curated entry if it ships one for the app, and to the origin alone otherwise. II logs the offending field to the browser console on the sign-in screen. A document carrying no field II recognises is ignored too. +- `logo` must be a **raster** image URL on the **same origin** as the document (relative URLs resolve against it), served as `image/png`, `image/jpeg`, `image/webp`, `image/gif`, or `image/avif`, at most 1 MiB and 4096 pixels per axis. `image/svg+xml` is rejected: serve a rasterised copy. II downloads the image (it is never hotlinked), redraws it at up to 512 pixels on its longest side (an animated image is flattened to its first frame), and renders that copy, so ship a roughly square PNG or WebP of about 512 pixels. +- **Write the logo URL relative** (`/logo.png`). II normalizes a canister gateway origin onto `ic0.app` and tries its `icp0.io` and `icp.net` twins in turn, so the document may be fetched from a sibling gateway domain of the same canister, and the same-origin check runs against whichever answered. An absolute URL pinned to one of them is cross-origin at the other two. +- Only the *shape* of `logo` (a non-empty URL on the document's own origin) is part of document validation. Once that passes, a logo that cannot be fetched or decoded, or that breaks the content-type, size, or dimension rules, costs the logo alone: the name and description still render. +- The document must not exceed 8 KiB, must be answered with `200`, and must not redirect (II follows redirects for neither the document nor the logo). II requests it without credentials and gives up after 10 seconds. + +### CORS + +The document and the logo are both read cross-origin, so both need CORS headers. With `@dfinity/static-site`, add them to the `_headers` file at the root of the build directory: -```javascript -const { granted } = await authClient.requestNotificationConsent(); // opens II; signs in if needed -const { granted } = await authClient.getNotificationConsentStatus(); // query, no screen -await authClient.revokeNotificationConsent(); ``` +/.well-known/ii-app-metadata + Content-Type: application/json + Access-Control-Allow-Origin: * -All three answer a **record**, not a bare boolean. `granted: false` is the user's refusal, not a failure. Ask from a click: the ceremony renders, so it cannot run under `prompt: 'none'`. +/logo.png + Access-Control-Allow-Origin: * +``` -**The sender list.** Serve `/.well-known/ii-notification-senders` on the derivation origin, naming the canisters allowed to notify for it: `{ "senders": ["ryjl3-tyaaa-aaaaa-aaaba-cai"] }`. At most 10, and cached, so a change is not instant. Without it every send is refused, silently. +`.well-known/` is uploaded automatically, but the document has no file extension, so its media type must be set with the bare `Content-Type:` line. -**Sending**, with the `identity-notifications` library (crates.io for Rust, Mops for Motoko, which needs moc 2.0.0 or later), then `send(recipient, { title, body, url, key, ... })`. Rust wires `endpoints!()` and three `MemoryId`s into `init`, and calls `init` again from `post_upgrade`; Motoko's mixin needs neither and nothing on upgrade. Configuration is environment variables (`notification_sender`, `notification_origin`), not init arguments. +Missing, unreachable, or invalid metadata never blocks sign-in. Publishing it verifies nothing about the app: II keeps showing the origin next to whatever the document provides, because the origin is the part users can check. -`key` is what a notification is *about*: sending again under the same key updates it rather than adding another, and it is what `dismiss` names, so derive it from something the caller cannot choose. `urgency` schedules against the origin's other traffic and is not a rendering hint. +## Older `@icp-sdk/auth` majors -The app addresses a user and nothing finer: where a notification reaches them is II's and the user's to manage. II composes what is shown, so publishing `/.well-known/ii-app-metadata` (see above) is what names the app on its notifications too. `send` neither traps nor awaits, so nothing comes back to branch on; `metrics()` is where a notification's fate shows up. Full rules: `references/notifications.md`. +This skill targets `@icp-sdk/auth` 11.x. On an older major the same flow differs as follows. + +### 10.x + +No notification consent: `requestNotificationConsent`, `getNotificationConsentStatus` and `revokeNotificationConsent` arrive in 11.1.0. Earlier 11.x majors do not have them either. + +Otherwise the same API, except the SSO domain check: + +- `isValidSsoDomain(domain, signal)` fetched `/.well-known/ii-openid-configuration` from the browser and returned a boolean; the organization had to serve it with `Access-Control-Allow-Origin: *`. 11.x removes it: a client built with `ssoDomain` checks the domain through Internet Identity and reports `getSsoStatus()`. +- `new AuthClient({ ssoDomain })` threw on a malformed domain; 11.x reports `{ state: 'invalid', reason }` from `getSsoStatus()` instead and never throws for the domain. 11.x also reports `invalid` for a port on any host other than a loopback host (`localhost`, `127.0.0.1`, `*.localhost`), and for a malformed name such as `foo..com` or `ab--cd.com`. + +10.x peers `@icp-sdk/core@^6`. + +### 9.x + +The same API as 10.x. 10.x changed only its peer, from `@icp-sdk/core@^5` to `^6`, so 9.x is what you use if you are held on core 5. With core 6 a read-only session signs in instead of throwing "this session is read-only, which `@icp-sdk/auth` cannot act for yet". + +### 8.x + +What 9.x changed: + +- `identityProvider` was a URL string; it is now `{ authorizeUrl, canisterId }`, and a string throws. +- `storage` and its `IdbStorage` / `LocalStorage` classes became `credentialStorage` with `IdbCredentialStorage` (the default), `LocalCredentialStorage`, `MemoryCredentialStorage`, and `SharedMemoryCredentialStorage`. `stateStorage` is new and holds the record of who is signed in, which is what makes tabs converge; `CookieStateStorage` extends that to sibling subdomains. +- `IdleManager` and its options (`idleOptions`, `onIdle`, `idleTimeout`, `disableIdle`) were removed. The session is bounded at Internet Identity instead, via `maxTimeToIdle` and `maxTimeToLive` on `signIn()`. +- `maxTimeToLive` bounded a delegation and capped every sign-in at 8 hours; it now bounds the session, and unset means the provider's own 30 days. +- `getStatus()`, `subscribe()`, `getPrincipal()`, and `dispose()` are new; the `identity`, `keyType`, and `targets` options are gone. + +### 5.x + +A callback-based API: + +- `await AuthClient.create({...})` instead of `new AuthClient({...})`. +- `identityProvider` passed per call to `login({...})` rather than at construction. +- `authClient.login({ onSuccess, onError })`, which needs a promise wrapper. +- `authClient.logout()` instead of `authClient.signOut()`. +- `await authClient.isAuthenticated()` (async) instead of sync; `authClient.getIdentity()` (sync) instead of async. +- 5.x appends `/authorize` to the `identityProvider` URL, so passing `https://id.ai` works there. +- No `requestAttributes` / `AttributesIdentity`: the identity-attributes flow requires 7.x or later. + +Upgrade when you can: the promise-based API is harder to misuse, and from 9.x the credentials a session's calls are signed with are short-lived and replaced for you, rather than one key being held for the session's whole length. ## Backend: Access Control -Anonymous-principal rejection, role guards, and caller binding in async functions are not II-specific: see the **canister-security** skill. +Anonymous-principal rejection, role guards, and caller binding in async functions are not II-specific: see the `canister-security` skill. + +## Where to go next + +- Load `internet-identity-shared-sessions` to carry one sign-in across sibling subdomains, once the primary origin above is elected. +- Load `internet-identity-attributes` for a verified email or display name in the backend. +- Load `internet-identity-notifications` to notify a signed-in user while the app is closed. +- Load `internet-identity-sso` to set up an organization's own SSO, the side `ssoDomain` consumes. +- Load `canister-security` for anonymous-principal rejection and role guards. diff --git a/skills/internet-identity/references/app-metadata.md b/skills/internet-identity/references/app-metadata.md deleted file mode 100644 index 1dfa4d50..00000000 --- a/skills/internet-identity/references/app-metadata.md +++ /dev/null @@ -1,47 +0,0 @@ -# App metadata - -Also names the app on the notifications it sends, not only on the sign-in screen. See `references/notifications.md`.: showing your app's name, description, and logo - -By default the Internet Identity sign-in screens identify an app by its **origin** alone. To also show a name, a short tagline, and a logo, serve a JSON document at `/.well-known/ii-app-metadata`. Publishing it is permissionless: there is no list to join and no approval step. A valid document replaces the curated entry II still ships for a small set of known apps. - -```json -{ - "name": "Example App", - "description": "A short tagline shown on the sign-in screen", - "logo": "/logo.png" -} -``` - -## Which origin serves it - -II fetches the document from the origin identities are derived for: the validated `derivationOrigin` when the auth request sets one, and the request's own origin otherwise. Publish it once on the derivation origin and every alternative origin that origin certifies shows the same name, description, and logo. A copy served only on the alternative origin the user visits is never read. - -## Validation rules - -All three fields are optional and unknown fields are ignored, so a document stays valid as II adds fields. - -- `name` is at most 40 characters and `description` at most 120, counted in **Unicode code points on the value as served**, before whitespace collapsing. Collapsing is display-only and never rescues an over-long value. -- Each field must contain at least one visible character. A field holding only whitespace or invisible characters is rejected, not treated as absent. -- Rejected characters: control characters (other than the ASCII whitespace `\t`, `\n`, `\v`, `\f`, `\r`), `U+FEFF`, and the bidirectional embeddings and overrides `U+202A` to `U+202E`. Accepted: the marks `U+200E`, `U+200F`, `U+061C`, the isolates `U+2066` to `U+2069`, and the zero-width characters `U+200B` to `U+200D`. Isolates must be **balanced**: a field closes every isolate it opens and none it did not open. -- **One bad field invalidates the whole document.** None of it is applied (not half of it); II falls back to its curated entry if it ships one for the app, and to the origin alone otherwise. II logs the offending field to the browser console on the sign-in screen. A document carrying no field II recognises is ignored too. -- `logo` must be a **raster** image URL on the **same origin** as the document (relative URLs resolve against it), served as `image/png`, `image/jpeg`, `image/webp`, `image/gif`, or `image/avif`, at most 1 MiB and 4096 pixels per axis. `image/svg+xml` is rejected: serve a rasterised copy. II downloads the image (it is never hotlinked), redraws it at up to 512 pixels on its longest side (an animated image is flattened to its first frame), and renders that copy, so ship a roughly square PNG or WebP of about 512 pixels. -- **Write the logo URL relative** (`/logo.png`). II normalizes a canister gateway origin onto `ic0.app` and tries its `icp0.io` and `icp.net` twins in turn, so the document may be fetched from a sibling gateway domain of the same canister, and the same-origin check runs against whichever answered. An absolute URL pinned to one of them is cross-origin at the other two. -- Only the *shape* of `logo` (a non-empty URL on the document's own origin) is part of document validation. Once that passes, a logo that cannot be fetched or decoded, or that breaks the content-type, size, or dimension rules, costs the logo alone: the name and description still render. -- The document must not exceed 8 KiB, must be answered with `200`, and must not redirect (II follows redirects for neither the document nor the logo). II requests it without credentials and gives up after 10 seconds. - -## CORS - -The document and the logo are both read cross-origin, so both need CORS headers. With `@dfinity/static-site`, add them to the `_headers` file at the root of the build directory: - -``` -/.well-known/ii-app-metadata - Content-Type: application/json - Access-Control-Allow-Origin: * - -/logo.png - Access-Control-Allow-Origin: * -``` - -`.well-known/` is uploaded automatically, but the document has no file extension, so its media type must be set with the bare `Content-Type:` line. - -Missing, unreachable, or invalid metadata never blocks sign-in. Publishing it verifies nothing about the app: II keeps showing the origin next to whatever the document provides, because the origin is the part users can check. diff --git a/skills/internet-identity/references/older-api.md b/skills/internet-identity/references/older-api.md deleted file mode 100644 index 61c7cf9d..00000000 --- a/skills/internet-identity/references/older-api.md +++ /dev/null @@ -1,42 +0,0 @@ -# Older `@icp-sdk/auth` majors - -SKILL.md targets `@icp-sdk/auth` 11.x. On an older major the same flow differs as follows. - -## 10.x - -No notification consent: `requestNotificationConsent`, `getNotificationConsentStatus` and `revokeNotificationConsent` arrive in 11.1.0. Earlier 11.x majors do not have them either. - -Otherwise the same API, except the SSO domain check: - -- `isValidSsoDomain(domain, signal)` fetched `/.well-known/ii-openid-configuration` from the browser and returned a boolean; the organization had to serve it with `Access-Control-Allow-Origin: *`. 11.x removes it: a client built with `ssoDomain` checks the domain through Internet Identity and reports `getSsoStatus()`. -- `new AuthClient({ ssoDomain })` threw on a malformed domain; 11.x reports `{ state: 'invalid', reason }` from `getSsoStatus()` instead and never throws for the domain. 11.x also reports `invalid` for a port on any host other than a loopback host (`localhost`, `127.0.0.1`, `*.localhost`), and for a malformed name such as `foo..com` or `ab--cd.com`. - -10.x peers `@icp-sdk/core@^6`. - -## 9.x - -The same API as 10.x. 10.x changed only its peer, from `@icp-sdk/core@^5` to `^6`, so 9.x is what you use if you are held on core 5. With core 6 a read-only session signs in instead of throwing "this session is read-only, which `@icp-sdk/auth` cannot act for yet". - -## 8.x - -What 9.x changed: - -- `identityProvider` was a URL string; it is now `{ authorizeUrl, canisterId }`, and a string throws. -- `storage` and its `IdbStorage` / `LocalStorage` classes became `credentialStorage` with `IdbCredentialStorage` (the default), `LocalCredentialStorage`, `MemoryCredentialStorage`, and `SharedMemoryCredentialStorage`. `stateStorage` is new and holds the record of who is signed in, which is what makes tabs converge; `CookieStateStorage` extends that to sibling subdomains. -- `IdleManager` and its options (`idleOptions`, `onIdle`, `idleTimeout`, `disableIdle`) were removed. The session is bounded at Internet Identity instead, via `maxTimeToIdle` and `maxTimeToLive` on `signIn()`. -- `maxTimeToLive` bounded a delegation and capped every sign-in at 8 hours; it now bounds the session, and unset means the provider's own 30 days. -- `getStatus()`, `subscribe()`, `getPrincipal()`, and `dispose()` are new; the `identity`, `keyType`, and `targets` options are gone. - -## 5.x - -A callback-based API: - -- `await AuthClient.create({...})` instead of `new AuthClient({...})`. -- `identityProvider` passed per call to `login({...})` rather than at construction. -- `authClient.login({ onSuccess, onError })`, which needs a promise wrapper. -- `authClient.logout()` instead of `authClient.signOut()`. -- `await authClient.isAuthenticated()` (async) instead of sync; `authClient.getIdentity()` (sync) instead of async. -- 5.x appends `/authorize` to the `identityProvider` URL, so passing `https://id.ai` works there. -- No `requestAttributes` / `AttributesIdentity`: the identity-attributes flow requires 7.x or later. - -Upgrade when you can: the promise-based API is harder to misuse, and 9.x and later re-mint the delegation calls are signed with instead of keeping one key alive for the whole session. diff --git a/skills/internet-identity/references/rust-identity-attributes.md b/skills/internet-identity/references/rust-identity-attributes.md deleted file mode 100644 index 02c6d6b0..00000000 --- a/skills/internet-identity/references/rust-identity-attributes.md +++ /dev/null @@ -1,74 +0,0 @@ -# Rust backend for identity attributes - -The `identity-attributes` crate is the Rust side of the `mo:identity-attributes` Motoko package: the same checks, the same environment variables, and the same Candid types, so one frontend works against either backend. - -```toml -[dependencies] -candid = "0.10" -ic-cdk = "0.20.1" -identity-attributes = "0.1" -``` - -```yaml -canisters: - - name: backend - settings: - environment_variables: - trusted_attribute_signers: "rdmx6-jaaaa-aaaaa-aaadq-cai" # required - frontend_origins: "https://your-app.icp.net" # required, comma-separated - trusted_sso_domains: "acme.com" # optional, comma-separated; omit to reject all sso: keys -``` - -`#[identity_attributes]` marks the function that receives the caller and their verified attributes, called only for a bundle that passes every check, and adds `_internet_identity_sign_in_start` and `_internet_identity_sign_in_finish`. The function cannot be `async` or generic: - -```rust -use candid::Principal; -use ic_cdk::query; -use identity_attributes::{identity_attributes, IdentityAttributes}; -use std::cell::RefCell; -use std::collections::BTreeMap; - -thread_local! { - // On the heap to keep the example short: an upgrade clears it. - static PROFILES: RefCell> = - const { RefCell::new(BTreeMap::new()) }; -} - -#[identity_attributes] -fn on_verified(caller: Principal, attributes: IdentityAttributes) { - PROFILES.with_borrow_mut(|profiles| profiles.insert(caller, attributes)); -} - -#[query] -fn get_profile(user_id: Principal) -> Option { - PROFILES.with_borrow(|profiles| profiles.get(&user_id).cloned()) -} - -ic_cdk::export_candid!(); -``` - -`IdentityAttributes` is `{ name: Option, email: Option, sso: Option }`: - -- `email` comes from `verified_email` (or `openid::verified_email`), and for an SSO sign-in from `sso::email`. The unverified `email` key of other sources is never read. -- `sso` is the organization's domain when the values came from `sso:` keys, and `None` otherwise. - -What the finish method checks, in order, and the `err` it returns: - -| Check | Error | -|---|---| -| A bundle is attached | `NoAttributes` | -| It decodes to an ICRC-3 `Value::Map` | `MalformedCandid` | -| `frontend_origins` is set | `FrontendOriginsNotConfigured` | -| `implicit:origin` is one of `frontend_origins` | `FrontendOriginMismatch { expected, got }` | -| `implicit:issued_at_timestamp_ns` is at most five minutes old | `Stale { ageNs }` | -| `implicit:nonce` was issued here and not consumed | `UnknownNonce` | -| A required implicit field is present | `MissingField` | -| Every `sso::*` key's domain is trusted | `UntrustedSsoSource { domain }` | -| Name and email come from `sso:` keys or from others, never both | `MixedSsoSources { ssoKeys, otherKeys }` | -| Each field comes from one key, and `sso:` keys from one domain | `AmbiguousAttribute { field, sources }` | - -The result is Candid `variant { ok; err : Error }`, so the frontend checks `"err" in result`. A call carrying a bundle traps when `trusted_attribute_signers` is unset or does not list the bundle's signer. - -Nonces are kept on the heap, so an upgrade clears them and a sign-in in flight fails with `UnknownNonce` and starts again. A nonce expires after five minutes, and at most 4096 are held, the oldest evicted first. - -`identity_attributes::sign_in_start()` and `identity_attributes::sign_in_finish(on_verified)` are the functions behind the two methods, for a canister that defines them itself.