Something went wrong. Try again.
Our Personal Data Server from scratch!
Something went wrong. Try again.
13 kB · 521 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522const OAUTH_STATE_KEY = "tranquil_pds_oauth_state";const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier";const DPOP_KEY_STORE = "tranquil_pds_dpop_keys";const DPOP_NONCE_KEY = "tranquil_pds_dpop_nonce";
export const SCOPES = [ "atproto", "repo:*?action=create", "repo:*?action=update", "repo:*?action=delete", "blob:*/*", "identity:*", "account:*?action=manage",].join(" ");
const CLIENT_ID = !(import.meta.env.DEV) || globalThis.location?.hostname !== 'localhost' ? `${globalThis.location.origin}/oauth-client-metadata.json` : `http://localhost/?scope=${encodeURIComponent(SCOPES)}`;
const REDIRECT_URI = `${globalThis.location.origin}/app/`;
interface OAuthState { state: string; codeVerifier: string; returnTo?: string;}
interface DPoPKeyPair { publicKey: CryptoKey; privateKey: CryptoKey; jwk: JsonWebKey;}
function generateRandomString(length: number): string { const array = new Uint8Array(length); crypto.getRandomValues(array); return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join( "", );}
function sha256(plain: string): Promise<ArrayBuffer> { const encoder = new TextEncoder(); const data = encoder.encode(plain); return crypto.subtle.digest("SHA-256", data);}
function base64UrlEncode(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( "", ); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( /=+$/, "", );}
export async function generateCodeChallenge(verifier: string): Promise<string> { const hash = await sha256(verifier); return base64UrlEncode(hash);}
export function generateState(): string { return generateRandomString(32);}
export function generateCodeVerifier(): string { return generateRandomString(32);}
export function saveOAuthState(state: OAuthState): void { sessionStorage.setItem(OAUTH_STATE_KEY, state.state); sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier);}
function getOAuthState(): OAuthState | null { const state = sessionStorage.getItem(OAUTH_STATE_KEY); const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY); if (!state || !codeVerifier) return null; return { state, codeVerifier };}
function clearOAuthState(): void { sessionStorage.removeItem(OAUTH_STATE_KEY); sessionStorage.removeItem(OAUTH_VERIFIER_KEY);}
function clearDPoPNonce(): void { sessionStorage.removeItem(DPOP_NONCE_KEY);}
export function clearAllOAuthState(): void { clearOAuthState(); clearDPoPNonce();}
async function openKeyStore(): Promise<IDBDatabase> { return new Promise((resolve, reject) => { const request = indexedDB.open(DPOP_KEY_STORE, 1); request.onerror = () => reject(request.error); request.onsuccess = () => resolve(request.result); request.onupgradeneeded = () => { const db = request.result; if (!db.objectStoreNames.contains("keys")) { db.createObjectStore("keys"); } }; });}
async function storeDPoPKeyPair(keyPair: DPoPKeyPair): Promise<void> { const db = await openKeyStore(); return new Promise((resolve, reject) => { const tx = db.transaction("keys", "readwrite"); const store = tx.objectStore("keys"); store.put(keyPair.publicKey, "publicKey"); store.put(keyPair.privateKey, "privateKey"); store.put(keyPair.jwk, "jwk"); tx.oncomplete = () => { db.close(); resolve(); }; tx.onerror = () => { db.close(); reject(tx.error); }; });}
async function loadDPoPKeyPair(): Promise<DPoPKeyPair | null> { try { const db = await openKeyStore(); return new Promise((resolve, reject) => { const tx = db.transaction("keys", "readonly"); const store = tx.objectStore("keys"); const publicKeyReq = store.get("publicKey"); const privateKeyReq = store.get("privateKey"); const jwkReq = store.get("jwk"); tx.oncomplete = () => { db.close(); if (publicKeyReq.result && privateKeyReq.result && jwkReq.result) { resolve({ publicKey: publicKeyReq.result, privateKey: privateKeyReq.result, jwk: jwkReq.result, }); } else { resolve(null); } }; tx.onerror = () => { db.close(); reject(tx.error); }; }); } catch { return null; }}
async function generateDPoPKeyPair(): Promise<DPoPKeyPair> { const keyPair = await crypto.subtle.generateKey( { name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"], ); const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); return { publicKey: keyPair.publicKey, privateKey: keyPair.privateKey, jwk, };}
async function getOrCreateDPoPKeyPair(): Promise<DPoPKeyPair> { const existing = await loadDPoPKeyPair(); if (existing) return existing;
const keyPair = await generateDPoPKeyPair(); await storeDPoPKeyPair(keyPair); return keyPair;}
async function createDPoPProof( keyPair: DPoPKeyPair, method: string, url: string, nonce?: string, accessTokenHash?: string,): Promise<string> { const header = { typ: "dpop+jwt", alg: "ES256", jwk: { kty: keyPair.jwk.kty, crv: keyPair.jwk.crv, x: keyPair.jwk.x, y: keyPair.jwk.y, }, };
const payload: Record<string, unknown> = { jti: generateRandomString(16), htm: method.toUpperCase(), htu: url.split("?")[0], iat: Math.floor(Date.now() / 1000), };
if (nonce) { payload.nonce = nonce; }
if (accessTokenHash) { payload.ath = accessTokenHash; }
const headerB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(header)).buffer as ArrayBuffer, ); const payloadB64 = base64UrlEncode( new TextEncoder().encode(JSON.stringify(payload)).buffer as ArrayBuffer, ); const signingInput = `${headerB64}.${payloadB64}`;
const signature = await crypto.subtle.sign( { name: "ECDSA", hash: "SHA-256" }, keyPair.privateKey, new TextEncoder().encode(signingInput), );
const sigBytes = new Uint8Array(signature); const signatureB64 = base64UrlEncode(sigBytes.buffer);
return `${signingInput}.${signatureB64}`;}
async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> { const canonical = JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y, }); const hash = await sha256(canonical); return base64UrlEncode(hash);}
export function getDPoPNonce(): string | null { return sessionStorage.getItem(DPOP_NONCE_KEY);}
export function setDPoPNonce(nonce: string): void { sessionStorage.setItem(DPOP_NONCE_KEY, nonce);}
export function extractDPoPNonceFromResponse(response: Response): void { const nonce = response.headers.get("DPoP-Nonce"); if (nonce) { setDPoPNonce(nonce); }}
async function startOAuthFlow(options?: { loginHint?: string; prompt?: string;}): Promise<void> { clearAllOAuthState();
const state = generateState(); const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier);
const keyPair = await getOrCreateDPoPKeyPair(); const dpopJkt = await computeJwkThumbprint(keyPair.jwk);
saveOAuthState({ state, codeVerifier });
const parParams: Record<string, string> = { client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: "code", scope: SCOPES, state: state, code_challenge: codeChallenge, code_challenge_method: "S256", dpop_jkt: dpopJkt, }; if (options?.loginHint) { parParams.login_hint = options.loginHint; } if (options?.prompt) { parParams.prompt = options.prompt; }
const parResponse = await fetch("/oauth/par", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(parParams), });
if (!parResponse.ok) { const error = await parResponse.json().catch(() => ({ error: "Unknown error", })); throw new Error( error.error_description || error.error || "Failed to start OAuth flow", ); }
const { request_uri } = await parResponse.json();
const authorizeUrl = new URL("/oauth/authorize", globalThis.location.origin); authorizeUrl.searchParams.set("client_id", CLIENT_ID); authorizeUrl.searchParams.set("request_uri", request_uri);
globalThis.location.href = authorizeUrl.toString();}
export async function startOAuthLogin(loginHint?: string): Promise<void> { return startOAuthFlow({ loginHint });}
export async function startOAuthRegister(): Promise<void> { return startOAuthFlow({ prompt: "create" });}
export async function getOAuthRequestUri(prompt?: string): Promise<string> { clearAllOAuthState();
const state = generateState(); const codeVerifier = generateCodeVerifier(); const codeChallenge = await generateCodeChallenge(codeVerifier);
const keyPair = await getOrCreateDPoPKeyPair(); const dpopJkt = await computeJwkThumbprint(keyPair.jwk);
saveOAuthState({ state, codeVerifier });
const parParams: Record<string, string> = { client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: "code", scope: SCOPES, state: state, code_challenge: codeChallenge, code_challenge_method: "S256", dpop_jkt: dpopJkt, }; if (prompt) { parParams.prompt = prompt; }
const parResponse = await fetch("/oauth/par", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams(parParams), });
if (!parResponse.ok) { const error = await parResponse.json().catch(() => ({ error: "Unknown error", })); throw new Error( error.error_description || error.error || "Failed to get request URI", ); }
const { request_uri } = await parResponse.json(); return request_uri;}
export function getRequestUriFromUrl(): string | null { const params = new URLSearchParams(globalThis.location.search); return params.get("request_uri");}
export async function ensureRequestUri( prompt = "create",): Promise<string | null> { const existing = getRequestUriFromUrl(); if (existing) return existing;
const newRequestUri = await getOAuthRequestUri(prompt); const url = new URL(globalThis.location.href); url.searchParams.set("request_uri", newRequestUri); globalThis.location.href = url.toString(); return null;}
export interface OAuthTokens { access_token: string; refresh_token?: string; token_type: string; expires_in?: number; scope?: string; sub: string;}
async function tokenRequest( params: URLSearchParams, retryWithNonce = true,): Promise<OAuthTokens> { const keyPair = await getOrCreateDPoPKeyPair(); const tokenEndpoint = `${globalThis.location.origin}/oauth/token`;
const dpopProof = await createDPoPProof( keyPair, "POST", tokenEndpoint, getDPoPNonce() ?? undefined, );
const response = await fetch("/oauth/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "DPoP": dpopProof, }, body: params, });
extractDPoPNonceFromResponse(response);
if (!response.ok) { const error = await response.json().catch(() => ({ error: "Unknown error", }));
if (retryWithNonce && error.error === "use_dpop_nonce" && getDPoPNonce()) { return tokenRequest(params, false); }
throw new Error( error.error_description || error.error || "Token request failed", ); }
return response.json();}
export async function handleOAuthCallback( code: string, state: string,): Promise<OAuthTokens> { const savedState = getOAuthState(); if (!savedState) { throw new Error("No OAuth state found. Please try logging in again."); }
if (savedState.state !== state) { clearOAuthState(); throw new Error("OAuth state mismatch. Please try logging in again."); }
const params = new URLSearchParams({ grant_type: "authorization_code", client_id: CLIENT_ID, code: code, redirect_uri: REDIRECT_URI, code_verifier: savedState.codeVerifier, });
clearOAuthState();
return tokenRequest(params);}
export async function refreshOAuthToken( refreshToken: string,): Promise<OAuthTokens> { const params = new URLSearchParams({ grant_type: "refresh_token", client_id: CLIENT_ID, refresh_token: refreshToken, });
return tokenRequest(params);}
export function checkForOAuthCallback(): | { code: string; state: string } | null { if (globalThis.location.pathname === "/app/migrate") { return null; }
const params = new URLSearchParams(globalThis.location.search); const code = params.get("code"); const state = params.get("state");
if (code && state) { return { code, state }; }
return null;}
export function clearOAuthCallbackParams(): void { const url = new URL(globalThis.location.href); url.search = ""; globalThis.history.replaceState({}, "", url.toString());}
export async function createDPoPProofForRequest( method: string, url: string, accessToken: string,): Promise<string> { const keyPair = await getOrCreateDPoPKeyPair(); const tokenHash = await sha256(accessToken); const ath = base64UrlEncode(tokenHash); return createDPoPProof( keyPair, method, url, getDPoPNonce() ?? undefined, ath, );}