@ activescott/auth-professer-passkey (英语)
密码( WebAuthn) 提供者 @activescott/auth。用户在签名时添加了密码(先通过电子邮件或短信),然后在无用户名中签名,包括Touch ID、Face ID、Windows Hello、Android、1Password、iCloud 键链或安全键.
服务器侧WebAuthn 验证用途 @simplewebauthn/server (网易财经. 零依赖性浏览器客户端船作为 @activescott/auth-provider-passkey/browser 子路径导出 .
使用量
服务器接线 - 没有新的存储接口; 密码重用 IdentityStore 你已经有了:
import { Auth, InMemoryChallengeStore } from "@activescott/auth"
import { PasskeyProvider } from "@activescott/auth-provider-passkey"
const auth = new Auth({
session: { secret: process.env.JWT_SECRET! /* ... */ },
userStore,
identityStore,
challengeStore: new InMemoryChallengeStore(), // DB-backed in production
providers: [
// other providers such as email and/or SMS go here too — users add
// a passkey while signed in, so another provider handles first sign-in
new PasskeyProvider({
rpName: "MyApp",
// Bind passkeys to your canonical domain in production; leave unset in
// dev so rpID and origin derive from each request (e.g. localhost).
appUrl:
process.env.NODE_ENV === "production" ? process.env.APP_URL : undefined,
challengeSecret: process.env.JWT_SECRET!,
}),
],
})
浏览器(所有四个端点都是取出/JSON-WebAuthn仪式在页面JavaScript中运行,而不是形成导航). createPasskeyClient 获取选项, 运行仪式, 并张贴结果 :
import { createPasskeyClient } from "@activescott/auth-provider-passkey/browser"
const passkeys = createPasskeyClient() // basePath defaults to "/auth"
// Add a passkey (user must be signed in):
await passkeys.registerPasskey()
// Sign in with a passkey:
await passkeys.signInWithPasskey()
location.assign("/dashboard") // session cookie is set
当用户取消或服务器拒绝请求时,两者都会抛出. 服务器拒绝 Error.message 是最具体的文本 details.reason (例如:. "Unknown credential",在密码管理器中保存的密码密钥,其服务器的身份已不复存在),否则其密码密钥 message 因此,显示用户是合适的.
当浏览器的密码提示结束时没有证书(用户取消了它,因为它的站点没有这个设备的密码,或者另一个仪式中止了它),浏览器扔出一个 NotAllowedError, (中文(简体) ), AbortError,或 TimeoutError 他们的文字读起来像一个错误. describePasskeyError 将被捕获的错误排序为输入的结果, 这样您就可以显示自己的消息 :
import {
createPasskeyClient,
describePasskeyError,
} from "@activescott/auth-provider-passkey/browser"
try {
await passkeys.signInWithPasskey()
} catch (caught) {
const failure = describePasskeyError(caught)
// { ok: false, reason: "no-credential-or-cancelled" | "error", message, error }
showError(
failure.reason === "no-credential-or-cancelled"
? "No passkey for this site yet. Sign in with email, then add one."
: failure.message,
)
}
浏览器刻意不说其中的哪个发生了,因此一个站点无法探测出密码;一个信息必须覆盖所有密码.
在水龙头前获取选项
Safari(iOS和macOS)只显示密码板,而索要的窃听器仍在处理中。 独自召唤, signInWithPasskey() 先获取选项, 然后再打电话 navigator.credentials.get,而Safari 失败与 NotAllowedError调用 prepareSignIn() 当报应者升起的时候 prepareRegistration() 使用“添加密码”按钮:
// Create the client once, at module scope
export const passkeys = createPasskeyClient()
// In the component that renders the button; the returned function is the cleanup
useEffect(() => passkeys.prepareSignIn(), [])
选择一到,水龙头就到 navigator.credentials.get (或 create) 之前 signInWithPasskey() (或 registerPasskey())返回。 在准备时,客户端:
- 在挑战到期前再次获取
optionsMaxAge毫秒( 毫秒)createPasskeyClient({ optionsMaxAge }),默认为4分钟,默认为1分钟challengeExpiry) (中文(简体) ). 装到下面challengeExpiry如果你改变这一点. - 每次尝试失败后再次获取,因为每次挑战都是单用(参见 挑战)),并添加了密码后,因为下一个选项排除了它.
- 当一个在飞行中, 或当自动填充请求被绑定在选项上时, 从未开始第二个获取 。 每个选项响应都取代了挑战饼干,每个来源有一个.
- 当源代码上的其他任何选项获取选项时, 放弃其选项, 因为他们的挑战已经消失: 另一个准备呼叫, 另一个客户端, 或者另一个标签( told over)
BroadcastChannel,或localStorage”)。 隐藏标签不刷新, 当显示其选项已丢失或已停止时会再次获取 .
水龙头仍在等待取来, Safari 拒绝该时间的表, 当它出现在第一个选项到达之前, 在刷新的时候, 在有别的东西取代了饼干之后, 或者在标签被隐藏之后, 或者在后向缓存过去 optionsMaxAge 和在大地之前, 自动填充请求待决后, 选项不会被刷新, 因此在后方有一个自带 optionsMaxAge 也等着 两种都保持可见的标签,如两个相并的窗口,都无法同时备妥:最后取取的是一个. 客户端没有准备的信号可以禁用按钮 。 经过一次拒绝的敲击后,它再次取出,而下一次的敲击一旦落地就会起作用.
没有准备的号召,这两种方法仍然有效。 他们在水龙头取水, Chrome, Firefox,和Edge允许.
对于有条件的UI(登录表单上的密码自动填充),添加 autocomplete="username webauthn" 到您的用户名/ 电子邮件输入, 并在页面加载时启动有条件的请求 :
import {
createPasskeyClient,
isConditionalUIAvailable,
} from "@activescott/auth-provider-passkey/browser"
if (await isConditionalUIAvailable()) {
// Resolves once the user picks a passkey from the autofill suggestions and
// the server has set the session cookie. A later signInWithPasskey() call
// (e.g. from a button) aborts this pending request.
await createPasskeyClient().signInWithPasskey({ conditional: true })
location.assign("/dashboard")
}
startRegistration 财务报告和审定财务报表 startAuthentication 仍然为运行 HTTP 圆通路本身的代码导出,但会为客户端而贬值.
终点
| 终点 | 需要认证 | 目的 |
|---|---|---|
POST /auth/passkey/register-options | 第二届会议 | 为登录用户添加密码的注册选项 |
POST /auth/passkey/register-verify | 第二届会议 | 校验证明, 保存证书, 链接密码身份 |
POST /auth/passkey/authenticate-options | 无 | 认证选项( 空) allowCredentials • 任何可发现的证书) |
POST /auth/passkey/authenticate-verify | 无 | 校验断言并设定会话 cookie |
登记模式: 自动加码。用户先与另一个提供者(电子邮件,短信)签名,然后从设置/dashboard页面中添加一个密码;之后他们可以用无用户名签名。 不支持 Passkey- first 注册 .
配置
| 选项 | 默认 | 说明 |
|---|---|---|
rpName | (必须) | 认证符提示中显示的依赖方名称 |
appUrl | (未设定) | Canonical app URL(例如:. "https://myapp.example"; 设定在生产中 |
rpID | appUrl 主机名, 其他请求主机名 | 依赖方ID; 覆盖 appUrl (例如,母域) |
expectedOrigin | appUrl 来源,其他请求来源 | 期望的 WebAuthn 来源,或其中的列表; 覆盖 appUrl |
challengeSecret | (必须) | 在短命挑战饼干上签名 |
challengeExpiry | "5m" | 挑战寿命 |
challengeCookieName | "auth_passkey_challenge" | 挑战饼干名称 |
要接受几个共享一个依赖方ID主机的密码,例如网站及其预览子域,通过一个列表. 密钥会绑住 rpID,所以一个保存在 example.com 他们都工作:
new PasskeyProvider({
rpName: "MyApp",
appUrl: "https://example.com", // rpID "example.com"
expectedOrigin: ["https://example.com", "https://beta.example.com"],
challengeSecret: process.env.JWT_SECRET!,
})
需要列表 rpID 或者说 appUrl 设置。 除非每个 http(s) 来源是赤出源( 无路径或后端斜线) , 否则构造者会丢入 rpID 或它的子域,因为浏览器拒绝其它任何地方的仪式. Android等非网络起源 android:apk-key-hash:... 被无节制地通过.
存储:密码是身份
每个密码是普通的身份行—— {provider: "passkey", identifier: <base64url credential ID>} -所以你现在.. IdentityStore 这是唯一的存储。 证书的验证状态(公用钥匙,签名柜台,运输,设备类型,.) 活在行提供商拥有的 Identity.metadata您的商店将元数据视为不透明 JSON blob : 坚持未修改的元数据, 并如所储存的那样返回它—— 提供者用一个 佐德语 每个读取和写回 批发通过 IdentityStore.update 每次签入后(计数+最后使用). 典型的身份表 :
CREATE TABLE identities (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users (id),
provider TEXT NOT NULL, -- 'email' | 'sms' | 'passkey' | ...
identifier TEXT NOT NULL, -- email, E.164 phone, or WebAuthn credential ID
metadata JSONB NOT NULL DEFAULT '{}', -- provider-owned; opaque to the app
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
verified_at TIMESTAMPTZ,
UNIQUE (provider, identifier)
);
CREATE INDEX identities_user_id ON identities (user_id);
元数据可能包含敏感材料——把它当作证书数据处理(休息时加密是一种合理的默认). 这里的廉洁比保密更重要:任何能写出这个一栏的人都可以自己注册自己的密钥,因此卫视也相应写出.
要列出用户设置页面的密码, listPasskeys 过滤其身份以获取密码, 验证每行的提供者状态( 抽取无效行) , 并返回您可以直接转到页面的普通 JSON :
import { listPasskeys } from "@activescott/auth-provider-passkey"
const passkeys = await listPasskeys(identityStore, user.id)
// [{ credentialId, nickname: string | null, synced: boolean,
// createdAt: ISO string, lastUsedAt: ISO string | null }, ...]
标记是你的; synced 对于同步到云密钥链或密码管理器的密码.
挑战
选项端点设置了 HttpOnly, SameSite=Lax cookie , 包含已签名的 JWT(challengeSecret,5分钟过期),将仪式与浏览器绑定,并将挑战记录在核心中 challengeStore核查终点既需要,也需要消耗储存在第一次救赎努力上的挑战——成功与否——因此,每项挑战严格来说都是单用.
prepareSignIn() 财务报告和审定财务报表 prepareRegistration() 在打开一页时,每隔几分钟获取一个选项,而这些挑战中的大多数永远得不到弥补. InMemoryChallengeStore 扫描已过期行; a ChallengeStore 您必须删除过去的行 expiresAt 自己来.
跨平台注释
- 同步的密码 (iCloud Keychain, Google 密码管理器, 1Password) 报告
deviceType: "multiDevice"和通常为0的签名柜台. 反回归被记录为警告,但确实 没有 失败认证——同步的密码折回计数器合法,因此屏蔽会锁住真正的用户. - 参数定义: 密码与依赖方ID绑定.
localhost(a) 必须在生产领域建立生产钥匙。 rpID的子域可以使用证书;不同的可注册域不能使用. - 认证器是用户的选择: 以
residentKey: "preferred", (中文(简体) ),userVerification: "preferred",没有authenticatorAttachment,所以平台验证器,密码管理器, 和漫游安全密钥都工作. - 算法:ES256和RS256被接受,涵盖苹果,Google,微软,以及共同的安全密钥.