chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* 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,
};
}