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