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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
277 lines · 8.8 KB Raw History
/**
 * The forge owner's login: an atproto OAuth client the appview runs on
 * itself.
 *
 * The flow is the standard authorization-code grant with PKCE and
 * DPoP-bound tokens, the same one the git helper runs as a loopback client.
 * Here the client is this origin: the metadata document and the callback
 * are served beside the forge, the session rides a cookie, and the access
 * token is what the request's own writes are made with. A session whose
 * DID is not the hosted account's buys nothing, because every owner-only
 * procedure checks the DID.
 */

import { randomBytes } from 'node:crypto';
import { readCookie } from '@pdsjs/core/http';
import { generateDpopKey } from '@pdsjs/git/dpop';
import { resolveRepoLocation } from '@pdsjs/git/identity';
import { discoverAuthServer, dpopPost } from '@pdsjs/git/oauth';

/** The cookie the session rides. */
export const SESSION_COOKIE = 'git-host-session';

/** A pending authorization dies if the user never comes back. */
const PENDING_TTL_MS = 10 * 60 * 1000;

/** A session lives this long past its last refresh. */
const SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000;

/**
 * @param {Uint8Array} bytes
 * @returns {string}
 */
function base64Url(bytes) {
  return Buffer.from(bytes)
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

const randomToken = () => base64Url(randomBytes(24));

/**
 * @param {Object} options
 * @param {string} options.origin - this forge's origin, which names the
 *   client_id and the redirect
 * @param {string} options.accountDid - the hosted account; any other
 *   login's session grants nothing
 * @param {string} [options.service] - the hosted account's PDS, which
 *   short-circuits identity resolution for a login named by DID
 * @param {string} [options.plcUrl] - the directory handles resolve against
 * @param {string} [options.scope] - the grant to ask for (default atproto,
 *   which covers the record and blob writes the forge makes)
 * @param {typeof fetch} [options.fetch] - what carries the OAuth requests;
 *   the global by default
 * @param {(message: string) => void} [options.onNotice]
 */
export function createForgeAuth(options) {
  const origin = options.origin.replace(/\/+$/, '');
  const clientId = `${origin}/oauth/client-metadata.json`;
  const redirectUri = `${origin}/oauth/callback`;
  const onNotice = options.onNotice ?? (() => {});

  /** Pending authorizations by state. @type {Map<string, {verifier: string, key: Awaited<ReturnType<typeof generateDpopKey>>, service: string, tokenEndpoint: string, at: number}>} */
  const pending = new Map();

  /** Sessions by sid. @type {Map<string, {did: string, handle: string|null, session: Record<string, unknown>, key: Awaited<ReturnType<typeof generateDpopKey>>, tokenEndpoint: string, clientId: string, scope: string, service: string, expiresAt: number}>} */
  const sessions = new Map();

  /** @param {string} handle */
  async function beginLogin(handle) {
    const { did, service } = await resolveRepoLocation(handle, {
      ATPROTO_GIT_PLC_URL: options.plcUrl,
      ...(options.service ? { ATPROTO_GIT_SERVICE: options.service } : {}),
    });
    const authServer = await discoverAuthServer(service, {
      fetch: options.fetch,
    });
    const key = await generateDpopKey({ extractable: true });
    const verifier = base64Url(randomBytes(32));
    const challenge = base64Url(
      new Uint8Array(
        await crypto.subtle.digest(
          'SHA-256',
          new TextEncoder().encode(verifier),
        ),
      ),
    );
    const state = base64Url(randomBytes(16));

    const par = await dpopPost(
      authServer.parEndpoint,
      key,
      {
        client_id: clientId,
        redirect_uri: redirectUri,
        response_type: 'code',
        scope: options.scope ?? 'atproto',
        state,
        code_challenge: challenge,
        code_challenge_method: 'S256',
        login_hint: handle,
      },
      { fetch: options.fetch },
    );

    pending.set(state, {
      verifier,
      key,
      service,
      tokenEndpoint: authServer.tokenEndpoint,
      at: Date.now(),
    });
    for (const [key_, entry] of pending) {
      if (Date.now() - entry.at > PENDING_TTL_MS) pending.delete(key_);
    }

    const authorizeUrl = new URL(authServer.authorizationEndpoint);
    authorizeUrl.searchParams.set('request_uri', par.request_uri);
    authorizeUrl.searchParams.set('client_id', clientId);
    return { did, authorizeUrl: authorizeUrl.toString() };
  }

  /**
   * @param {URL} url - the callback request's URL
   * @returns {Promise<{sid: string, did: string, cookie: string}>}
   */
  async function completeLogin(url) {
    const error = url.searchParams.get('error');
    if (error) {
      throw new Error(`authorization denied: ${error}`);
    }
    const code = url.searchParams.get('code');
    const state = url.searchParams.get('state') ?? '';
    const entry = pending.get(state);
    if (!code || !entry) {
      throw new Error('the redirect carried no code or an unknown state');
    }
    pending.delete(state);

    const token = await dpopPost(
      entry.tokenEndpoint,
      entry.key,
      {
        grant_type: 'authorization_code',
        code,
        redirect_uri: redirectUri,
        client_id: clientId,
        code_verifier: entry.verifier,
      },
      { fetch: options.fetch },
    );
    const sid = randomToken();
    sessions.set(sid, {
      did: String(token.sub),
      handle: null,
      session: token,
      key: entry.key,
      tokenEndpoint: entry.tokenEndpoint,
      clientId,
      scope: String(token.scope ?? ''),
      service: entry.service,
      expiresAt: Date.now() + SESSION_TTL_MS,
    });
    onNotice(`login: ${token.sub}`);
    return {
      sid,
      did: String(token.sub),
      cookie: sessionCookie(sid),
    };
  }

  /**
   * Exchange the refresh token for a fresh pair, rotating the stored one.
   * @param {string} sid
   * @returns {Promise<boolean>} false when the session is beyond saving
   */
  async function refresh(sid) {
    const stored = sessions.get(sid);
    if (!stored) return false;
    try {
      const token = await dpopPost(
        stored.tokenEndpoint,
        stored.key,
        {
          grant_type: 'refresh_token',
          refresh_token: String(stored.session.refresh_token),
          client_id: stored.clientId,
        },
        { fetch: options.fetch },
      );
      stored.session = token;
      stored.scope = String(token.scope ?? '');
      stored.expiresAt = Date.now() + SESSION_TTL_MS;
      return true;
    } catch (err) {
      onNotice(
        `session refresh failed: ${err instanceof Error ? err.message : err}`,
      );
      sessions.delete(sid);
      return false;
    }
  }

  /**
   * The DID the request's session names, or null. A session whose DID is
   * not the hosted account's reads as no session: every owner check
   * compares against the hosted account, so the forge never has to.
   * @param {Request} request
   * @returns {Promise<string|null>}
   */
  async function sessionDid(request) {
    const sid = readCookie(request, SESSION_COOKIE);
    if (!sid) return null;
    const stored = sessions.get(sid);
    if (!stored) return null;
    if (stored.expiresAt < Date.now()) {
      sessions.delete(sid);
      return null;
    }
    if (stored.did !== options.accountDid) return null;
    return stored.did;
  }

  /**
   * The access token behind the request's session, for the writes the
   * request causes. Refreshes when the server says the token is stale.
   * @param {Request} request
   * @returns {Promise<string|null>}
   */
  async function tokenFor(request) {
    const sid = readCookie(request, SESSION_COOKIE);
    if (!sid) return null;
    const stored = sessions.get(sid);
    if (!stored) return null;
    const token = stored.session;
    return typeof token.access_token === 'string' ? token.access_token : null;
  }

  /**
   * @param {string} sid
   * @returns {string}
   */
  function sessionCookie(sid) {
    return `${SESSION_COOKIE}=${sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL_MS / 1000}`;
  }

  return {
    beginLogin,
    completeLogin,
    sessionDid,
    tokenFor,
    refresh,
    logout(/** @type {Request} */ request) {
      const sid = readCookie(request, SESSION_COOKIE);
      if (sid) sessions.delete(sid);
      return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
    },
    /** The metadata document the client_id names. */
    metadataDocument() {
      return {
        client_id: clientId,
        client_name: 'git-host',
        client_uri: origin,
        redirect_uris: [redirectUri],
        scope: options.scope ?? 'atproto',
        grant_types: ['authorization_code', 'refresh_token'],
        response_types: ['code'],
        token_endpoint_auth_method: 'none',
        application_type: 'web',
        dpop_bound_access_tokens: true,
      };
    },
  };
}

/** @typedef {ReturnType<typeof createForgeAuth>} ForgeAuth */