@activescott/auth-provider-passkey

中文文档
2.3.0发布于 2026/10/31MITTypeScriptES Module构建来源已验证近 30 天下载 3422

Passkey (WebAuthn) provider for @activescott/auth

原始使用文档
文档目录7 节
  1. Usage
  2. Fetch options before the tap
  3. Endpoints
  4. Configuration
  5. Storage: passkeys are identities
  6. Challenges
  7. Cross-platform notes

@activescott/auth-provider-passkey

npm version License: MIT

Passkey (WebAuthn) provider for @activescott/auth. Users add a passkey while signed in (via email or SMS first), then sign in usernameless with Touch ID, Face ID, Windows Hello, Android, 1Password, iCloud Keychain, or a security key.

Server-side WebAuthn verification uses @simplewebauthn/server (WebCrypto-based). A zero-dependency browser client ships as the @activescott/auth-provider-passkey/browser subpath export.

Usage

Server wiring — no new storage interface; passkeys reuse the IdentityStore you already have:

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!,
    }),
  ],
})

Browser (all four endpoints are fetch/JSON — WebAuthn ceremonies run in page JavaScript, not form navigations). createPasskeyClient fetches the options, runs the ceremony, and posts the result:

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

Both throw when the user cancels or the server rejects the request. A server rejection's Error.message is the most specific text the response carries — the error's details.reason (e.g. "Unknown credential", a passkey saved in a password manager whose identity the server no longer has), else its message — so it is fit to show the user.

When the browser's passkey prompt ends without a credential (the user cancelled it, it timed out because the site has no passkey on this device, or another ceremony aborted it), the browser throws a NotAllowedError, AbortError, or TimeoutError whose text reads like a fault. describePasskeyError sorts a caught error into a typed result so you can show your own message instead:

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,
  )
}

Browsers deliberately don't say which of those happened, so a site can't probe for passkeys; one message has to cover them all.

Fetch options before the tap

Safari (iOS and macOS) shows the passkey sheet only while the tap that asked for it is still being handled. Called on its own, signInWithPasskey() fetches the options first and only then calls navigator.credentials.get, and Safari fails that with NotAllowedError. Call prepareSignIn() when the sign-in UI mounts, and prepareRegistration() on the page with the "Add a passkey" button:

// 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(), [])

Once the options have arrived, a tap reaches navigator.credentials.get (or create) before signInWithPasskey() (or registerPasskey()) returns. While preparing, the client:

  • fetches again before the challenge expires, every optionsMaxAge milliseconds (createPasskeyClient({ optionsMaxAge }), default 4 minutes, a minute under the default challengeExpiry). Set it below challengeExpiry if you change that.
  • fetches again after each attempt fails, since every challenge is single-use (see Challenges), and after a passkey is added, since the next options exclude it.
  • never starts a second fetch while one is in flight, while the passkey sheet is open, or while an autofill request is bound to the options. Each options response replaces the challenge cookie, and there is one per origin.
  • drops its options when anything else on the origin fetches options, since their challenge is gone: the other prepare call, another client, or another tab (told over BroadcastChannel, or localStorage where that is missing). A hidden tab does not refresh, and fetches again when shown if its options are gone or stale.

A tap still waits for a fetch, and Safari refuses the sheet that time, when it comes before the first options arrive, during a refresh, after something else replaced the cookie, or after the tab sat hidden or in the back/forward cache past optionsMaxAge and before its refetch lands. With a pending autofill request the options are not refreshed, so a tap after optionsMaxAge waits too. Two tabs that both stay visible, such as two windows side by side, cannot both be ready: the one that fetched last is. The client has no readiness signal to disable the button with. After a refused tap it fetches again, and the next tap works once that lands.

Without the prepare call, both methods still work. They fetch at the tap, which Chrome, Firefox, and Edge allow.

For conditional UI (passkey autofill on the login form), add autocomplete="username webauthn" to your username/email input and start a conditional request on page load:

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 and startAuthentication are still exported for code that runs the HTTP round trips itself, but are deprecated in favor of the client.

Endpoints

EndpointAuth requiredPurpose
POST /auth/passkey/register-optionssessionRegistration options for adding a passkey to the signed-in user
POST /auth/passkey/register-verifysessionVerify the attestation, store the credential, link a passkey identity
POST /auth/passkey/authenticate-optionsnoneAuthentication options (empty allowCredentials → any discoverable credential)
POST /auth/passkey/authenticate-verifynoneVerify the assertion and set the session cookie

Registration model: add-passkey-while-signed-in. Users sign in with another provider (email, SMS) first, then add a passkey from a settings/dashboard page; afterwards they can sign in usernameless. Passkey-first signup is not supported.

Configuration

OptionDefaultDescription
rpName(required)Relying party name shown in authenticator prompts
appUrl(unset)Canonical app URL (e.g. "https://myapp.example"); set in production
rpIDappUrl hostname, else request hostnameRelying party ID; overrides appUrl (e.g. a parent domain)
expectedOriginappUrl origin, else request originExpected WebAuthn origin, or a list of them; overrides appUrl
challengeSecret(required)Signs the short-lived challenge cookie
challengeExpiry"5m"Challenge lifetime
challengeCookieName"auth_passkey_challenge"Challenge cookie name

To accept passkeys on several hosts that share one relying party ID, such as a site and its preview subdomains, pass a list. A passkey stays bound to rpID, so one saved on example.com works on all of them:

new PasskeyProvider({
  rpName: "MyApp",
  appUrl: "https://example.com", // rpID "example.com"
  expectedOrigin: ["https://example.com", "https://beta.example.com"],
  challengeSecret: process.env.JWT_SECRET!,
})

A list needs rpID or appUrl set. The constructor throws unless each http(s) origin is a bare origin (no path or trailing slash) on rpID or a subdomain of it, since browsers refuse a ceremony anywhere else. Non-web origins such as Android's android:apk-key-hash:... are passed through unchecked.

Storage: passkeys are identities

Each passkey is an ordinary identity row — {provider: "passkey", identifier: <base64url credential ID>} — so your existing IdentityStore is the only storage involved. The credential's verification state (public key, signature counter, transports, device type, ...) lives in the row's provider-owned Identity.metadata. Your store treats that metadata as an opaque JSON blob: persist it unmodified and return it exactly as stored — the provider validates it with a zod schema on every read and writes it back wholesale via IdentityStore.update after each sign-in (counter + last-used). A typical identities table:

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);

Metadata may contain sensitive material — treat it like credential data (encryption at rest is a reasonable default). Integrity matters more than secrecy here: anyone who can write this column can register their own key, so guard writes accordingly.

To list a user's passkeys for a settings page, listPasskeys filters their identities to passkeys, validates each row's provider state (skipping invalid rows), and returns plain JSON you can hand straight to the page:

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 }, ...]

The markup is yours; synced is true for passkeys synced to a cloud keychain or password manager.

Challenges

The options endpoints set an HttpOnly, SameSite=Lax cookie containing a signed JWT (challengeSecret, 5-minute expiry) that binds the ceremony to the browser, and record the challenge in the core challengeStore. The verify endpoints require both and consume the stored challenge on the first redemption attempt — success or not — so every challenge is strictly single-use.

prepareSignIn() and prepareRegistration() fetch options every few minutes while a page is open, and most of those challenges are never redeemed. InMemoryChallengeStore sweeps expired rows; a ChallengeStore you write has to delete rows past expiresAt itself.

Cross-platform notes

  • Synced passkeys (iCloud Keychain, Google Password Manager, 1Password) report deviceType: "multiDevice" and usually a signature counter of 0. A counter regression is logged as a warning but does not fail authentication — synced passkeys regress counters legitimately, so blocking would lock out real users.
  • rpID scoping: a passkey is bound to its relying party ID. localhost works for development; production passkeys must be created on the production domain. Subdomains of the rpID can use the credential; a different registrable domain cannot.
  • Authenticator choice is the user's: options are generated with residentKey: "preferred", userVerification: "preferred", and no authenticatorAttachment, so platform authenticators, password managers, and roaming security keys all work.
  • Algorithms: ES256 and RS256 are accepted, covering Apple, Google, Microsoft, and common security keys.

版本与发布记录

@activescott/auth-provider-passkey 共发布 6 个版本,首次发布于 2026/08/02;过去 12 个月发布了 6 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
2.3.0latest2026/10/317 天前
2.2.02026/10/031 个月前
2.1.02026/09/191 个月前
2.0.02026/08/122 个月前
1.0.02026/08/092 个月前
0.1.02026/08/023 个月前

关于 @activescott/auth-provider-passkey 的常见问题

@activescott/auth-provider-passkey 是什么?

Passkey (WebAuthn) provider for @activescott/auth

如何安装 @activescott/auth-provider-passkey?

在项目目录运行 npm install @activescott/auth-provider-passkey,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@activescott/auth(^5.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@activescott/auth-provider-passkey 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@activescott/auth-provider-passkey 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@activescott/auth-provider-passkey 可以商用吗?

@activescott/auth-provider-passkey 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@activescott/auth-provider-passkey 的下载量有多少?

根据 npm 官方下载统计,@activescott/auth-provider-passkey 在 2026/10/05 – 2026/11/03 这 30 天内被下载 3,422 次,日均约 114 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@activescott/auth-provider-passkey 还在维护吗?

最新版本 2.3.0 发布于 2026/10/31(7 天前);至今共发布 6 个版本,其中过去 12 个月发布了 6 个;首次发布于 2026/08/02。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。