关于 internet-identity
为 Internet Computer 应用实现 Internet Identity 身份验证的参考技能,面向使用 IC SDK 的开发者,重点说明登录、会话、SSO 和身份属性的正确处理。
主要功能
- 01通行密钥与 OpenID 登录指南
- 02组织 SSO 配置指南
- 03会话与子域名共享模式
- 04身份属性验证指南
- 05本地 Internet Identity 开发设置
适用场景
- 01为 IC 应用添加 Internet Identity 登录
- 02配置同级子域名间的登录共享
- 03在后端读取已验证的身份属性
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ dfinity/ icskills --skill internet-identity安装后在 Claude Code 中输入/internet-identity确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/app-metadata.mdenterprise-sso.mdolder-api.mdrust-identity-attributes.mdshared-sessions.md详细介绍
Internet Identity 身份验证
概述
Internet Identity(II)是 Internet Computer 的原生身份验证系统。用户可以使用通行密钥、OpenID 账户(Google、Apple、Microsoft),或通过所属组织自己的 SSO 登录 II 驱动的应用;无需用户名或密码。每位用户在每个应用源下都有唯一的 principal,从而避免跨应用跟踪。
本技能的参考资料位于 references/:
references/shared-sessions.md:如何在同级子域名之间共享一次登录,涵盖端到端流程。references/app-metadata.md:/.well-known/ii-app-metadata的所有验证规则。references/enterprise-sso.md:SSO 的组织端(OIDC 客户端、发现文件、限制、缓存)。references/rust-identity-attributes.md:用于身份属性的 Rust 后端,以及identity-attributescrate。references/older-api.md:@icp-sdk/auth10.x 及更早版本的差异。
前置条件
@icp-sdk/auth@^11和@icp-sdk/core@^6。两个主版本必须保持配套。 auth 11 和 10 的 peer 依赖均为@icp-sdk/core@^6;auth 9 的 peer 依赖为^5。- 对于 Motoko 后端:需要
mo:identity-attributes>= 0.4.0(mops),这是注入两个登录方法并验证属性包的 mixin。它会引入mo:core>= 2.5.0,并要求moc>= 1.6.0 才能使用include。
Canister ID
| Canister | ID | URL | 用途 |
|---|---|---|---|
| Internet Identity(后端) | rdmx6-jaaaa-aaaaa-aaadq-cai | 签发委托,签署属性包 | |
| Internet Identity(前端) | uqzsh-gqaaa-aaaaq-qaada-cai | https://id.ai | 提供登录界面;authorizeUrl 指向此处 |
这两个 ID 在主网和本地网络上相同。请将其硬编码。
会导致构建失败的错误
-
II 环境 URL 错误。
authorizeUrl指向前端 canister:主网上为https://id.ai/authorize,本地 II(ii: true)则为http://id.ai.localhost:8000/authorize。系统会原样使用此 URL,因此必须包含/authorize:https://id.ai会打开 II 主页,且永远不会返回委托。 -
将
identityProvider设为字符串,或只提供其中一部分。 它是一个对象{ authorizeUrl, canisterId },两项都必须提供;传入字符串或URL会抛出TypeError。完全省略此选项即可使用主网 II,这是大多数应用需要的设置。 -
在前端代码中使用顶层
await。 Vite 6 及更早版本默认面向es2020时代的浏览器,这些浏览器不支持顶层 await,因此构建会失败;Vite 7 则允许使用。请将初始化代码包装在async function init()中(或放入框架生命周期钩子)。这种写法在所有版本中都能构建。不要为了让编译通过而将build.target改为esnext。 -
把
maxTimeToLive当作签名密钥的有效期。 它限制的是 II 中的会话;maxTimeToIdle则会结束无人使用的会话。调用所用的委托有效期较短,并由客户端替换。除非应用有自己的策略,否则不要设置这两个选项:II 默认采用空闲七天、总计三十天的期限。 -
未等待
signIn(),或省略try/catch。 用户关闭弹出窗口或身份验证失败时,signIn()会被拒绝。 -
在浏览器代码中使用
shouldFetchRootKey或fetchRootKey()。ic_envcookie(由前端 canister 或 Vite 开发服务器设置)会通过IC_ROOT_KEY携带根密钥:将其作为rootKey传给HttpAgent.create(),并保持host未设置。运行时获取根密钥会让中间人能够在主网上替换成伪造的根密钥。 -
得到
2vxsx-fae作为 principal。 这是匿名 principal:身份验证已静默失败。常见原因包括:authorizeUrl错误(尤其是缺少/authorize)、未处理signIn()的拒绝,或在signIn()完成前读取身份。如果存在登录流程,但当前源没有对应凭据,getIdentity()会抛出SessionNotHeldError(而不是返回匿名身份)。 -
将主体作为参数传递。 后端从 IC 协议读取调用方:Motoko 中为
msg.caller,Rust 中为ic_cdk::api::msg_caller()。有关访问控制模式,请参阅 canister-security 技能。 -
为网关域名添加
derivationOrigin或ii-alternative-origins。 II 会将ic0.app、icp0.io和icp.net规范化为同一种形式,因此托管在其中任一域名上的 canister 都会获得相同的 principal。添加此配置会破坏身份验证。此处出现不同的 principal,意味着使用了不同的通行密钥或设备。确实需要添加它的情况是使用第二个源(自定义域名):请参阅“在多个源上托管应用”。 -
在前端生成属性 nonce。 传给
requestAttributes的 nonce 必须来自后端(_internet_identity_sign_in_start),否则 canister 无法检查 bundle 的implicit:nonce是否由其签发,重放保护也就失效了。 -
读取属性数据时不验证签名者。 IC 会验证签名,但不会验证签名者身份:任何 canister 都能生成有效的 bundle。受信任的签名者是
rdmx6-jaaaa-aaaaa-aaadq-cai。使用identity-attributes库(Motoko 中的mo:identity-attributesmixin,Rust 中的#[identity_attributes]属性),并配置trusted_attribute_signers和frontend_origins。手写的 Rust 代码必须在读取msg_caller_info_data()前检查msg_caller_info_signer(),否则攻击者 canister 可以伪造email = "[email protected]"。 -
替换 Microsoft scoped-key 前缀中的
{tid}。microsoft提供方 URL 是字面值https://login.microsoftonline.com/{tid}/v2.0;密钥形式必须准确为openid:https://login.microsoftonline.com/{tid}/v2.0:email。填入租户 ID 会导致所有查询都无法匹配。 -
将
email视为已验证。email是用户 II 关联账户中的原始地址;II 不会检查它,因此应将其视为用户输入。只有在 II 确认用户控制该地址时才会提供verified_email:即 OpenID 提供方(例如 Google)将其标记为已验证,或用户直接在 II 中关联并验证了该邮箱。只能依据verified_email授予访问权限。sso:下没有verified_email:组织的 SSO 无法提供该属性;其sso:<domain>:email由该组织自己的提供方声明,因此只信任你列出的域名。 -
共享 Cookie 域名,却没有共享
derivationOrigin。 只有共享同一个 principal 的子域名才能共享登录状态。如果没有一个经授权、供所有子域名使用的派生源,共享记录中记录的账户就不可能属于读取源,/reauth会让用户陷入无限跳转。请参阅references/shared-sessions.md。 -
静默重新签发时不提供
hint、使用默认传输方式,或回调未声明。 当 II 中有多个会话时,不提供hint的prompt: 'none'会以InteractionRequiredError(reason为account_selection_required)失败。此操作会在页面加载时运行,不需要用户手势,因此必须在独立路由上使用transport: 'redirect',并在该源的/.well-known/ii-auth-callbacks中声明;否则重定向不会返回。 -
在错误的源上提供
/.well-known/ii-app-metadata、未启用 CORS,或有字段无效。 II 会从派生源(如已设置)或请求源读取此文件;文档和 Logo 都需要启用 CORS,而且任一字段无效都会导致整个文档无效。请参阅references/app-metadata.md。 -
安装不兼容的主版本。 auth 11 要求对等依赖
@icp-sdk/core@^6;如果习惯性地将 core 固定为^5,就会出现:npm error ERESOLVE unable to resolve dependency tree npm error Found: @icp-sdk/[email protected] npm error peer @icp-sdk/core@"^6" from @icp-sdk/[email protected]不要用
--legacy-peer-deps或--force来消除错误。将@icp-sdk/auth@^11与@icp-sdk/core@^6固定搭配;如果有其他依赖让你必须使用 core 5,则继续使用@icp-sdk/auth@^9。 -
未设置
agentOptions时登录本地 II。 客户端通过代理调用 II canister 来创建委托,该代理默认使用主网根密钥进行验证。使用本地 II 时,身份验证流程会完成,但创建委托随后会失败,并报错TrustError: Certificate verification error("Invalid signature")。从ic_envcookie 传入agentOptions: { rootKey },并将host留空。较旧网络启动器启动的本地 II 缺少创建委托所需的方法:运行icp network update并重启网络。 -
根据委托的过期时间安排登出。 委托
getIdentity()签名使用的身份有效期很短,会随着时间推移而更换,因此根据identity.getDelegation()的过期时间设置计时器,会在几分钟后将用户登出。会话结束时会收到expired状态:订阅该状态,并在isAuthenticated()变为 false 时退出已登录视图。 -
在浏览器中检查 SSO 域名。 不要
fetch('https://<domain>/.well-known/ii-openid-configuration'),也不要查找isValidSsoDomain(已在 11.x 中移除)。使用ssoDomain创建客户端,并读取getSsoStatus():II 会按照与登录时相同的规则检查域名,组织无需设置 CORS 标头。 -
在
signIn()前等待 SSO 检查,或用try/catch包裹构造函数来处理域名。 必须在点击处理程序中同步调用signIn(),否则 Safari 会阻止弹出窗口。无论 SSO 状态如何,包括invalid,登录都会继续(II 自带的界面会显示进度和错误)。在任何状态下,如果用户关闭弹出窗口或身份验证失败,登录都会被拒绝,因此请添加拒绝处理程序。域名格式错误时,构造函数不会抛出异常,而是报告invalid。 -
更改现有客户端的
ssoDomain或openIdProvider。 与其他所有选项一样,这两个选项只能在构造时设置;没有 setter,也不能针对每次signIn()单独覆盖。要使用新域名,请创建一个新客户端,并对被替换的客户端调用dispose()。
在本地开发期间使用 II
默认方式:从本地网络使用主网 II。 使用 icp-cli >= 0.2.4 时,本地网络会信任主网子网的签名,因此来自 https://id.ai 的委托可用于访问本地部署的后端。创建客户端时不传入 identityProvider。
**备用方式:使用本地 II,**仅用于完全离线开发或指定的 II 构建版本。在 icp.yaml 的本地网络配置中添加 ii: true:
networks:
- name: local
mode: managed
ii: true
随后 II 会在 http://id.ai.localhost:8000 提供服务,canister ID 保持不变。项目中无需添加 canister 条目。传入本地根密钥:
const authClient = new AuthClient({
identityProvider: {
authorizeUrl: "http://id.ai.localhost:8000/authorize",
canisterId: "rdmx6-jaaaa-aaaaa-aaadq-cai",
},
agentOptions: { rootKey: canisterEnv?.IC_ROOT_KEY },
});
前端:登录流程
与框架无关;请按需适配 DOM 部分。
import { AuthClient } from "@icp-sdk/auth/client";
import { HttpAgent, Actor } from "@icp-sdk/core/agent";
import { safeGetCanisterEnv } from "@icp-sdk/core/agent/canister-env";
// The ic_env cookie carries the root key and canister IDs, locally and on mainnet.
const canisterEnv = safeGetCanisterEnv();
// Mainnet II is the default. derivationOrigin, openIdProvider, ssoDomain,
// stateStorage, transport, prompt, and hint are also constructor options.
const authClient = new AuthClient();
async function signIn() {
try {
// Optional session bounds: signIn({ maxTimeToIdle, maxTimeToLive }), in ns.
return await authClient.signIn();
} catch (error) {
console.error("Sign-in failed:", error);
throw error;
}
}
// Ends the session at II: every tab of this origin is signed out.
async function signOut() {
await authClient.signOut();
}
// No host: the default resolves correctly locally, on mainnet, and on custom domains.
async function createAuthenticatedActor(identity, canisterId, idlFactory) {
const agent = await HttpAgent.create({ identity, rootKey: canisterEnv?.IC_ROOT_KEY });
return Actor.createActor(idlFactory, { agent, canisterId });
}
// Wrapped in a function: Vite 6 and earlier reject top-level await by default.
async function init() {
// isAuthenticated() is sync; getIdentity() is async.
if (authClient.isAuthenticated()) {
const identity = await authClient.getIdentity();
const actor = await createAuthenticatedActor(identity, canisterId, idlFactory);
}
// getStatus(): 'signed-in' | 'signed-in-elsewhere' | 'expired' | 'signed-out'.
// subscribe() fires on any change, including in another tab.
authClient.subscribe(() => render(authClient.getStatus()));
}
init();
生命周期。 每个页面使用一个客户端,或每个组件使用一个客户端:两者都会读取和写入同一登录状态。视图所拥有的客户端会随视图一同释放:dispose() 会释放其监听器、订阅和计划的刷新操作,但不会登出。
一键登录
使用 openIdProvider('google'、'apple'、'microsoft')或 ssoDomain(组织的域名)可跳过 II 的方式选择界面。两者互斥(类型错误,且构造时会抛出异常),并且只能在构造时设置。
const google = new AuthClient({ openIdProvider: "google" });
const acme = new AuthClient({ ssoDomain: "acme.com" });
检查组织的域名
使用 ssoDomain 构建的客户端会在创建后立即通过 II 检查自己的域名,并像会话一样报告结果:通过 getSsoStatus() 获取同步快照,通过 subscribe() 接收变更通知。不会抛出异常,也不需要 AbortSignal。
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
在用户输入时进行检查:
let client;
let timer;
input.addEventListener("input", () => {
client?.dispose(); // also stops the old client's check
client = undefined; // Continue never signs in to the old domain
showSpinner();
clearTimeout(timer); // debounce: one client per pause, not per keystroke
timer = setTimeout(() => {
const next = new AuthClient({ ssoDomain: input.value }); // never throws for the domain
next.subscribe(() => render(next.getSsoStatus()));
render(next.getSsoStatus());
client = next;
}, 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);
});
- 被取代的客户端会在发布结果前被释放,因此不会渲染过期结果。像上文那样对输入进行防抖:每输入一个字符就构建一个客户端会导致客户端和检查频繁更替。
refreshSsoStatus()会重新运行检查,可用于“重试”按钮。当retryAfter尚未到期时,II 会立即再次返回unavailable,不会发起新的请求,因此应禁用按钮并显示倒计时(“2 分钟后重试”)。对invalid客户端调用此方法不会产生任何效果:用户需要修正输入,这会构建一个新客户端。客户端不会自行重新检查。- 在所有状态下都可以调用
signIn():II 会自行解析域名,并显示自己的加载和错误界面,即使值为invalid也一样。任何状态下,弹出窗口关闭或身份验证失败都会导致调用被拒绝。 invalid遵循 II 自身的规则:至少包含两个标签,每个标签由 1 至 63 个字母、数字或连字符组成,连字符不能位于标签开头或结尾,标签的第三和第四个位置不能同时为--(xn--punycode 除外),总长度最多为 253 个字符,且只有回环主机(localhost、127.0.0.1或.localhost下的名称)可以带端口。其reason会列出发现的所有问题。- 此检查会预热 II 的缓存,因此在同一客户端上调用
signIn()时会更快启动。
有关组织如何设置其一侧的说明(OIDC 客户端、ii-openid-configuration 文件、逐应用访问权限、拒绝该文件的限制、缓存):references/enterprise-sso.md。
身份属性
当后端需要主体之外的信息(例如经过验证的电子邮件地址)时,II 会在委托凭证旁返回一个签名属性包。后端提供两个方法:_internet_identity_sign_in_start 用于生成 nonce,_internet_identity_sign_in_finish 用于验证属性包。identity-attributes 库会同时添加这两个方法:Motoko 中的 mo:identity-attributes mixin,以及 Rust 中的 #[identity_attributes] 属性(references/rust-identity-attributes.md)。两者返回相同的 Candid 类型,因此无论使用哪一种,前端都相同。
密钥
requestAttributes({ keys, nonce }) 必须同时提供两者;没有默认的密钥集合。
| 密钥 | 含义 | 用途 |
|---|---|---|
name | 用户与 II 关联的账户中的显示名称 | 个性化 |
email | 用户与 II 关联的账户中的原始地址;II 不会验证此地址 | 联系信息、邮件列表 |
verified_email | 仅当 II 确认用户控制该地址时才会提供(OpenID 提供商将其标记为已验证,或用户通过 II 验证了该地址) | 访问控制;唯一可用于授权的电子邮件地址 |
同时请求 email 和 verified_email 可实现回退:验证通过时,两者的值相同;否则只会返回 email。
scopedKeys 会将密钥限定到用户用于登录的账户,因此它们会在同一步骤中共享,无需额外提示词:
| 调用 | 密钥 | 默认 keys |
|---|---|---|
scopedKeys({ openIdProvider: 'google' }) | openid:https://accounts.google.com:<key> | name、email、verified_email |
scopedKeys({ openIdProvider: 'apple' }) | openid:https://appleid.apple.com:<key> | 同上 |
scopedKeys({ openIdProvider: 'microsoft' }) | openid:https://login.microsoftonline.com/{tid}/v2.0:<key>({tid} 为字面值) | 同上 |
scopedKeys({ ssoDomain: 'acme.com' }) | sso:acme.com:<key> | name、email(不含 verified_email) |
传入 keys 可缩小范围,例如:scopedKeys({ openIdProvider: 'google', keys: ['name', 'verified_email'] })。
前端
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;
}
如需一键登录,请使用 openIdProvider 或 ssoDomain 构建客户端,在 @icp-sdk/auth/client 导入中添加 scopedKeys,并改为请求 scopedKeys({ openIdProvider: "google", keys: ["name", "verified_email"] }) 或 scopedKeys({ ssoDomain: "acme.com" });其余内容不变。
每个捆绑包都带有三个隐式字段,后端必须验证:implicit:nonce(由后端签发且尚未使用的 nonce)、implicit:origin(受信任的前端来源)、implicit:issued_at_timestamp_ns(时间新鲜,通常在五分钟内)。
后端:Motoko
[dependencies]
identity-attributes = "0.4.1"
core = "2.5.0"
[toolchain]
moc = "1.6.0"
include IdentityAttributes({ onVerified }) 会注入这两个方法,并且仅在捆绑包通过签名者、来源、nonce 和时效性检查后才运行 onVerified。它会将捆绑包解析为 { name : ?Text; email : ?Text; sso : ?Text }:
- OpenID 和未限定来源:
email来自verified_email(或openid:<provider>:verified_email),因此前端需要请求verified_email。 - SSO 来源:
name和email来自sso:<domain>:name/sso:<domain>:email,仅当域名列于trusted_sso_domains时才会接受,且sso为该域名。否则,sso为null。
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<Principal, Profile>();
include IdentityAttributes({
onVerified = func(caller, attrs) {
profiles.add(caller, attrs);
};
});
public query func getProfile(userId : Principal) : async ?Profile {
profiles.get(userId)
};
};
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"
未设置 trusted_attribute_signers 时,所有捆绑包都会因不受信任而被拒绝;未设置 frontend_origins 时,_internet_identity_sign_in_finish 会返回 #err(#FrontendOriginsNotConfigured)。这些错误变体(#NoAttributes、#MalformedCandid、#FrontendOriginMismatch、#Stale、#UnknownNonce、#AmbiguousAttribute、#UntrustedSsoSource、#MixedSsoSources)会告知前端应使用新的 nonce 重试,还是报告程序错误。
在多个来源提供应用
II 会按来源派生主体,因此 https://<canister-id>.icp.net 和 https://shop.example.com 对应两个不同的用户。选择一个派生来源(Canister 地址:自定义域名可能变更,Canister 地址不会),并将其他来源列为备选来源。请在某个来源有用户之前固定该设置:如果某个已有登录记录的来源被重新指向,基于该来源创建的所有账户都会变得无法访问。
-
备选来源设置
derivationOrigin;主来源不设置:const authClient = new AuthClient({ derivationOrigin: "https://<canister-id>.icp.net" }); -
派生来源提供
/.well-known/ii-alternative-origins(在静态站点构建中为dir/.well-known/ii-alternative-origins):{ "alternativeOrigins": ["https://shop.example.com"] }最多 100 项,且只能填写来源(不带末尾斜杠和路径)。超过上限时,II 会拒绝整个列表(“has too many entries: To prevent misuse at most 100 alternative origins are allowed”):所有备选来源都会停止验证身份,而不只是超出的项。
-
使用
@dfinity/static-site时,添加_headers区块(该文件没有扩展名,且经过认证的资源默认不会设置 CORS 标头):/.well-known/ii-alternative-origins Content-Type: application/json Access-Control-Allow-Origin: *
在兄弟子域名之间共享一次登录的实现方式基于此功能:references/shared-sessions.md。
登录屏幕上的应用元数据
在派生来源提供 /.well-known/ii-app-metadata({ "name", "description", "logo" },所有字段均可选),并为该文档和徽标启用 CORS,II 就会在来源旁显示应用名称、标语和徽标。name 最多 40 个码点,description 最多 120 个码点;logo 应为指向同源栅格图像的相对 URL(不支持 SVG)。任一字段无效都会导致整个文档失效。完整规则:references/app-metadata.md。
后端:访问控制
拒绝匿名主体、角色守卫,以及在异步函数中绑定调用者,并非 II 特有功能:请参阅 canister-security 技能。