@basenative/auth-webauthn

中文文档
3.3.0发布于 2026/11/03Apache-2.0TypeScriptES Module零依赖构建来源已验证近 30 天下载 2135

WebAuthn (passkey) adapter for @basenative/auth — server, client, and Cloudflare Workers/Pages handlers

原始使用文档
文档目录17 节
  1. Why
  2. Install
  3. Quick start (Cloudflare Pages Functions + D1)
  4. 1. Bindings ( wrangler.toml )
  5. 2. Apply the schema
  6. 3. Wire the six handlers
  7. Client-side usage
  8. Role seeding
  9. Storage interface (the contract)
  10. Configuration
  11. userVerification
  12. Migrating an existing project
  13. From express-session / cookie-session (PendingBusiness)
  14. From NextAuth / Auth.js
  15. Security notes
  16. Testing
  17. License

@basenative/auth-webauthn

WebAuthn (passkey) provider for Cloudflare Workers / Pages. Follows the same { type, ... } provider shape as @basenative/auth, but has no dependency on it. Drop-in passkey login, with pluggable storage and a tiny browser client.

Part of the BaseNative ecosystem. Lifted from the production-tested passkey implementation in t4bs.

Why

Modern passkey auth is a small amount of code with a lot of papercuts. This package handles the papercuts (challenge TTL, cookie flags, base64url encoding, WebAuthn version drift, role seeding) so your app code stays small.

Install

pnpm add @basenative/auth-webauthn @simplewebauthn/server
# Browser-side (only on the client):
pnpm add @simplewebauthn/browser

@simplewebauthn/server is loaded lazily — peers aren't pulled into your worker bundle until first use.

Quick start (Cloudflare Pages Functions + D1)

1. Bindings (wrangler.toml)

[[d1_databases]]
binding = "DB"
database_name = "myapp-db"
database_id = "..."

[vars]
RP_NAME   = "MyApp"
RP_ID     = "myapp.com"          # use "localhost" in dev
RP_ORIGIN = "https://myapp.com"  # use "http://localhost:8788" in dev
ADMIN_HANDLES = "warren,bob"     # optional — see "Role seeding"

2. Apply the schema

wrangler d1 execute DB --local  --file=./node_modules/@basenative/auth-webauthn/migrations/0001_webauthn_schema.sql
wrangler d1 execute DB --remote --file=./node_modules/@basenative/auth-webauthn/migrations/0001_webauthn_schema.sql

Or copy the SQL into your own migrations/ directory.

3. Wire the six handlers

Create one file per route under functions/api/auth/. They're all the same shape:

// functions/api/auth/_adapter.js
import {
  webauthnAdapter,
  d1WebAuthnStores,
} from '@basenative/auth-webauthn';

export const getAdapter = (env) =>
  webauthnAdapter({
    rp: { rpName: env.RP_NAME, rpID: env.RP_ID, origin: env.RP_ORIGIN },
    stores: d1WebAuthnStores(env.DB),
  });
// functions/api/auth/register-options.js
import { registerOptionsHandler } from '@basenative/auth-webauthn/handlers';
import { getAdapter } from './_adapter.js';
export const onRequestPost = registerOptionsHandler(getAdapter);
// functions/api/auth/register-verify.js
import { registerVerifyHandler } from '@basenative/auth-webauthn/handlers';
import { getAdapter } from './_adapter.js';
export const onRequestPost = registerVerifyHandler(getAdapter);
// functions/api/auth/login-options.js
import { loginOptionsHandler } from '@basenative/auth-webauthn/handlers';
import { getAdapter } from './_adapter.js';
export const onRequestPost = loginOptionsHandler(getAdapter);
// functions/api/auth/login-verify.js
import { loginVerifyHandler } from '@basenative/auth-webauthn/handlers';
import { seedRoles, parseHandleList, d1WebAuthnStores } from '@basenative/auth-webauthn';
import { getAdapter } from './_adapter.js';

export const onRequestPost = loginVerifyHandler(getAdapter, {
  onLogin: async ({ env, user }) => {
    if (!user) return;
    const stores = d1WebAuthnStores(env.DB);
    await seedRoles({
      stores,
      user,
      seedMap: parseHandleList(env.ADMIN_HANDLES, 'admin'),
      changedBy: 'seed:ADMIN_HANDLES',
    });
  },
});
// functions/api/auth/me.js
import { meHandler } from '@basenative/auth-webauthn/handlers';
import { getAdapter } from './_adapter.js';
export const onRequestGet = meHandler(getAdapter);
// functions/api/auth/logout.js
import { logoutHandler } from '@basenative/auth-webauthn/handlers';
import { getAdapter } from './_adapter.js';
export const onRequestPost = logoutHandler(getAdapter);

That's the entire server side.

Client-side usage

import {
  isPasskeySupported,
  registerPasskey,
  loginPasskey,
  me,
  logout,
} from '@basenative/auth-webauthn/client';

if (isPasskeySupported()) {
  await registerPasskey('alice');   // → /me payload
  await loginPasskey('alice');      // or loginPasskey('') for usernameless
  const { user } = await me();
  await logout();
}

Custom endpoint paths:

await registerPasskey('alice', {
  paths: {
    registerOptions: '/auth/passkey/register/start',
    registerVerify:  '/auth/passkey/register/finish',
    me:              '/auth/whoami',
  },
});

Role seeding

If your app has admins/moderators, list them in an env var (or anywhere). On login, upgrade matching handles automatically:

import { seedRoles, parseHandleList } from '@basenative/auth-webauthn';

await seedRoles({
  stores,
  user,
  seedMap: parseHandleList(env.ADMIN_HANDLES, 'admin'),
  changedBy: 'seed:ADMIN_HANDLES',
});

This is a no-op when the user is already at or above the target role — admins won't be downgraded. Use the seedMap directly for arbitrary mappings ({ alice: 'admin', mod-bob: 'moderator' }).

Storage interface (the contract)

webauthnAdapter takes any stores object with these four members:

interface WebAuthnStores {
  users: {
    getByHandle(handle): Promise<User | null>;
    getById(id):         Promise<User | null>;
    create({ id, handle }): Promise<User>;          // returns { id, handle, role: 'user' }
    setRole?(id, role, changedBy): Promise<void>;   // optional, used by seedRoles
  };

  credentials: {
    listByUser(userId):  Promise<Credential[]>;
    getById(credId):     Promise<Credential & { userId } | null>;
    create({ id, userId, publicKey, counter, transports }): Promise<void>;
    updateCounter(id, counter): Promise<void>;
  };

  challenges: {
    create({ challenge, userId, purpose, ttlSeconds }): Promise<void>;
    consume(challenge, purpose): Promise<{ userId } | null>; // single-use; deletes on read
  };

  userSessions: {
    create({ id, userId, ttlSeconds }): Promise<void>;
    getUser(token):       Promise<User | null>;     // returns null if expired
    destroy(token):       Promise<void>;
  };
}

d1WebAuthnStores(DB) is the reference implementation; write your own to back this with Postgres, MySQL, Redis, or Durable Objects. The migration constant exported from @basenative/auth-webauthn/d1-stores contains the same SQL as the migrations file, in case you want to apply it programmatically.

Configuration

webauthnAdapter({
  rp: {
    rpName: 'MyApp',
    rpID:   'myapp.com',
    origin: 'https://myapp.com',
  },
  stores,
  ttl: {
    sessionSeconds:   30 * 24 * 3600, // default
    challengeSeconds: 300,            // default; clamped to ≤ 300
  },
  cookieName:    'bn_auth',           // default
  secureCookie:  true,                // default; set false for localhost http://
  userVerification: 'preferred',      // default; also 'required' | 'discouraged'
});

The session cookie is always HttpOnly; SameSite=Lax. Secure is on by default.

userVerification

Controls how strongly the ceremony asks for (and checks for) user verification — PIN, biometric, or device passcode — as opposed to mere user presence (a touch or tap). One option drives both sides so they can't disagree:

  • Registration: authenticatorSelection.userVerification
  • Authentication: userVerification
  • Verification: requireUserVerification: userVerification === 'required'
ValueCeremony asks for UVVerification enforces UV
'preferred' (default)yes, if the authenticator supports itno
'required'yesyes — assertions without UV are rejected
'discouraged'nono

Security note: 'preferred' is the default because it matches existing behaviour and keeps every previously-registered passkey working — some authenticators (particularly older security keys) never perform UV, only user presence. Set 'required' when your threat model needs a second factor baked into the passkey itself (e.g. shared devices, high-value accounts): it's strictly stronger, but it will reject authentication from any authenticator that can't perform UV, including credentials registered before you turned it on. Roll it out only after confirming your users' authenticators support UV, or pair it with a re-registration prompt.

Migrating an existing project

If your current auth looks like:

// before
app.use(session({ secret, store: new RedisStore({ ... }) }));
app.post('/login', async (req, res) => {
  if (await checkPassword(req.body.email, req.body.password)) {
    req.session.user = await db.users.findByEmail(req.body.email);
    res.redirect('/');
  } else {
    res.status(401).send('bad-creds');
  }
});
app.get('/me', (req, res) => res.json({ user: req.session.user || null }));

The mapping is:

Express-session@basenative/auth-webauthn
req.session.user (loaded by middleware)await adapter.currentUser(request)
req.session.user = ... (login)verifyAuthentication returns a session token
req.session.destroy() (logout)adapter.destroySession(token)
RedisStore / MemoryStoreswap stores.userSessions for a Redis-backed one
secret (signed cookies)not needed — session token is opaque + random

Step-by-step migration:

  1. Add the schema. Run migrations/0001_webauthn_schema.sql against your DB. If you already have a users table, replace the CREATE TABLE users line with ALTER TABLE statements that add role, role_changed_at, role_changed_by. Keep your existing email, password_hash, etc. — passkey-only doesn't require dropping them.
  2. Pick a handle field. PendingBusiness probably authenticates by email; the adapter validates handles against ^[a-z0-9_-]{2,24}$. Either expose a separate handle field or override validHandle by writing a tiny custom store layer that hashes/normalizes emails. (Most apps add a one-time "pick a username" step at first login; users like it more than emails as login IDs.)
  3. Run both auths in parallel. Mount the new /api/auth/* endpoints alongside the existing /login route. Add a "Set up passkey" button on the account-settings page that calls registerPasskey(currentUser.handle). Existing password-based logins keep working.
  4. Migrate session reads. Where you have req.session.user, switch to await adapter.currentUser(req). If you're on Express, write a thin middleware:
    app.use(async (req, _res, next) => {
      req.user = await adapter.currentUser(req);
      next();
    });
    
    The adapter is runtime-agnostic — it just needs a Request-shaped object with .headers.get('cookie'). Wrap Express's req.headers if needed.
  5. Cut over. Once every active user has at least one passkey, retire the password endpoints. The DB rows can stay — password_hash simply becomes unused.

From NextAuth / Auth.js

Those libraries already speak provider plugins; this package fits the same model. Implement an adapter call inside a custom NextAuth provider's authorize callback that wraps verifyAuthentication. The cookie name in webauthnAdapter should match your NextAuth cookie if you want session reuse, otherwise use distinct names and call both currentUser and getSession until you cut over.

Security notes

  • Challenge rows are single-use and deleted on consume(), regardless of expiry.
  • Challenge TTL is clamped to ≤ 5 minutes server-side. Don't try to override it higher; consumer code that passes a larger value gets silently clamped.
  • Cookies are HttpOnly; Secure; SameSite=Lax. Disable Secure only for local-HTTP development.
  • Errors come back as { error: 'reason' } with a numeric status. The reason strings are stable and safe to log; no PII or PII-derivable values are included.
  • seedRoles only ever upgrades. It will never remove admin from a user, even if they're missing from the seed list — handle role removal manually.
  • userVerification defaults to 'preferred' (UV requested, not enforced) — see the "Configuration" section above for the 'required' tradeoff.

Testing

pnpm --filter @basenative/auth-webauthn test

The unit tests inject a fake WebAuthn primitives object (opts.lib) so the suite runs without the @simplewebauthn/server peer dep installed. Production code never passes opts.lib; it's an internal hook.

License

Apache-2.0

版本与发布记录

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

版本发布日期距今
3.3.0latest2026/11/035 天前
3.2.02026/11/035 天前
3.1.02026/11/017 天前
3.0.02026/10/317 天前
2.4.02026/10/2613 天前
2.3.22026/10/2217 天前

关于 @basenative/auth-webauthn 的常见问题

@basenative/auth-webauthn 是什么?

WebAuthn (passkey) adapter for @basenative/auth — server, client, and Cloudflare Workers/Pages handlers

如何安装 @basenative/auth-webauthn?

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

@basenative/auth-webauthn 支持 TypeScript 吗?

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

@basenative/auth-webauthn 支持 ESM 还是 CommonJS?

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

@basenative/auth-webauthn 可以商用吗?

@basenative/auth-webauthn 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@basenative/auth-webauthn 的下载量有多少?

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

@basenative/auth-webauthn 还在维护吗?

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