~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
85 lines · 2.5 KB Raw History
// @pdsjs/core/passkey - The passkey ceremony: a challenge issued under a
// short-lived cookie, and the cookie read back to prove a response answers
// it. Shared by the account passkey pages, the account sign-in, and the OAuth
// consent screen's passkey approval. The WebAuthn verification math is in
// webauthn.js.

import { createChallengeJwt, verifyChallengeJwt } from './auth.js';
import { readCookie } from './http.js';
import { generateChallenge } from './webauthn.js';

// Cookie holding the challenge for an in-flight passkey ceremony
export const PASSKEY_COOKIE = 'pdsjs_webauthn';

/**
 * @param {import('./ports.js').SharedStoragePort} sharedStorage
 * @param {string} jwtSecret
 */
export function createPasskeyCeremony(sharedStorage, jwtSecret) {
  /**
   * Whether this deployment's storage can hold and look up a passkey.
   * @returns {boolean}
   */
  function passkeysAvailable() {
    return Boolean(sharedStorage.getPasskey);
  }

  /**
   * The relying party a passkey is bound to: this server's hostname, without
   * a port. A credential registered here works only here.
   * @param {URL} url
   * @returns {string}
   */
  function passkeyRpId(url) {
    return url.hostname;
  }

  /**
   * Start a ceremony: a fresh challenge, plus the cookie that will prove the
   * response answers it.
   * @param {string} purpose - 'register' or 'sign-in'
   * @param {URL} url
   * @param {string} [cookiePath]
   * @returns {Promise<{challenge: string, cookie: string}>}
   */
  async function startPasskeyCeremony(purpose, url, cookiePath = '/account') {
    const challenge = generateChallenge();
    const token = await createChallengeJwt(challenge, purpose, jwtSecret);
    return {
      challenge,
      cookie: [
        `${PASSKEY_COOKIE}=${encodeURIComponent(token)}`,
        `Path=${cookiePath}`,
        'HttpOnly',
        'SameSite=Lax',
        'Max-Age=300',
        url.protocol === 'https:' ? 'Secure' : null,
      ]
        .filter(Boolean)
        .join('; '),
    };
  }

  /**
   * The challenge this request's cookie was issued for.
   * @param {Request} request
   * @param {string} purpose
   * @returns {Promise<string|null>}
   */
  async function readPasskeyChallenge(request, purpose) {
    const token = readCookie(request, PASSKEY_COOKIE);
    if (!token) return null;
    try {
      return await verifyChallengeJwt(token, purpose, jwtSecret);
    } catch {
      return null;
    }
  }

  return {
    passkeysAvailable,
    passkeyRpId,
    startPasskeyCeremony,
    readPasskeyChallenge,
  };
}