Passkey Authentication - jra3/mulm GitHub Wiki

Passkey Authentication

WebAuthn/FIDO2 passwordless authentication using @simplewebauthn.

Overview

Passkeys provide biometric authentication (Touch ID, Face ID, Windows Hello) as a third login method alongside password and Google OAuth.

UX Pattern: Conditional UI - passkeys appear in email field autofill (only if registered).


Table of Contents

  1. Database Schema
  2. API Routes
  3. Registration Flow
  4. Login Flow
  5. Frontend Implementation
  6. Configuration
  7. Testing

Database Schema

webauthn_credentials

CREATE TABLE webauthn_credentials (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    member_id INTEGER NOT NULL REFERENCES members(id) ON DELETE CASCADE,
    credential_id TEXT NOT NULL UNIQUE,
    public_key BLOB NOT NULL,
    counter INTEGER NOT NULL DEFAULT 0,
    transports TEXT,              -- JSON: ["internal", "usb", "nfc", "ble"]
    device_name TEXT,             -- User-assigned (e.g., "iPhone", "MacBook Pro")
    created_on DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    last_used_on DATETIME
);

CREATE INDEX idx_webauthn_member_id ON webauthn_credentials (member_id);
CREATE INDEX idx_webauthn_credential_id ON webauthn_credentials (credential_id);

webauthn_challenges

CREATE TABLE webauthn_challenges (
    challenge TEXT PRIMARY KEY,
    member_id INTEGER,           -- NULL for login, present for registration
    purpose TEXT NOT NULL,       -- 'registration' or 'authentication'
    expires_on DATETIME NOT NULL -- 5 minutes from creation
);

Cleanup: Challenges deleted after single use OR 5-minute expiration.


API Routes

Registration (Requires Login):

  • POST /auth/passkey/register/options - Get challenge
  • POST /auth/passkey/register/verify - Save credential

Login (Public):

  • POST /auth/passkey/login/options - Get challenge
  • POST /auth/passkey/login/verify - Authenticate

Management (Requires Login):

  • DELETE /auth/passkey/:id - Remove passkey
  • PATCH /auth/passkey/:id/name - Rename passkey

Implementation

POST /auth/passkey/register/options

// src/routes/auth.ts
export const passkeyRegisterOptions = async (req: MulmRequest, res: Response) => {
  if (!req.viewer) {
    return res.status(401).json({ error: 'Not authenticated' });
  }

  const options = await generateRegistrationOptionsForMember(
    req.viewer.id,
    req.viewer.contact_email,
    req.viewer.display_name
  );

  res.json(options);
};

POST /auth/passkey/register/verify

export const passkeyRegisterVerify = async (req: MulmRequest, res: Response) => {
  if (!req.viewer) {
    return res.status(401).json({ error: 'Not authenticated' });
  }

  const { credential, deviceName } = req.body;

  const result = await verifyAndSaveCredential(
    req.viewer.id,
    credential,  // RegistrationResponseJSON from @simplewebauthn/browser
    deviceName
  );

  if (result.verified) {
    res.json({ verified: true, credentialId: result.credentialId });
  } else {
    res.status(400).json({ error: 'Verification failed' });
  }
};

POST /auth/passkey/login/verify

export const passkeyLoginVerify = async (req: MulmRequest, res: Response) => {
  const credential = req.body;

  const result = await verifyCredentialAndAuthenticate(credential);

  if (result.verified && result.memberId) {
    await regenerateSession(req, res, result.memberId);
    res.json({ verified: true });
  } else {
    res.status(401).json({ error: 'Authentication failed' });
  }
};

Registration Flow

sequenceDiagram
    participant User
    participant Browser
    participant App
    participant DB

    User->>Browser: Click "Add New Passkey"
    Browser->>User: Prompt for device name
    User->>Browser: Enter "MacBook Pro"
    Browser->>App: POST /auth/passkey/register/options
    App->>App: Generate challenge
    App->>DB: INSERT challenge (5min expiry)
    App-->>Browser: Registration options JSON
    Browser->>Browser: startRegistration(options)
    Browser->>User: Touch ID/Face ID prompt
    User->>Browser: Authenticate (biometric)
    Browser->>App: POST /auth/passkey/register/verify
    App->>App: Verify attestation
    App->>DB: INSERT webauthn_credentials
    App->>DB: DELETE challenge (single-use)
    App-->>Browser: Success
    Browser-->>User: Page reload (shows new passkey)

Key functions:

// src/auth/webauthn.ts
export async function generateRegistrationOptionsForMember(
  memberId: number,
  userEmail: string,
  userName: string
): Promise<PublicKeyCredentialCreationOptionsJSON> {
  const existingCredentials = await getCredentialsByMember(memberId);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: userEmail,
    userDisplayName: userName,
    userID: Buffer.from(String(memberId)),
    attestationType: 'none',
    excludeCredentials: existingCredentials.map(cred => ({
      id: cred.credential_id,
      transports: cred.transports ? JSON.parse(cred.transports) : undefined,
    })),
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred',
      authenticatorAttachment: 'platform',  // Prefer Touch ID/Face ID
    },
  });

  await saveChallenge(options.challenge, 'registration', memberId);
  return options;
}

Login Flow

sequenceDiagram
    participant User
    participant Browser
    participant App
    participant DB

    User->>Browser: Navigate to signin page
    Browser->>App: Load signin.pug
    App-->>Browser: HTML + conditional UI script
    Browser->>App: POST /auth/passkey/login/options
    App->>App: Generate challenge
    App->>DB: INSERT challenge (5min expiry)
    App-->>Browser: Authentication options JSON
    Browser->>Browser: startAuthentication(options, true)
    Note over Browser: Passkey appears in email autofill
    User->>Browser: Click email field
    Browser-->>User: Show passkey in dropdown
    User->>Browser: Select passkey
    Browser->>User: Touch ID/Face ID prompt
    User->>Browser: Authenticate
    Browser->>App: POST /auth/passkey/login/verify
    App->>DB: SELECT credential by ID
    App->>App: Verify signature + counter
    App->>DB: UPDATE counter + last_used_on
    App->>DB: INSERT session
    App-->>Browser: Success + Set cookie
    Browser-->>User: Redirect home (logged in)

Key functions:

// src/auth/webauthn.ts
export async function verifyCredentialAndAuthenticate(
  response: AuthenticationResponseJSON
): Promise<{ verified: boolean; memberId?: number }> {
  // 1. Get credential from database
  const credentialIdBase64 = Buffer.from(response.id, 'base64url').toString('base64url');
  const credential = await getCredentialById(credentialIdBase64);
  if (!credential) return { verified: false };

  // 2. Get and validate challenge
  const clientDataJSON = Buffer.from(response.response.clientDataJSON, 'base64').toString();
  const clientData = JSON.parse(clientDataJSON);
  const challengeData = await getChallenge(clientData.challenge);
  if (!challengeData) return { verified: false };

  // 3. Verify signature
  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge: challengeData.challenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: credential.credential_id,
      publicKey: credential.public_key,
      counter: credential.counter,
    },
  });

  if (!verification.verified) return { verified: false };

  // 4. Update counter (replay attack prevention)
  await updateCredentialCounter(
    credential.credential_id,
    verification.authenticationInfo.newCounter
  );

  return { verified: true, memberId: credential.member_id };
}

Frontend Implementation

Signin Page (Conditional UI)

File: src/views/account/signin.pug

+textInput("Email", "email")(autocomplete="username webauthn")

script(type="module").
  import { startAuthentication } from 'https://esm.sh/@simplewebauthn/[email protected]';

  (async function() {
    // Check Conditional UI support
    if (!window.PublicKeyCredential?.isConditionalMediationAvailable) return;
    if (!await PublicKeyCredential.isConditionalMediationAvailable()) return;

    // Get authentication options
    const optRes = await fetch('/auth/passkey/login/options', { method: 'POST' });
    if (!optRes.ok) return;
    const opts = await optRes.json();

    // Start conditional UI - passkeys appear in email autofill
    const credential = await startAuthentication(opts, true); // true = conditional

    // Verify with server
    const verRes = await fetch('/auth/passkey/login/verify', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(credential)
    });

    if (verRes.ok) window.location.href = '/';
  })();

Key points:

  • autocomplete="username webauthn" enables autofill UI
  • mediation: 'conditional' makes it non-blocking
  • Fails silently if no passkeys exist (no errors shown)
  • Uses ES6 module import from CDN

Account Settings (Management)

File: src/views/account/links.pug

h3 Passkeys
#passkeysList
  if credentials && credentials.length > 0
    each cred in credentials
      .passkey-item(id=`passkey-${cred.id}`)
        .font-bold= cred.device_name || 'Unnamed Device'
        .text-sm Added #{new Date(cred.created_on).toLocaleDateString()}
        if cred.last_used_on
          .text-xs Last used #{new Date(cred.last_used_on).toLocaleDateString()}
        button.destructive(
          hx-delete=`/auth/passkey/${cred.id}`
          hx-target=`#passkey-${cred.id}`
          hx-confirm="Remove this passkey?"
        ) Remove
  else
    .text-gray-500 No passkeys registered yet

button#addPasskeyBtn Add New Passkey

script(type="module").
  import { startRegistration } from 'https://esm.sh/@simplewebauthn/[email protected]';

  const btn = document.getElementById('addPasskeyBtn');
  if (btn && window.PublicKeyCredential) {
    btn.addEventListener('click', async () => {
      const deviceName = prompt('Name this passkey:');
      if (!deviceName) return;

      const optRes = await fetch('/auth/passkey/register/options', { method: 'POST' });
      const opts = await optRes.json();

      const credential = await startRegistration(opts);

      await fetch('/auth/passkey/register/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ credential, deviceName })
      });

      window.location.reload();
    });
  }

HTMX usage:

  • Deletion uses hx-delete (no custom JS needed)
  • Registration requires JS for WebAuthn API (no alternative)

Configuration

Development (src/config.json):

{
  "webauthn": {
    "rpName": "BAS BAP Portal",
    "rpID": "localhost",
    "origin": "http://localhost:4200"
  }
}

Production (/mnt/basny-data/app/config/config.production.json):

{
  "webauthn": {
    "rpName": "BAS BAP Portal",
    "rpID": "bap.basny.org",
    "origin": "https://bap.basny.org"
  }
}

Status: ✅ Production config updated (October 2025)

Important:

  • rpID must match domain (no protocol, no port)
  • Dev and production passkeys are separate (different rpID)
  • Production file must have 600 permissions, owned by UID 1001

Testing

File: src/__tests__/webauthn.test.ts (11 tests)

describe('WebAuthn Database Operations', () => {
  it('should save and retrieve credential', async () => {
    const id = await saveCredential({
      member_id: 1,
      credential_id: 'test-cred-123',
      public_key: Buffer.from('public-key-bytes'),
      counter: 0,
      device_name: 'Test Device'
    });

    const cred = await getCredentialById('test-cred-123');
    expect(cred.device_name).toBe('Test Device');
  });

  it('should delete challenge after single use', async () => {
    await saveChallenge('test-challenge', 'authentication');

    const first = await getChallenge('test-challenge');
    expect(first).toBeDefined();

    const second = await getChallenge('test-challenge');
    expect(second).toBeNull(); // Already deleted
  });
});

Coverage: Full CRUD for credentials and challenges, counter validation, expiration handling.


Core Functions

generateRegistrationOptionsForMember()

// src/auth/webauthn.ts
export async function generateRegistrationOptionsForMember(
  memberId: number,
  userEmail: string,
  userName: string
): Promise<PublicKeyCredentialCreationOptionsJSON> {
  const existingCredentials = await getCredentialsByMember(memberId);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userName: userEmail,
    userDisplayName: userName,
    userID: Buffer.from(String(memberId)),
    attestationType: 'none',
    excludeCredentials: existingCredentials.map(cred => ({
      id: cred.credential_id,
      transports: cred.transports ? JSON.parse(cred.transports) : undefined,
    })),
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'preferred',
      authenticatorAttachment: 'platform',
    },
  });

  await saveChallenge(options.challenge, 'registration', memberId);
  return options;
}

verifyAndSaveCredential()

export async function verifyAndSaveCredential(
  memberId: number,
  response: RegistrationResponseJSON,
  deviceName?: string
): Promise<{ verified: boolean; credentialId?: string }> {
  // Extract and validate challenge
  const clientDataJSON = Buffer.from(response.response.clientDataJSON, 'base64').toString();
  const clientData = JSON.parse(clientDataJSON);
  const challengeData = await getChallenge(clientData.challenge);

  if (!challengeData || challengeData.member_id !== memberId) {
    return { verified: false };
  }

  // Verify attestation
  const verification = await verifyRegistrationResponse({
    response,
    expectedChallenge: challengeData.challenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
  });

  if (!verification.verified || !verification.registrationInfo) {
    return { verified: false };
  }

  // Save credential
  const { credential } = verification.registrationInfo;
  await saveCredential({
    member_id: memberId,
    credential_id: credential.id,
    public_key: Buffer.from(credential.publicKey),
    counter: credential.counter,
    transports: response.response.transports ? JSON.stringify(response.response.transports) : undefined,
    device_name: deviceName,
  });

  return { verified: true, credentialId: credential.id };
}

verifyCredentialAndAuthenticate()

export async function verifyCredentialAndAuthenticate(
  response: AuthenticationResponseJSON
): Promise<{ verified: boolean; memberId?: number }> {
  // Get credential from database
  const credentialIdBase64 = Buffer.from(response.id, 'base64url').toString('base64url');
  const credential = await getCredentialById(credentialIdBase64);
  if (!credential) return { verified: false };

  // Get and validate challenge
  const clientDataJSON = Buffer.from(response.response.clientDataJSON, 'base64').toString();
  const clientData = JSON.parse(clientDataJSON);
  const challengeData = await getChallenge(clientData.challenge);
  if (!challengeData) return { verified: false };

  // Verify signature
  const verification = await verifyAuthenticationResponse({
    response,
    expectedChallenge: challengeData.challenge,
    expectedOrigin: origin,
    expectedRPID: rpID,
    credential: {
      id: credential.credential_id,
      publicKey: credential.public_key,
      counter: credential.counter,
    },
  });

  if (!verification.verified) return { verified: false };

  // Update counter (replay attack prevention)
  await updateCredentialCounter(
    credential.credential_id,
    verification.authenticationInfo.newCounter
  );

  return { verified: true, memberId: credential.member_id };
}

Database Operations

File: src/db/webauthn.ts

// Save new credential
await saveCredential({
  member_id: 123,
  credential_id: 'base64url-id',
  public_key: Buffer.from(publicKeyBytes),
  counter: 0,
  transports: '["internal"]',
  device_name: 'iPhone'
});

// Get all credentials for member
const credentials = await getCredentialsByMember(memberId);

// Get specific credential
const credential = await getCredentialById(credentialId);

// Update counter after authentication
await updateCredentialCounter(credentialId, newCounter);

// Update device name
await updateCredentialDeviceName(id, 'New Name');

// Delete credential
await deleteCredential(id);

// Challenge management
await saveChallenge('challenge', 'registration', memberId);
const challengeData = await getChallenge('challenge');  // Single-use, auto-deletes

Security Features

Counter Validation (Replay Attack Prevention)

const storedCounter = credential.counter;
const newCounter = verification.authenticationInfo.newCounter;

// Counter must increase
if (newCounter <= storedCounter) {
  // Possible replay attack - reject!
  return { verified: false };
}

await updateCredentialCounter(credentialId, newCounter);

Challenge Single-Use

export async function getChallenge(challenge: string) {
  const rows = await query<WebAuthnChallenge>(
    'SELECT * FROM webauthn_challenges WHERE challenge = ? AND expires_on > datetime("now")',
    [challenge]
  );

  const challengeData = rows[0] || null;

  // Delete after retrieval (single-use)
  if (challengeData) {
    await deleteOne('webauthn_challenges', { challenge });
  }

  return challengeData;
}

Rate Limiting

// src/index.ts
router.post("/auth/passkey/login/options", loginRateLimiter, auth.passkeyLoginOptions);
router.post("/auth/passkey/login/verify", loginRateLimiter, auth.passkeyLoginVerify);

// src/middleware/rateLimiter.ts
export const loginRateLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5,
  keyGenerator: (req) => {
    // For passkey endpoints, rate limit by IP only (no email in body)
    if (req.path?.includes('/passkey/')) {
      return getIpKey(req);
    }
    // Password login: rate limit by IP + email
    const email = (req.body as { email?: string })?.email || 'unknown';
    return `${getIpKey(req)}:${email}`;
  },
  skip: (req) => {
    // Skip in test environment or for localhost
    return process.env.NODE_ENV === 'test' || req.ip === '::1' || req.ip === '127.0.0.1';
  }
});

Implementation Files

File Lines Purpose
src/auth/webauthn.ts 195 Core WebAuthn logic
src/db/webauthn.ts 163 Database layer
src/routes/auth.ts +153 Route handlers
src/__tests__/webauthn.test.ts 216 Tests
db/migrations/031-add-webauthn-support.sql 47 Schema
src/views/account/signin.pug +43 Conditional UI
src/views/account/links.pug +96 Management UI

Dependencies:


Related Documentation


**Last Updated: November 2025