Something went wrong. Try again.
An AT Protocol Personal Data Server written in JavaScript pdsjs.dev
pds atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278/** * 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 */