-
Notifications
You must be signed in to change notification settings - Fork 2
Pact Passkeys
Passwordless sign-in with WebAuthn credentials: four ceremony methods over the same hook-and-session machinery as every other login. pact verifies the ceremonies server-side; the browser half is two calls on the standard JSON APIs, shown in the runnable passkey-signin example.
import { Pact } from '@tundralibs/pact';
const pact = Pact.create({
bits: { READ: 1n },
modulePermissions: { Notes: ['READ'] },
hooks: {
/* getUser + the four passkey hooks — see below */
},
options: {
passkeys: {
rpId: 'example.com', // the domain credentials are scoped to
rpName: 'Example',
origins: ['https://app.example.com'],
// userVerification: 'REQUIRED' (default) | 'PREFERRED' | 'DISCOURAGED'
// algorithms: ['ES256', 'RS256']
// timeout: 60000
},
},
});Option values follow pact's uppercase convention; pact maps them to the
lowercase wire values WebAuthn expects. userVerification defaults to
'REQUIRED': a passkey login replaces the password and the second factor,
so presence alone (an unlocked key without a PIN) is refused. Set
'PREFERRED' to accept authenticators that cannot verify the user. Configuring passkeys makes
getPasskey, getPasskeys, savePasskey, updatePasskeyCounter, and
getUser required — checked at construction, so a missing hook fails at
boot rather than surfacing mid-request. The stored shape is
PactStoredPasskey (public key as a JWK string; nothing in it is
secret); a suggested table lives in Storage.
An authenticated flow — the caller vouches for userId (a signed-in
user adding a device, or a signup that just created the account):
// 1. Begin: options for the browser, a challenge for the app to stash.
const begin = await pact.beginPasskeyRegistration(userId, displayName);
await stash(begin.challenge);
// browser: navigator.credentials.create({
// publicKey: PublicKeyCredential.parseCreationOptionsFromJSON(begin.options),
// }) → credential.toJSON() back to the server
// 2. Finish: verify and persist.
const record = await pact.finishPasskeyRegistration(userId, responseJson, {
challenge: await unstash(),
});The challenge is app-owned between the calls, exactly like the OAuth
state — pact holds no ceremony state, so the app must bind finish to
its begin: authenticate the finish call as the same userId the
ceremony was begun for, or key the challenge stash by user and ceremony
kind (Map<challenge, { kind, userId }>, as the example does). Under
attestation policy none a registration response needs no real
authenticator, so an unbound finish endpoint would let anyone attach
their own passkey to any account. Both ceremonies also re-check that the
user exists and is active at finish time. Existing credentials go into
excludeCredentials, so an authenticator refuses to re-register itself.
// Identifier-first: allowCredentials narrows to the user's passkeys.
const begin = await pact.beginPasskeyLogin('ada@example.dev');
// Usernameless: empty allowCredentials; the browser offers its
// discoverable credentials and pact resolves the user from the
// asserted credential.
const anonymous = await pact.beginPasskeyLogin();
// Finish mints a session through the standard pipeline — the result is
// a PactLoginResult, the `login` event fires with method 'PASSKEY'.
const result = await pact.finishPasskeyLogin(responseJson, { challenge });An unknown identifier is indistinguishable from a user with no
passkeys; a user with passkeys necessarily reveals their credential ids
in allowCredentials — the inherent identifier-first tradeoff. Offer
the usernameless form where that matters. The minted session is an ordinary bearer token: the middleware,
refresh, and logout all apply unchanged.
Every ceremony verifies the full checklist: clientData type, challenge,
and origin; rpIdHash against the configured rpId; user presence, and
user verification when 'REQUIRED'; the assertion signature against the
stored public key (ES256 and RS256).
| Path | Failure |
|---|---|
finishPasskeyLogin |
INVALID_CREDENTIALS — collapsed: unknown credential, origin or challenge mismatch, bad signature, and suspected clones are identical outward |
finishPasskeyRegistration |
PASSKEY_REGISTRATION_FAILED with a diagnostic reason
|
Ceremonies without options.passkeys
|
INVALID_OPTION |
| Missing passkey hooks |
MISSING_HOOK, thrown at construction (the five hooks above) |
| No session store at login |
MISSING_HOOK from finishPasskeyLogin — a session store is a login-time need, not construction-checked |
A signature counter at or below the stored value (both non-zero) is the
cloned-authenticator signal: the login fails as INVALID_CREDENTIALS
and passkeyCloneSuspected(credentialId, userId) fires server-side —
the place to alert or force re-enrollment. Synced passkeys (iCloud,
Google Password Manager) report 0 forever and skip the check, per spec.
Two operational notes. An authenticator that reports the same non-zero
counter at registration and its first assertion is treated as a clone on
first use — spec-correct, and rare in practice. And the counter check
races: two assertions read-verify-write concurrently can both pass, so
have updatePasskeyCounter reject non-monotonic writes
(UPDATE ... WHERE sign_count < ? — see Storage) if
clone detection matters to your deployment.
Attestation runs under policy 'none': the attestation statement is
parsed but certificate chains are not verified, which is the standard
posture unless you allowlist device models. Full attestation and
Ed25519 credentials are on the roadmap; passkey
management (listing and deleting a user's credentials) is app UI over
your own storage.