/** * 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} beginSignIn * @property {(params: URLSearchParams) => Promise<{session: OAuthSession, returnTo: string}>} completeSignIn * @property {(session: OAuthSession|null) => Promise} signOut * @property {(callback: () => void) => void} onSessionEnded * @property {(session: OAuthSession, returnTo: string) => void} renewGrant * @property {() => void} clearRenewal * @property {(session: OAuthSession, url: string, init?: RequestInit) => Promise} 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>} */ async function jsonObject(response) { const value = await response.json().catch(() => ({})); return value && typeof value === 'object' && !Array.isArray(value) ? /** @type {Record} */ (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} form * @returns {Promise>} */ 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} */ 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|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} */ 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, }; }