/** * 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} params - form body * @param {Object} [options] * @param {typeof fetch} [options.fetch] - what carries the request; the * global by default * @returns {Promise} 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} 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( `git-remote-atproto

${text}

`, ); 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} */ 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} 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} */ 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} */ 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, }; }