davepi-plugin-passkey
v0.1.0
Published
Passkey (WebAuthn) sign-in and recovery codes for dAvePi: a way in that doesn't depend on email. Usernameless discoverable-credential sign-in with required user verification, single-use challenges, signature-counter checks, and scrypt-hashed single-use re
Maintainers
Readme
davepi-plugin-passkey
Passkey (WebAuthn) sign-in and recovery codes for dAvePi: a way in that doesn't depend on email.
Magic links stop working when a person's mail provider is down. A passkey (Face ID, Touch ID, Windows Hello, a phone or a security key) doesn't, and neither does a recovery code. Like davepi-plugin-magic-link, a successful sign-in mints a normal davepi session with utils/tokens.issueTokenPair, so the rest of your API, your claims provider and your session validator treat it like any other sign-in.
Install
npm install davepi-plugin-passkey{
"davepi": {
"plugins": ["davepi-plugin-magic-link", "davepi-plugin-passkey"]
}
}Set:
| Variable | |
|---|---|
| PASSKEY_RP_ID | Required. Your site's domain, e.g. example.com. Passkeys are bound to it and work on it and its subdomains. Changing it later strands every existing passkey. |
| PASSKEY_ORIGINS | Required. Comma-separated origins your web app runs on, e.g. https://app.example.com. Each must be https:// (or http://localhost) on PASSKEY_RP_ID or a subdomain of it. |
| PASSKEY_RP_NAME | The name browsers show. Defaults to APP_NAME. |
| PASSKEY_PATH | Default /auth/passkey. |
| PASSKEY_RECOVERY_CODES | Codes per set, 6–20. Default 10. |
Without PASSKEY_RP_ID and PASSKEY_ORIGINS (or with origins it can't serve) the plugin logs a warning and mounts nothing.
Routes
Public (rate-limited with the framework's authLimiter):
| | Body | Answer |
|---|---|---|
| POST /auth/passkey/login/options | | WebAuthn request options for navigator.credentials.get |
| POST /auth/passkey/login/verify | { response } | { accessToken, refreshToken, user } |
| POST /auth/passkey/recovery/verify | { email, code } | { accessToken, refreshToken, user, recovery: { remaining } } |
Signed in (a person's own session; API keys and X-Client-Id callers get 403):
| | Body | Answer |
|---|---|---|
| POST /auth/passkey/register/options | | WebAuthn creation options for navigator.credentials.create |
| POST /auth/passkey/register/verify | { response, name? } | 201 { passkey, recoveryCodes }. recoveryCodes is set when the person had none left (show them once); otherwise null. |
| GET /auth/passkey/credentials | | { results: [{ id, name, deviceType, backedUp, createdAt, lastUsedAt }] } |
| PATCH /auth/passkey/credentials/:id | { name } | { passkey } |
| DELETE /auth/passkey/credentials/:id | | 204 |
| GET /auth/passkey/recovery | | { remaining, total, createdAt } |
| POST /auth/passkey/recovery/regenerate | | { recoveryCodes }. The old codes stop working. |
In the browser, @simplewebauthn/browser turns the options into a response: startRegistration({ optionsJSON }) and startAuthentication({ optionsJSON }).
How it keeps sign-in safe
- Usernameless sign-in. Sign-in options name no account (discoverable credentials), so asking for them reveals nothing. The passkey the browser offers identifies itself, and the stored public key decides.
- User verification is required. A passkey is the whole sign-in here, not a second factor, so the authenticator must check a PIN, fingerprint or face.
- Single-use challenges. Each ceremony's challenge is random, lasts 5 minutes and is claimed atomically before the response is checked, so a response can't be replayed or raced. Origin and RP ID are checked by
@simplewebauthn/server. - Cloned authenticators. A signature counter that goes backwards is refused.
- Only public keys are stored. WebAuthn never shares the private key.
- Recovery codes are 10 characters of Crockford base32 (50 bits), shown once, stored only as salted scrypt hashes, and spent on use. Wrong codes and unknown emails get the same answer after the same amount of work.
Hooks
const passkey = require('davepi-plugin-passkey');
// After passkey.added, passkey.removed, passkey.signin,
// recovery.signin and recovery.regenerated — e.g. to email a security notice.
passkey.onEvent(async ({ type, userId, req }) => { /* … */ });
// Which of these users have a passkey (a Set of strings).
const withPasskeys = await passkey.passkeyUserIds(userIds);The hook is best-effort: a throw is logged and the request still succeeds.
Data
Four collections, none of them a davepi schema (so no generated route, GraphQL type or MCP tool exposes them): passkey_credentials, passkey_challenges (TTL-indexed), passkey_recovery_codes, and passkey_recovery_sets (each person's current set of codes; only that set counts).
