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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
501 lines · 15.3 KB Raw History
/**
 * Authorization-code OAuth for an atproto browser client.
 *
 * The client is public: PKCE protects the code exchange, and DPoP binds the
 * access and refresh tokens to a non-extractable browser key.
 */

import { createDpopClient } from './dpop.js';

const REFRESH_MARGIN_MS = 30_000;

/**
 * @typedef {Object} OAuthSession
 * @property {string} did
 * @property {string} handle
 * @property {string} pds
 * @property {string} [clientId]
 * @property {string} issuer
 * @property {string} tokenEndpoint
 * @property {string|null} revocationEndpoint
 * @property {string} accessToken
 * @property {string} refreshToken
 * @property {number} expiresAt
 * @property {string} scope
 */

/**
 * @typedef {Object} OAuthClientOptions
 * @property {(input: string) => Promise<{did: string, handle: string, pds: string}>} resolveIdentity
 * @property {string} scope
 * @property {string} storagePrefix
 * @property {import('./dpop.js').DpopClient} [dpop]
 * @property {boolean} [requireProtectedResourceMetadata]
 */

/**
 * @typedef {Object} OAuthClient
 * @property {(scope?: string) => string} clientId
 * @property {() => string} redirectUri
 * @property {() => OAuthSession|null} currentSession
 * @property {(input: string, options?: {scope?: string, returnTo?: string}) => Promise<never>} beginSignIn
 * @property {(params: URLSearchParams) => Promise<{session: OAuthSession, returnTo: string}>} completeSignIn
 * @property {(session: OAuthSession|null) => Promise<void>} signOut
 * @property {(callback: () => void) => void} onSessionEnded
 * @property {(session: OAuthSession, returnTo: string) => void} renewGrant
 * @property {() => void} clearRenewal
 * @property {(session: OAuthSession, url: string, init?: RequestInit) => Promise<Response>} authedFetch
 * @property {import('./dpop.js').DpopClient} dpop
 */

/** An authorization-server refusal, with the OAuth error code it named. */
export class OAuthError extends Error {
  /**
   * @param {string} message
   * @param {string} [code]
   */
  constructor(message, code = 'oauth_error') {
    super(message);
    this.code = code;
  }
}

/**
 * @param {unknown} value
 * @returns {string|undefined}
 */
function stringValue(value) {
  return typeof value === 'string' ? value : undefined;
}

/**
 * @param {Response} response
 * @returns {Promise<Record<string, unknown>>}
 */
async function jsonObject(response) {
  const value = await response.json().catch(() => ({}));
  return value && typeof value === 'object' && !Array.isArray(value)
    ? /** @type {Record<string, unknown>} */ (value)
    : {};
}

/**
 * @param {OAuthClientOptions} options
 * @returns {OAuthClient}
 */
export function createOAuthClient({
  resolveIdentity,
  scope: defaultScope,
  storagePrefix,
  dpop = createDpopClient({ databaseName: `${storagePrefix}-oauth` }),
  requireProtectedResourceMetadata = false,
}) {
  const SESSION_KEY = `${storagePrefix}:session`;
  const PENDING_KEY = `${storagePrefix}:pending`;
  const RENEW_KEY = `${storagePrefix}:renewed`;

  /** @typedef {{state: string, verifier: string, clientId: string, scope: string, did: string, handle: string, pds: string, issuer: string, tokenEndpoint: string, authorizeEndpoint: string, revocationEndpoint: string|null, returnTo?: string}} PendingSignIn */

  /**
   * @param {string} scope
   * @returns {string}
   */
  function clientId(scope = defaultScope) {
    const { origin, hostname } = window.location;
    if (hostname === 'localhost' || hostname === '127.0.0.1') {
      const params = new URLSearchParams();
      params.set('redirect_uri', redirectUri());
      params.set('scope', scope);
      return `http://localhost?${params}`;
    }
    return `${origin}/client-metadata.json`;
  }

  function redirectUri() {
    return `${window.location.origin}/callback`;
  }

  /**
   * @returns {OAuthSession|null}
   */
  function currentSession() {
    const raw = localStorage.getItem(SESSION_KEY);
    if (!raw) return null;
    try {
      return JSON.parse(raw);
    } catch {
      return null;
    }
  }

  /** @param {OAuthSession} session */
  function storeSession(session) {
    localStorage.setItem(SESSION_KEY, JSON.stringify(session));
  }

  function randomToken() {
    const bytes = crypto.getRandomValues(new Uint8Array(32));
    return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
      '',
    );
  }

  /**
   * @param {string} url
   * @param {Record<string, string>} form
   * @returns {Promise<Record<string, unknown>>}
   */
  async function postWithDpop(url, form) {
    for (let attempt = 0; attempt < 2; attempt += 1) {
      const proof = await dpop.dpopProof({
        keyPair: await dpop.dpopKeyPair(),
        method: 'POST',
        url,
        nonce: dpop.nonceFor(url),
      });
      const response = await fetch(url, {
        method: 'POST',
        headers: {
          'content-type': 'application/x-www-form-urlencoded',
          dpop: proof,
        },
        body: new URLSearchParams(form),
      });
      dpop.rememberNonce(url, response);

      if (response.ok) return jsonObject(response);

      const body = await jsonObject(response);
      const code = stringValue(body.error);
      const freshNonce = response.headers.get('dpop-nonce');
      if (code === 'use_dpop_nonce' && freshNonce && attempt === 0) continue;
      throw new OAuthError(
        stringValue(body.error_description) ||
          code ||
          `${url} ${response.status}`,
        code,
      );
    }
    throw new OAuthError('The server kept asking for a new DPoP nonce.');
  }

  /**
   * @param {string} pds
   * @returns {Promise<{issuer: string, parEndpoint: string, authorizeEndpoint: string, tokenEndpoint: string, revocationEndpoint: string|null}>}
   */
  async function discoverAuthServer(pds) {
    const base = pds.replace(/\/+$/, '');
    let issuer = base;
    let resource;
    try {
      resource = await fetch(`${base}/.well-known/oauth-protected-resource`);
    } catch {
      if (requireProtectedResourceMetadata) {
        throw new OAuthError('That PDS does not publish OAuth metadata.');
      }
      resource = null;
    }
    if (resource?.ok) {
      const body = await jsonObject(resource);
      const namedIssuer = Array.isArray(body.authorization_servers)
        ? stringValue(body.authorization_servers[0])
        : undefined;
      if (namedIssuer) issuer = namedIssuer.replace(/\/+$/, '');
      else if (requireProtectedResourceMetadata) {
        throw new OAuthError('That PDS names no authorization server.');
      }
    } else if (requireProtectedResourceMetadata) {
      throw new OAuthError('That PDS does not publish OAuth metadata.');
    }

    const response = await fetch(
      `${issuer}/.well-known/oauth-authorization-server`,
    );
    if (!response.ok) {
      throw new OAuthError(
        'The authorization server publishes no metadata.',
        'invalid_server_metadata',
      );
    }
    const doc = await jsonObject(response);
    const metadataIssuer = stringValue(doc.issuer);
    const parEndpoint = stringValue(doc.pushed_authorization_request_endpoint);
    const authorizeEndpoint = stringValue(doc.authorization_endpoint);
    const tokenEndpoint = stringValue(doc.token_endpoint);
    if (metadataIssuer && metadataIssuer.replace(/\/+$/, '') !== issuer) {
      throw new OAuthError(
        'The authorization server metadata names a different issuer.',
        'invalid_server_metadata',
      );
    }
    if (!parEndpoint || !authorizeEndpoint || !tokenEndpoint) {
      throw new OAuthError(
        'The authorization server does not publish the required endpoints.',
        'invalid_server_metadata',
      );
    }
    return {
      issuer,
      parEndpoint,
      authorizeEndpoint,
      tokenEndpoint,
      revocationEndpoint: stringValue(doc.revocation_endpoint) || null,
    };
  }

  /**
   * @param {string} input
   * @param {{scope?: string, returnTo?: string}} [options]
   * @returns {Promise<never>}
   */
  async function beginSignIn(input, { scope = defaultScope, returnTo } = {}) {
    const identity = await resolveIdentity(input);
    const server = await discoverAuthServer(identity.pds);
    const verifier = randomToken();
    const state = randomToken();
    const challenge = await dpop.sha256Base64Url(verifier);
    const id = clientId(scope);

    const par = await postWithDpop(server.parEndpoint, {
      client_id: id,
      response_type: 'code',
      redirect_uri: redirectUri(),
      scope,
      state,
      code_challenge: challenge,
      code_challenge_method: 'S256',
      login_hint: identity.handle,
    });
    const requestUri = stringValue(par.request_uri);
    if (!requestUri)
      throw new OAuthError('The authorization server returned no request URI.');

    sessionStorage.setItem(
      PENDING_KEY,
      JSON.stringify({
        state,
        verifier,
        clientId: id,
        scope,
        did: identity.did,
        handle: identity.handle,
        pds: identity.pds,
        ...server,
        ...(returnTo ? { returnTo } : {}),
      }),
    );

    const authorize = new URL(server.authorizeEndpoint);
    authorize.searchParams.set('client_id', id);
    authorize.searchParams.set('request_uri', requestUri);
    window.location.assign(authorize.toString());
    return await new Promise(() => {});
  }

  /**
   * @param {URLSearchParams} params
   * @returns {Promise<{session: OAuthSession, returnTo: string}>}
   */
  async function completeSignIn(params) {
    const error = params.get('error');
    if (error)
      throw new OAuthError(params.get('error_description') || error, error);
    const code = params.get('code');
    const state = params.get('state');
    if (!code || !state) throw new OAuthError('That callback carried no code.');

    const raw = sessionStorage.getItem(PENDING_KEY);
    if (!raw) throw new OAuthError('No sign-in was in progress in this tab.');
    /** @type {PendingSignIn} */
    const pending = JSON.parse(raw);
    sessionStorage.removeItem(PENDING_KEY);
    if (pending.state !== state) {
      throw new OAuthError('That callback answers a different sign-in.');
    }
    const issuer = params.get('iss');
    if (issuer && issuer.replace(/\/+$/, '') !== pending.issuer) {
      throw new OAuthError(
        'That callback came from a different authorization server.',
      );
    }

    const id = pending.clientId || clientId(pending.scope);
    const token = await postWithDpop(pending.tokenEndpoint, {
      client_id: id,
      grant_type: 'authorization_code',
      code,
      redirect_uri: redirectUri(),
      code_verifier: pending.verifier,
    });
    if (stringValue(token.sub) !== pending.did) {
      throw new OAuthError(
        'The server issued a token for a different account.',
      );
    }
    const accessToken = stringValue(token.access_token);
    const refreshToken = stringValue(token.refresh_token);
    if (!accessToken || !refreshToken) {
      throw new OAuthError('The token response did not contain a token pair.');
    }
    const session = {
      did: pending.did,
      handle: pending.handle,
      pds: pending.pds,
      clientId: id,
      issuer: pending.issuer,
      tokenEndpoint: pending.tokenEndpoint,
      revocationEndpoint: pending.revocationEndpoint,
      accessToken,
      refreshToken,
      expiresAt: Date.now() + (Number(token.expires_in) || 3600) * 1000,
      scope: stringValue(token.scope) || pending.scope,
    };
    storeSession(session);
    return { session, returnTo: pending.returnTo || '/' };
  }

  /** @type {Promise<OAuthSession>|null} */
  let refreshing = null;
  /** @type {(() => void)|null} */
  let sessionEndedCallback = null;

  function sessionExpired() {
    localStorage.removeItem(SESSION_KEY);
    sessionEndedCallback?.();
    return new OAuthError(
      'That session is no longer valid. Sign in again.',
      'session_expired',
    );
  }

  /** @param {OAuthSession} session */
  async function fresh(session) {
    if (session.expiresAt - REFRESH_MARGIN_MS > Date.now()) return session;
    refreshing ??= (async () => {
      try {
        const token = await postWithDpop(session.tokenEndpoint, {
          client_id: session.clientId || clientId(session.scope),
          grant_type: 'refresh_token',
          refresh_token: session.refreshToken,
        }).catch((error) => {
          if (error instanceof OAuthError && error.code === 'invalid_grant') {
            throw sessionExpired();
          }
          throw error;
        });
        const accessToken = stringValue(token.access_token);
        if (!accessToken)
          throw new OAuthError(
            'The refresh response contained no access token.',
          );
        const next = {
          ...session,
          accessToken,
          refreshToken:
            stringValue(token.refresh_token) || session.refreshToken,
          expiresAt: Date.now() + (Number(token.expires_in) || 3600) * 1000,
          scope: stringValue(token.scope) || session.scope,
        };
        storeSession(next);
        return next;
      } finally {
        refreshing = null;
      }
    })();
    return refreshing;
  }

  /** @param {OAuthSession|null} session */
  async function signOut(session) {
    if (session?.revocationEndpoint) {
      try {
        await fetch(session.revocationEndpoint, {
          method: 'POST',
          headers: { 'content-type': 'application/x-www-form-urlencoded' },
          body: new URLSearchParams({
            token: session.refreshToken,
            client_id: session.clientId || clientId(session.scope),
          }),
        });
      } catch {
        // Local sign-out does not depend on the server hearing the revocation.
      }
    }
    localStorage.removeItem(SESSION_KEY);
    await dpop.forgetDpopKey();
  }

  /** @param {() => void} callback */
  function onSessionEnded(callback) {
    sessionEndedCallback = callback;
  }

  /**
   * @param {OAuthSession} session
   * @param {string} returnTo
   */
  function renewGrant(session, returnTo) {
    if (sessionStorage.getItem(RENEW_KEY)) return;
    sessionStorage.setItem(RENEW_KEY, '1');
    beginSignIn(session.did, { returnTo }).catch(() => {
      sessionStorage.removeItem(RENEW_KEY);
    });
  }

  function clearRenewal() {
    sessionStorage.removeItem(RENEW_KEY);
  }

  /**
   * @param {OAuthSession} session
   * @param {string} url
   * @param {RequestInit} [init]
   * @returns {Promise<Response>}
   */
  async function authedFetch(session, url, init = {}) {
    let active = await fresh(session);
    let refreshed = false;
    for (let attempt = 0; attempt < 3; attempt += 1) {
      const proof = await dpop.dpopProof({
        keyPair: await dpop.dpopKeyPair(),
        method: init.method || 'GET',
        url,
        nonce: dpop.nonceFor(url),
        token: active.accessToken,
      });
      const response = await fetch(url, {
        ...init,
        headers: {
          ...(init.headers || {}),
          authorization: `DPoP ${active.accessToken}`,
          dpop: proof,
        },
      });
      dpop.rememberNonce(url, response);
      if (response.status !== 401) return response;
      const challenge = response.headers.get('www-authenticate') || '';
      if (challenge.includes('use_dpop_nonce')) continue;
      if (!refreshed) {
        refreshed = true;
        active = await fresh({ ...active, expiresAt: 0 });
        continue;
      }
      throw sessionExpired();
    }
    throw sessionExpired();
  }

  return {
    clientId,
    redirectUri,
    currentSession,
    beginSignIn,
    completeSignIn,
    signOut,
    onSessionEnded,
    renewGrant,
    clearRenewal,
    authedFetch,
    dpop,
  };
}