/** * OAuth flow helpers for e2e tests */ import { randomBytes } from 'node:crypto'; import { DpopClient } from './dpop.js'; const BASE = 'http://localhost:8787'; /** * Fetch with retry for flaky wrangler dev * @param {string} url * @param {RequestInit} options * @param {number} maxAttempts * @returns {Promise} */ 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, }; }