chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
/**
* The forge owner's login: an atproto OAuth client the appview runs on
* itself.
*
* The flow is the standard authorization-code grant with PKCE and
* DPoP-bound tokens, the same one the git helper runs as a loopback client.
* Here the client is this origin: the metadata document and the callback
* are served beside the forge, the session rides a cookie, and the access
* token is what the request's own writes are made with. A session whose
* DID is not the hosted account's buys nothing, because every owner-only
* procedure checks the DID.
*/
import { randomBytes } from 'node:crypto';
import { readCookie } from '@pdsjs/core/http';
import { generateDpopKey } from '@pdsjs/git/dpop';
import { resolveRepoLocation } from '@pdsjs/git/identity';
import { discoverAuthServer, dpopPost } from '@pdsjs/git/oauth';
/** The cookie the session rides. */
export const SESSION_COOKIE = 'git-host-session';
/** A pending authorization dies if the user never comes back. */
const PENDING_TTL_MS = 10 * 60 * 1000;
/** A session lives this long past its last refresh. */
const SESSION_TTL_MS = 14 * 24 * 60 * 60 * 1000;
/**
* @param {Uint8Array} bytes
* @returns {string}
*/
function base64Url(bytes) {
return Buffer.from(bytes)
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
const randomToken = () => base64Url(randomBytes(24));
/**
* @param {Object} options
* @param {string} options.origin - this forge's origin, which names the
* client_id and the redirect
* @param {string} options.accountDid - the hosted account; any other
* login's session grants nothing
* @param {string} [options.service] - the hosted account's PDS, which
* short-circuits identity resolution for a login named by DID
* @param {string} [options.plcUrl] - the directory handles resolve against
* @param {string} [options.scope] - the grant to ask for (default atproto,
* which covers the record and blob writes the forge makes)
* @param {typeof fetch} [options.fetch] - what carries the OAuth requests;
* the global by default
* @param {(message: string) => void} [options.onNotice]
*/
export function createForgeAuth(options) {
const origin = options.origin.replace(/\/+$/, '');
const clientId = `${origin}/oauth/client-metadata.json`;
const redirectUri = `${origin}/oauth/callback`;
const onNotice = options.onNotice ?? (() => {});
/** Pending authorizations by state. @type {Map<string, {verifier: string, key: Awaited<ReturnType<typeof generateDpopKey>>, service: string, tokenEndpoint: string, at: number}>} */
const pending = new Map();
/** Sessions by sid. @type {Map<string, {did: string, handle: string|null, session: Record<string, unknown>, key: Awaited<ReturnType<typeof generateDpopKey>>, tokenEndpoint: string, clientId: string, scope: string, service: string, expiresAt: number}>} */
const sessions = new Map();
/** @param {string} handle */
async function beginLogin(handle) {
const { did, service } = await resolveRepoLocation(handle, {
ATPROTO_GIT_PLC_URL: options.plcUrl,
...(options.service ? { ATPROTO_GIT_SERVICE: options.service } : {}),
});
const authServer = await discoverAuthServer(service, {
fetch: options.fetch,
});
const key = await generateDpopKey({ extractable: true });
const verifier = base64Url(randomBytes(32));
const challenge = base64Url(
new Uint8Array(
await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(verifier),
),
),
);
const state = base64Url(randomBytes(16));
const par = await dpopPost(
authServer.parEndpoint,
key,
{
client_id: clientId,
redirect_uri: redirectUri,
response_type: 'code',
scope: options.scope ?? 'atproto',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
login_hint: handle,
},
{ fetch: options.fetch },
);
pending.set(state, {
verifier,
key,
service,
tokenEndpoint: authServer.tokenEndpoint,
at: Date.now(),
});
for (const [key_, entry] of pending) {
if (Date.now() - entry.at > PENDING_TTL_MS) pending.delete(key_);
}
const authorizeUrl = new URL(authServer.authorizationEndpoint);
authorizeUrl.searchParams.set('request_uri', par.request_uri);
authorizeUrl.searchParams.set('client_id', clientId);
return { did, authorizeUrl: authorizeUrl.toString() };
}
/**
* @param {URL} url - the callback request's URL
* @returns {Promise<{sid: string, did: string, cookie: string}>}
*/
async function completeLogin(url) {
const error = url.searchParams.get('error');
if (error) {
throw new Error(`authorization denied: ${error}`);
}
const code = url.searchParams.get('code');
const state = url.searchParams.get('state') ?? '';
const entry = pending.get(state);
if (!code || !entry) {
throw new Error('the redirect carried no code or an unknown state');
}
pending.delete(state);
const token = await dpopPost(
entry.tokenEndpoint,
entry.key,
{
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
client_id: clientId,
code_verifier: entry.verifier,
},
{ fetch: options.fetch },
);
const sid = randomToken();
sessions.set(sid, {
did: String(token.sub),
handle: null,
session: token,
key: entry.key,
tokenEndpoint: entry.tokenEndpoint,
clientId,
scope: String(token.scope ?? ''),
service: entry.service,
expiresAt: Date.now() + SESSION_TTL_MS,
});
onNotice(`login: ${token.sub}`);
return {
sid,
did: String(token.sub),
cookie: sessionCookie(sid),
};
}
/**
* Exchange the refresh token for a fresh pair, rotating the stored one.
* @param {string} sid
* @returns {Promise<boolean>} false when the session is beyond saving
*/
async function refresh(sid) {
const stored = sessions.get(sid);
if (!stored) return false;
try {
const token = await dpopPost(
stored.tokenEndpoint,
stored.key,
{
grant_type: 'refresh_token',
refresh_token: String(stored.session.refresh_token),
client_id: stored.clientId,
},
{ fetch: options.fetch },
);
stored.session = token;
stored.scope = String(token.scope ?? '');
stored.expiresAt = Date.now() + SESSION_TTL_MS;
return true;
} catch (err) {
onNotice(
`session refresh failed: ${err instanceof Error ? err.message : err}`,
);
sessions.delete(sid);
return false;
}
}
/**
* The DID the request's session names, or null. A session whose DID is
* not the hosted account's reads as no session: every owner check
* compares against the hosted account, so the forge never has to.
* @param {Request} request
* @returns {Promise<string|null>}
*/
async function sessionDid(request) {
const sid = readCookie(request, SESSION_COOKIE);
if (!sid) return null;
const stored = sessions.get(sid);
if (!stored) return null;
if (stored.expiresAt < Date.now()) {
sessions.delete(sid);
return null;
}
if (stored.did !== options.accountDid) return null;
return stored.did;
}
/**
* The access token behind the request's session, for the writes the
* request causes. Refreshes when the server says the token is stale.
* @param {Request} request
* @returns {Promise<string|null>}
*/
async function tokenFor(request) {
const sid = readCookie(request, SESSION_COOKIE);
if (!sid) return null;
const stored = sessions.get(sid);
if (!stored) return null;
const token = stored.session;
return typeof token.access_token === 'string' ? token.access_token : null;
}
/**
* @param {string} sid
* @returns {string}
*/
function sessionCookie(sid) {
return `${SESSION_COOKIE}=${sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${SESSION_TTL_MS / 1000}`;
}
return {
beginLogin,
completeLogin,
sessionDid,
tokenFor,
refresh,
logout(/** @type {Request} */ request) {
const sid = readCookie(request, SESSION_COOKIE);
if (sid) sessions.delete(sid);
return `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`;
},
/** The metadata document the client_id names. */
metadataDocument() {
return {
client_id: clientId,
client_name: 'git-host',
client_uri: origin,
redirect_uris: [redirectUri],
scope: options.scope ?? 'atproto',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'none',
application_type: 'web',
dpop_bound_access_tokens: true,
};
},
};
}
/** @typedef {ReturnType<typeof createForgeAuth>} ForgeAuth */