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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
165 lines · 4.9 KB Raw History
/**
 * OAuth flow helpers for e2e tests
 */

import { randomBytes } from 'node:crypto';
import { DpopClient } from './dpop.js';

let BASE = 'http://localhost:8787';

/**
 * Set the base URL for OAuth requests
 * @param {string} url
 */
export function setBaseUrl(url) {
  BASE = url;
}

/**
 * Fetch with retry for flaky wrangler dev
 * @param {string} url
 * @param {RequestInit} options
 * @param {number} maxAttempts
 * @returns {Promise<Response>}
 */
async function fetchWithRetry(url, options, maxAttempts = 3) {
  let lastError;
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
    try {
      const res = await fetch(url, options);
      // Check if we got an HTML error page instead of expected response
      const contentType = res.headers.get('content-type') || '';
      if (!res.ok && contentType.includes('text/html')) {
        // Wrangler dev error page - retry
        if (attempt < maxAttempts - 1) {
          await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
          continue;
        }
      }
      return res;
    } catch (err) {
      lastError = err;
      if (attempt < maxAttempts - 1) {
        await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
      }
    }
  }
  throw lastError || new Error('Fetch failed after retries');
}

/**
 * Get an OAuth token with a specific scope via full PAR -> authorize -> token flow
 * @param {string} scope - The scope to request
 * @param {string} did - The DID to authenticate as
 * @param {string} password - The password for authentication
 * @returns {Promise<{accessToken: string, refreshToken: string, dpop: DpopClient}>}
 */
export async function getOAuthTokenWithScope(scope, did, password) {
  const dpop = await DpopClient.create();
  const clientId = 'http://localhost:3000';
  const redirectUri = 'http://localhost:3000/callback';
  const codeVerifier = randomBytes(32).toString('base64url');
  const challengeBuffer = await crypto.subtle.digest(
    'SHA-256',
    new TextEncoder().encode(codeVerifier),
  );
  const codeChallenge = Buffer.from(challengeBuffer).toString('base64url');

  // PAR request (with retry for flaky wrangler dev)
  let parData;
  for (let attempt = 0; attempt < 3; attempt++) {
    // Generate fresh DPoP proof for each attempt
    const parProof = await dpop.createProof('POST', `${BASE}/oauth/par`);
    const parRes = await fetchWithRetry(`${BASE}/oauth/par`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        DPoP: parProof,
      },
      body: new URLSearchParams({
        client_id: clientId,
        redirect_uri: redirectUri,
        response_type: 'code',
        scope: scope,
        code_challenge: codeChallenge,
        code_challenge_method: 'S256',
        login_hint: did,
      }).toString(),
    });
    if (parRes.ok) {
      parData = await parRes.json();
      break;
    }
    if (attempt < 2) {
      await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
    } else {
      const text = await parRes.text();
      throw new Error(
        `PAR request failed: ${parRes.status} - ${text.slice(0, 100)}`,
      );
    }
  }

  // Authorize (with retry)
  let authCode;
  for (let attempt = 0; attempt < 3; attempt++) {
    const authRes = await fetchWithRetry(`${BASE}/oauth/authorize`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        request_uri: parData.request_uri,
        client_id: clientId,
        password: password,
      }).toString(),
      redirect: 'manual',
    });
    const location = authRes.headers.get('location');
    if (location) {
      authCode = new URL(location).searchParams.get('code');
      if (authCode) break;
    }
    if (attempt < 2) {
      await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
    } else {
      throw new Error('Authorize request failed to return code');
    }
  }

  // Token exchange (with retry and fresh DPoP proof)
  let tokenData;
  for (let attempt = 0; attempt < 3; attempt++) {
    const tokenProof = await dpop.createProof('POST', `${BASE}/oauth/token`);
    const tokenRes = await fetchWithRetry(`${BASE}/oauth/token`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        DPoP: tokenProof,
      },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: authCode || '',
        client_id: clientId,
        redirect_uri: redirectUri,
        code_verifier: codeVerifier,
      }).toString(),
    });
    if (tokenRes.ok) {
      tokenData = await tokenRes.json();
      break;
    }
    if (attempt < 2) {
      await new Promise((r) => setTimeout(r, 100 * (attempt + 1)));
    } else {
      const text = await tokenRes.text();
      throw new Error(
        `Token request failed: ${tokenRes.status} - ${text.slice(0, 100)}`,
      );
    }
  }

  return {
    accessToken: tokenData.access_token,
    refreshToken: tokenData.refresh_token,
    dpop,
  };
}