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