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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
345 lines · 11.0 KB Raw History
/**
 * OAuth client for the git remote helper: the atproto authorization-code flow
 * with PKCE and DPoP-bound tokens, as a loopback client.
 *
 * The helper is a CLI, so it authorizes as a loopback client: the client_id
 * and redirect_uri are the origin of a one-shot local HTTP server, and the
 * authorization server generates the client metadata rather than fetching it.
 * The DPoP key is generated extractable and travels with the token pair in
 * the session file; the token endpoint refuses a refresh from any other key.
 */

import { createServer } from 'node:http';
import { createDpopProof, exportDpopKey, generateDpopKey } from './dpop.js';

const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000;

/**
 * The stored form of an OAuth session. Everything a later invocation needs to
 * present and refresh the tokens, including the exact client_id the tokens
 * were issued to, which the token endpoint checks on refresh.
 *
 * @typedef {Object} OauthSession
 * @property {string} did
 * @property {string} [identifier] - the handle or DID given at login, which
 *   names the session file
 * @property {string} service - PDS base URL
 * @property {string} tokenEndpoint
 * @property {string} clientId
 * @property {string} scope
 * @property {string} accessToken
 * @property {string} refreshToken
 * @property {number} expiresAt - access token expiry, ms since epoch
 * @property {JsonWebKey} privateJwk - the DPoP key the tokens are bound to
 * @property {typeof fetch} [fetch] - what carries the token refresh; the
 *   global by default
 */

/**
 * @param {Uint8Array} bytes
 * @returns {string}
 */
function base64UrlEncode(bytes) {
  let binary = '';
  for (const b of bytes) binary += String.fromCharCode(b);
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

export class OauthError extends Error {
  /**
   * @param {string} message
   * @param {string} [code]
   */
  constructor(message, code = 'oauth_error') {
    super(message);
    this.code = code;
  }
}

/**
 * Find the authorization server for a PDS: the protected-resource document
 * names it, and on a server that is its own authorization server (pds.js) the
 * fallback resolves the same metadata directly.
 *
 * @param {string} service - PDS base URL
 * @param {Object} [options]
 * @param {typeof fetch} [options.fetch] - what carries the requests; the
 *   global by default
 * @returns {Promise<{issuer: string, parEndpoint: string, authorizationEndpoint: string, tokenEndpoint: string}>}
 */
export async function discoverAuthServer(service, { fetch: carrier } = {}) {
  const send = carrier ?? ((input, init) => globalThis.fetch(input, init));
  const base = service.replace(/\/+$/, '');
  let issuer = base;
  try {
    const res = await send(`${base}/.well-known/oauth-protected-resource`);
    if (res.ok) {
      const body = /** @type {{authorization_servers?: string[]}} */ (
        await res.json()
      );
      if (body.authorization_servers?.[0]) {
        issuer = body.authorization_servers[0].replace(/\/+$/, '');
      }
    }
  } catch {
    // The PDS answers as its own authorization server.
  }
  const res = await send(`${issuer}/.well-known/oauth-authorization-server`);
  if (!res.ok) {
    throw new OauthError(
      `${issuer} publishes no authorization server metadata`,
    );
  }
  const metadata =
    /** @type {{issuer: string, pushed_authorization_request_endpoint: string, authorization_endpoint: string, token_endpoint: string}} */ (
      await res.json()
    );
  return {
    issuer: metadata.issuer,
    parEndpoint: metadata.pushed_authorization_request_endpoint,
    authorizationEndpoint: metadata.authorization_endpoint,
    tokenEndpoint: metadata.token_endpoint,
  };
}

/**
 * POST to an authorization-server endpoint with a DPoP proof, replaying once
 * when the server answers use_dpop_nonce with the nonce to use (RFC 9449 §8).
 * Exported for other clients of the same flow, which build their own token
 * requests over it.
 *
 * @param {string} url
 * @param {import('./dpop.js').DpopKey} key
 * @param {Record<string, string>} params - form body
 * @param {Object} [options]
 * @param {typeof fetch} [options.fetch] - what carries the request; the
 *   global by default
 * @returns {Promise<any>} the JSON response body
 */
export async function dpopPost(url, key, params, { fetch: carrier } = {}) {
  const send = carrier ?? ((input, init) => globalThis.fetch(input, init));
  /** @type {string|undefined} */
  let nonce;
  for (let attempt = 0; attempt < 2; attempt++) {
    const res = await send(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        DPoP: await createDpopProof({ key, method: 'POST', url, nonce }),
      },
      body: new URLSearchParams(params).toString(),
    });
    const body = await res.json();
    if (res.ok) return body;
    const freshNonce = res.headers.get('DPoP-Nonce');
    if (body?.error === 'use_dpop_nonce' && freshNonce && attempt === 0) {
      nonce = freshNonce;
      continue;
    }
    throw new OauthError(
      body?.error_description ??
        body?.message ??
        `${url} failed: ${res.status}`,
      body?.error ?? 'oauth_error',
    );
  }
  throw new OauthError('unreachable');
}

/**
 * Serve the redirect the browser lands on after consent, once.
 * @param {import('node:http').Server} server
 * @param {string} expectedState
 * @returns {Promise<string>} the authorization code
 */
function waitForCallback(server, expectedState) {
  return new Promise((resolve, reject) => {
    const timer = setTimeout(() => {
      reject(new OauthError('timed out waiting for the browser redirect'));
    }, CALLBACK_TIMEOUT_MS);
    server.on('request', (req, res) => {
      const url = new URL(req.url ?? '/', 'http://127.0.0.1');
      // Only the redirect target settles the wait; a stray request (a favicon
      // fetch, a probe) must not consume the one-shot server.
      if (url.pathname !== '/') {
        res.writeHead(404).end();
        return;
      }
      const code = url.searchParams.get('code');
      const state = url.searchParams.get('state');
      const error = url.searchParams.get('error');
      const done = (/** @type {string} */ text) => {
        res.writeHead(200, {
          connection: 'close',
          'content-type': 'text/html; charset=utf-8',
        });
        res.end(
          `<!doctype html><meta charset="utf-8"><title>git-remote-atproto</title><p>${text}</p>`,
        );
        clearTimeout(timer);
      };
      if (error) {
        done('Authorization was denied. You can close this tab.');
        reject(new OauthError(`authorization denied: ${error}`, error));
        return;
      }
      if (!code || state !== expectedState) {
        done('Unexpected redirect. You can close this tab.');
        reject(new OauthError('redirect carried no code or a wrong state'));
        return;
      }
      done('Logged in. You can close this tab and return to your terminal.');
      resolve(code);
    });
  });
}

/**
 * @param {import('node:http').Server} server
 * @returns {Promise<void>}
 */
function closeServer(server) {
  return new Promise((resolve) => {
    if (!server.listening) {
      resolve();
      return;
    }
    server.closeIdleConnections();
    server.close(() => resolve());
  });
}

/**
 * Run the authorization-code flow against a PDS and return a session.
 *
 * @param {Object} opts
 * @param {string} opts.service - PDS base URL
 * @param {string} opts.scope - space-separated scopes to request
 * @param {string} [opts.identifier] - login hint for the consent page
 * @param {(url: string) => void|Promise<void>} opts.openBrowser - taken as a
 *   parameter so tests drive the consent form over fetch instead
 * @param {(text: string) => void} [opts.log]
 * @param {typeof fetch} [opts.fetch]
 * @returns {Promise<OauthSession>}
 */
export async function oauthLogin({
  service,
  scope,
  identifier,
  openBrowser,
  log = () => {},
  fetch: carrier,
}) {
  const authServer = await discoverAuthServer(service, { fetch: carrier });
  const key = await generateDpopKey({ extractable: true });

  const server = createServer();
  await new Promise((resolve) =>
    server.listen(0, '127.0.0.1', () => resolve(undefined)),
  );
  const address = /** @type {import('node:net').AddressInfo} */ (
    server.address()
  );
  // The redirect target doubles as the client_id: a loopback client's
  // metadata is generated by the server, and its redirect check is by origin.
  const clientId = `http://127.0.0.1:${address.port}/`;

  try {
    const verifier = base64UrlEncode(
      crypto.getRandomValues(new Uint8Array(32)),
    );
    const challenge = base64UrlEncode(
      new Uint8Array(
        await crypto.subtle.digest(
          'SHA-256',
          new TextEncoder().encode(verifier),
        ),
      ),
    );
    const state = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)));

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

    const authorizeUrl = new URL(authServer.authorizationEndpoint);
    authorizeUrl.searchParams.set('request_uri', par.request_uri);
    authorizeUrl.searchParams.set('client_id', clientId);

    const callback = waitForCallback(server, state);
    log(`Opening ${authorizeUrl}\n`);
    await openBrowser(authorizeUrl.toString());
    const code = await callback;

    const token = await dpopPost(
      authServer.tokenEndpoint,
      key,
      {
        grant_type: 'authorization_code',
        code,
        redirect_uri: clientId,
        client_id: clientId,
        code_verifier: verifier,
      },
      { fetch: carrier },
    );

    return {
      did: token.sub,
      service: service.replace(/\/+$/, ''),
      tokenEndpoint: authServer.tokenEndpoint,
      clientId,
      scope: token.scope,
      accessToken: token.access_token,
      refreshToken: token.refresh_token,
      expiresAt: Date.now() + token.expires_in * 1000,
      privateJwk: await exportDpopKey(key),
      ...(carrier ? { fetch: carrier } : {}),
    };
  } finally {
    await closeServer(server);
  }
}

/**
 * Exchange the session's refresh token for a fresh pair. The store rotates
 * refresh tokens, so the returned session replaces the old one entirely.
 *
 * @param {OauthSession} session
 * @param {import('./dpop.js').DpopKey} key - the session's imported DPoP key
 * @returns {Promise<OauthSession>}
 */
export async function refreshOauthSession(session, key) {
  const token = await dpopPost(
    session.tokenEndpoint,
    key,
    {
      grant_type: 'refresh_token',
      refresh_token: session.refreshToken,
      client_id: session.clientId,
    },
    { fetch: session.fetch },
  );
  return {
    ...session,
    scope: token.scope,
    accessToken: token.access_token,
    refreshToken: token.refresh_token,
    expiresAt: Date.now() + token.expires_in * 1000,
  };
}