Appearance
总览
你要的决策规则很清晰:
- Windows + Chrome(或 Chromium 家族)→ 优先谷歌(2),否则 -1;
- macOS + Safari → 苹果(1),否则 -1;
- macOS + Chrome → 优先谷歌(2),若未绑定谷歌则回退苹果(1);
- 其它情况一律 -1;
- 前提:用户
credential数组里必须已绑定对应类型,否则直接 -1。
下面给你最小变更代码片段(TypeScript,带行前注释解释复杂逻辑),以及我对最佳实践的广泛调研与改进建议。
实现思路(最小可用)
- 先看用户是否已绑定(有 1/2);未绑定任何 → -1。
- 通过 User-Agent Client Hints(
navigator.userAgentData)优先识别平台与品牌,Safari 再用 UA 字符串兜底。(MDN Web Docs) - 根据你的规则在 Windows / macOS 不同浏览器上做选择,并在 macOS Chrome 支持回退。
- 可选:在返回非 -1 前,用
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()做一次能力探测,若平台验证器不可用,也返回 -1(更稳)。(MDN Web Docs)
代码片段(只需新增/修改以下函数)
说明:按你的偏好,仅提供需要添加/调整的片段;对“复杂逻辑”已在上一行给注释。
ts
// 放到你的前端 util 文件中,例如: src/utils/passkeyType.ts
export type PasskeyType = -1 | 1 | 2; // -1=不支持, 1=苹果, 2=谷歌
/**
* 根据用户已绑定的 credential 类型 + 运行环境(平台/浏览器) 得出要验证的通行密钥类型
* @param boundTypes 用户绑定的类型数组,如 [1,2]、[1]、[2]、[]
* @param nav 可注入的 navigator(便于测试)
*/
export async function decidePasskeyType(
boundTypes: number[],
nav: Navigator = navigator
): Promise<PasskeyType> {
// 复杂逻辑:先判断绑定情况,空则 -1
const hasApple = boundTypes?.includes(1);
const hasGoogle = boundTypes?.includes(2);
if (!hasApple && !hasGoogle) return -1;
// 复杂逻辑:优先用 UA-CH 获取平台与品牌,Safari 用 UA 兜底
const anyNav = nav as any;
const uaData = anyNav.userAgentData;
const ua = nav.userAgent || "";
const platform = (uaData?.platform || "").toLowerCase();
const brands: string[] = uaData?.brands?.map((b: any) => b.brand) ?? [];
const brandStr = brands.join(" ");
// 复杂逻辑:判断是否 Chromium 家族(Chrome/Edge/Opera/Brave 等)
const isChromium =
/Chrom(e|ium)|Google Chrome|Microsoft Edge|Opera|OPR|Brave/i.test(
brandStr
) || /Chrome|Chromium|Edg|OPR|Brave/i.test(ua);
// 复杂逻辑:Safari 的 UA 识别,排除 Chromium 标记
const isSafari =
/Safari\//.test(ua) &&
!/Chrome\//.test(ua) &&
!/Chromium\//.test(ua) &&
!/Edg\//.test(ua) &&
!/OPR\//.test(ua);
const isWindows = platform.includes("win") || /Windows NT/i.test(ua);
const isMac = platform.includes("mac") || /Macintosh/i.test(ua);
// 复杂逻辑(可选稳健性):若平台验证器不可用,则直接判为不支持
if (
typeof (window as any).PublicKeyCredential
?.isUserVerifyingPlatformAuthenticatorAvailable === "function"
) {
try {
const uvpaa = await (
window as any
).PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
if (!uvpaa) return -1;
} catch {
// 探测失败不拦截,继续按规则判断
}
}
// Windows 规则:仅当 Chromium 且已绑定谷歌时返回 2,否则 -1
if (isWindows) {
return isChromium && hasGoogle ? 2 : -1;
}
// macOS 规则:Safari 走苹果;Chrome(Chromium) 优先谷歌,若未绑谷歌但已绑苹果则回退苹果
if (isMac) {
if (isSafari) return hasApple ? 1 : -1;
if (isChromium) return hasGoogle ? 2 : hasApple ? 1 : -1;
}
// 其它平台/浏览器:按你的要求一律不支持
return -1;
}备注:Chrome 在 macOS 上既能使用 Google Password Manager,也能使用 iCloud Keychain(取决于用户设置),因此在 macOS+Chrome 环境下的“优先谷歌、无谷歌则回退苹果”是有现实依据的。(Chrome for Developers)
广泛调研 & 我对这套实现的评估
这套“按平台/浏览器 + 绑定类型”来选提供方,合理吗?
可用,但偏脆弱:依赖 UA/UA-CH 做供应商推断,受浏览器策略变化影响较大;Safari/Chrome 在 macOS 上与各自密码管理器/系统钥匙串的关系也在持续演进。(MDN Web Docs)
更稳的思路是“能力探测优先”,例如:
PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()判断是否有平台验证器;PublicKeyCredential.isConditionalMediationAvailable()判断是否可用条件式 UI(无感自动填充登录);PublicKeyCredential.getClientCapabilities()(WebAuthn L3)检测浏览器支持的能力集合。(MDN Web Docs)
业界更推荐的做法(最佳实践)
不要“猜供应商”,而是让浏览器/系统根据 allowCredentials & Discoverable 自动选择:
- 若你知道用户的 credentialId 列表,把它们放进
allowCredentials,浏览器会在本机或外接密钥里精确匹配对应凭据; - 若做“账号名免输入/条件式 UI”场景,直接传空数组
allowCredentials: [],由浏览器列出可用 passkey 让用户选。(developers.yubico.com)
- 若你知道用户的 credentialId 列表,把它们放进
在注册时记录 AAGUID → 供应商(而非靠 UA 推断):
- WebAuthn 的 AAGUID 能标识创建该 passkey 的提供方(如 Google Password Manager、iCloud Keychain 等);
- 你可以在注册完成后,从
attestation/凭据信息中取 AAGUID,映射为“苹果/谷歌/其它”,存入数据库,后续无需再做 UA 猜测。 - 注意:苹果出于隐私,很多情况下会返回 全零 AAGUID 或不提供可用 attestation,这点要在实现上容错(可归类为“Apple/iCloud(可能)”或“未知”)。(web.dev)
优先用“条件式 UI(Conditional Mediation)”实现一键直登,并在不支持时回退到按钮触发的标准
get()流程:这也是目前 Web 平台推荐的用户体验路径。(web.dev)Chrome/macOS 的多提供方现实:Chrome 既可用 Google Password Manager,也可调用 iCloud Keychain;因此与其在前端硬编码“选择谁”,不如基于已绑定的具体 credentialId 让浏览器匹配。(Chrome for Developers)
参考规范与一线文档:WebAuthn L3 规范、MDN 指南、Google 开发者指南、Yubico 等,均强调基于能力与凭据的流程,而非供应商硬分支。(W3C)
更优实现范式(建议逐步替换)
目标:不返回“供应商类型”,而是让浏览器根据真实的 credentialId 去找可用的 passkey。
A. 已知用户 → 精确匹配其凭据
ts
// 复杂逻辑:把数据库里用户已绑定的 credentialId 映射为 allowCredentials
const allowCredentials = userCredentials.map((c) => ({
type: "public-key",
id: c.credentialIdUint8Array, // 服务器存的原始ID还原为 Uint8Array
// transports 可选
}));
// 复杂逻辑:常规触发登录(非条件式),由浏览器在本机/外接密钥中匹配这些ID
const assertion = await startAuthentication({
publicKey: {
challenge, // 后端下发
allowCredentials, // 关键在这
timeout: 60000,
userVerification: "preferred",
},
});这能确保只有用户已绑定的凭据会被使用,无需判断“苹果或谷歌”。(developers.yubico.com)
B. 免输入登录(Conditional UI)
ts
// 复杂逻辑:先做能力探测,只有支持条件式 UI 的浏览器才走 autofill
const isCond = await PublicKeyCredential.isConditionalMediationAvailable?.();
if (isCond) {
const assertion = await startAuthentication({
publicKey: {
challenge,
// 关键点:传空数组,让浏览器列出“可发现凭据”
allowCredentials: [],
userVerification: "required",
},
// 关键点:条件式 UI
mediation: "conditional",
});
}这是目前 Chrome/Safari 等推荐的“表单自动填充式”登录体验。(MDN Web Docs)
C. 注册时记录 AAGUID(标记供应商)
注册成功后的返回里读取 AAGUID,并映射成供应商枚举(如 apple / google / other),保存到你现在的 passkey_credentials 表。后续若你仍需要“类型”回传后端,也可据此生成,而不再依赖 UA 推测。(web.dev)
小结
- 以上给出的
decidePasskeyType()满足你现有规则,并考虑了绑定与回退; - 但从长期维护与用户体验看,更推荐迁移到“基于 allowCredentials / Discoverable / 条件式 UI / AAGUID”的范式,把“选择哪家供应商”的问题交还给浏览器与真实凭据,这是当前主流最佳实践。(Google for Developers)
如果你愿意,我可以把上面的“更优范式”落成到你现有的 @simplewebauthn/server 四个路由(/passkeys/{register|login}/{options|verify})里,补齐 allowCredentials 与 AAGUID 存取的最小改动清单。