Our Personal Data Server from scratch!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 520 lines 13 kB view raw
1const OAUTH_STATE_KEY = "tranquil_pds_oauth_state"; 2const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier"; 3const DPOP_KEY_STORE = "tranquil_pds_dpop_keys"; 4const DPOP_NONCE_KEY = "tranquil_pds_dpop_nonce"; 5 6const SCOPES = [ 7 "atproto", 8 "repo:*?action=create", 9 "repo:*?action=update", 10 "repo:*?action=delete", 11 "blob:*/*", 12 "identity:*", 13 "account:*?action=manage", 14].join(" "); 15 16const CLIENT_ID = !(import.meta.env.DEV) 17 ? `${globalThis.location.origin}/oauth/client-metadata.json` 18 : `http://localhost/?scope=${SCOPES}`; 19 20const REDIRECT_URI = `${globalThis.location.origin}/app/`; 21 22interface OAuthState { 23 state: string; 24 codeVerifier: string; 25 returnTo?: string; 26} 27 28interface DPoPKeyPair { 29 publicKey: CryptoKey; 30 privateKey: CryptoKey; 31 jwk: JsonWebKey; 32} 33 34function generateRandomString(length: number): string { 35 const array = new Uint8Array(length); 36 crypto.getRandomValues(array); 37 return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join( 38 "", 39 ); 40} 41 42function sha256(plain: string): Promise<ArrayBuffer> { 43 const encoder = new TextEncoder(); 44 const data = encoder.encode(plain); 45 return crypto.subtle.digest("SHA-256", data); 46} 47 48function base64UrlEncode(buffer: ArrayBuffer): string { 49 const bytes = new Uint8Array(buffer); 50 const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join( 51 "", 52 ); 53 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( 54 /=+$/, 55 "", 56 ); 57} 58 59export async function generateCodeChallenge(verifier: string): Promise<string> { 60 const hash = await sha256(verifier); 61 return base64UrlEncode(hash); 62} 63 64export function generateState(): string { 65 return generateRandomString(32); 66} 67 68export function generateCodeVerifier(): string { 69 return generateRandomString(32); 70} 71 72export function saveOAuthState(state: OAuthState): void { 73 sessionStorage.setItem(OAUTH_STATE_KEY, state.state); 74 sessionStorage.setItem(OAUTH_VERIFIER_KEY, state.codeVerifier); 75} 76 77function getOAuthState(): OAuthState | null { 78 const state = sessionStorage.getItem(OAUTH_STATE_KEY); 79 const codeVerifier = sessionStorage.getItem(OAUTH_VERIFIER_KEY); 80 if (!state || !codeVerifier) return null; 81 return { state, codeVerifier }; 82} 83 84function clearOAuthState(): void { 85 sessionStorage.removeItem(OAUTH_STATE_KEY); 86 sessionStorage.removeItem(OAUTH_VERIFIER_KEY); 87} 88 89function clearDPoPNonce(): void { 90 sessionStorage.removeItem(DPOP_NONCE_KEY); 91} 92 93export function clearAllOAuthState(): void { 94 clearOAuthState(); 95 clearDPoPNonce(); 96} 97 98async function openKeyStore(): Promise<IDBDatabase> { 99 return new Promise((resolve, reject) => { 100 const request = indexedDB.open(DPOP_KEY_STORE, 1); 101 request.onerror = () => reject(request.error); 102 request.onsuccess = () => resolve(request.result); 103 request.onupgradeneeded = () => { 104 const db = request.result; 105 if (!db.objectStoreNames.contains("keys")) { 106 db.createObjectStore("keys"); 107 } 108 }; 109 }); 110} 111 112async function storeDPoPKeyPair(keyPair: DPoPKeyPair): Promise<void> { 113 const db = await openKeyStore(); 114 return new Promise((resolve, reject) => { 115 const tx = db.transaction("keys", "readwrite"); 116 const store = tx.objectStore("keys"); 117 store.put(keyPair.publicKey, "publicKey"); 118 store.put(keyPair.privateKey, "privateKey"); 119 store.put(keyPair.jwk, "jwk"); 120 tx.oncomplete = () => { 121 db.close(); 122 resolve(); 123 }; 124 tx.onerror = () => { 125 db.close(); 126 reject(tx.error); 127 }; 128 }); 129} 130 131async function loadDPoPKeyPair(): Promise<DPoPKeyPair | null> { 132 try { 133 const db = await openKeyStore(); 134 return new Promise((resolve, reject) => { 135 const tx = db.transaction("keys", "readonly"); 136 const store = tx.objectStore("keys"); 137 const publicKeyReq = store.get("publicKey"); 138 const privateKeyReq = store.get("privateKey"); 139 const jwkReq = store.get("jwk"); 140 tx.oncomplete = () => { 141 db.close(); 142 if (publicKeyReq.result && privateKeyReq.result && jwkReq.result) { 143 resolve({ 144 publicKey: publicKeyReq.result, 145 privateKey: privateKeyReq.result, 146 jwk: jwkReq.result, 147 }); 148 } else { 149 resolve(null); 150 } 151 }; 152 tx.onerror = () => { 153 db.close(); 154 reject(tx.error); 155 }; 156 }); 157 } catch { 158 return null; 159 } 160} 161 162async function generateDPoPKeyPair(): Promise<DPoPKeyPair> { 163 const keyPair = await crypto.subtle.generateKey( 164 { name: "ECDSA", namedCurve: "P-256" }, 165 true, 166 ["sign", "verify"], 167 ); 168 const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 169 return { 170 publicKey: keyPair.publicKey, 171 privateKey: keyPair.privateKey, 172 jwk, 173 }; 174} 175 176async function getOrCreateDPoPKeyPair(): Promise<DPoPKeyPair> { 177 const existing = await loadDPoPKeyPair(); 178 if (existing) return existing; 179 180 const keyPair = await generateDPoPKeyPair(); 181 await storeDPoPKeyPair(keyPair); 182 return keyPair; 183} 184 185async function createDPoPProof( 186 keyPair: DPoPKeyPair, 187 method: string, 188 url: string, 189 nonce?: string, 190 accessTokenHash?: string, 191): Promise<string> { 192 const header = { 193 typ: "dpop+jwt", 194 alg: "ES256", 195 jwk: { 196 kty: keyPair.jwk.kty, 197 crv: keyPair.jwk.crv, 198 x: keyPair.jwk.x, 199 y: keyPair.jwk.y, 200 }, 201 }; 202 203 const payload: Record<string, unknown> = { 204 jti: generateRandomString(16), 205 htm: method.toUpperCase(), 206 htu: url.split("?")[0], 207 iat: Math.floor(Date.now() / 1000), 208 }; 209 210 if (nonce) { 211 payload.nonce = nonce; 212 } 213 214 if (accessTokenHash) { 215 payload.ath = accessTokenHash; 216 } 217 218 const headerB64 = base64UrlEncode( 219 new TextEncoder().encode(JSON.stringify(header)).buffer as ArrayBuffer, 220 ); 221 const payloadB64 = base64UrlEncode( 222 new TextEncoder().encode(JSON.stringify(payload)).buffer as ArrayBuffer, 223 ); 224 const signingInput = `${headerB64}.${payloadB64}`; 225 226 const signature = await crypto.subtle.sign( 227 { name: "ECDSA", hash: "SHA-256" }, 228 keyPair.privateKey, 229 new TextEncoder().encode(signingInput), 230 ); 231 232 const sigBytes = new Uint8Array(signature); 233 const signatureB64 = base64UrlEncode(sigBytes.buffer); 234 235 return `${signingInput}.${signatureB64}`; 236} 237 238async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> { 239 const canonical = JSON.stringify({ 240 crv: jwk.crv, 241 kty: jwk.kty, 242 x: jwk.x, 243 y: jwk.y, 244 }); 245 const hash = await sha256(canonical); 246 return base64UrlEncode(hash); 247} 248 249export function getDPoPNonce(): string | null { 250 return sessionStorage.getItem(DPOP_NONCE_KEY); 251} 252 253export function setDPoPNonce(nonce: string): void { 254 sessionStorage.setItem(DPOP_NONCE_KEY, nonce); 255} 256 257export function extractDPoPNonceFromResponse(response: Response): void { 258 const nonce = response.headers.get("DPoP-Nonce"); 259 if (nonce) { 260 setDPoPNonce(nonce); 261 } 262} 263 264async function startOAuthFlow(options?: { 265 loginHint?: string; 266 prompt?: string; 267}): Promise<void> { 268 clearAllOAuthState(); 269 270 const state = generateState(); 271 const codeVerifier = generateCodeVerifier(); 272 const codeChallenge = await generateCodeChallenge(codeVerifier); 273 274 const keyPair = await getOrCreateDPoPKeyPair(); 275 const dpopJkt = await computeJwkThumbprint(keyPair.jwk); 276 277 saveOAuthState({ state, codeVerifier }); 278 279 const parParams: Record<string, string> = { 280 client_id: CLIENT_ID, 281 redirect_uri: REDIRECT_URI, 282 response_type: "code", 283 scope: SCOPES, 284 state: state, 285 code_challenge: codeChallenge, 286 code_challenge_method: "S256", 287 dpop_jkt: dpopJkt, 288 }; 289 if (options?.loginHint) { 290 parParams.login_hint = options.loginHint; 291 } 292 if (options?.prompt) { 293 parParams.prompt = options.prompt; 294 } 295 296 const parResponse = await fetch("/oauth/par", { 297 method: "POST", 298 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 299 body: new URLSearchParams(parParams), 300 }); 301 302 if (!parResponse.ok) { 303 const error = await parResponse.json().catch(() => ({ 304 error: "Unknown error", 305 })); 306 throw new Error( 307 error.error_description || error.error || "Failed to start OAuth flow", 308 ); 309 } 310 311 const { request_uri } = await parResponse.json(); 312 313 const authorizeUrl = new URL("/oauth/authorize", globalThis.location.origin); 314 authorizeUrl.searchParams.set("client_id", CLIENT_ID); 315 authorizeUrl.searchParams.set("request_uri", request_uri); 316 317 globalThis.location.href = authorizeUrl.toString(); 318} 319 320export async function startOAuthLogin(loginHint?: string): Promise<void> { 321 return startOAuthFlow({ loginHint }); 322} 323 324export async function startOAuthRegister(): Promise<void> { 325 return startOAuthFlow({ prompt: "create" }); 326} 327 328export async function getOAuthRequestUri(prompt?: string): Promise<string> { 329 clearAllOAuthState(); 330 331 const state = generateState(); 332 const codeVerifier = generateCodeVerifier(); 333 const codeChallenge = await generateCodeChallenge(codeVerifier); 334 335 const keyPair = await getOrCreateDPoPKeyPair(); 336 const dpopJkt = await computeJwkThumbprint(keyPair.jwk); 337 338 saveOAuthState({ state, codeVerifier }); 339 340 const parParams: Record<string, string> = { 341 client_id: CLIENT_ID, 342 redirect_uri: REDIRECT_URI, 343 response_type: "code", 344 scope: SCOPES, 345 state: state, 346 code_challenge: codeChallenge, 347 code_challenge_method: "S256", 348 dpop_jkt: dpopJkt, 349 }; 350 if (prompt) { 351 parParams.prompt = prompt; 352 } 353 354 const parResponse = await fetch("/oauth/par", { 355 method: "POST", 356 headers: { "Content-Type": "application/x-www-form-urlencoded" }, 357 body: new URLSearchParams(parParams), 358 }); 359 360 if (!parResponse.ok) { 361 const error = await parResponse.json().catch(() => ({ 362 error: "Unknown error", 363 })); 364 throw new Error( 365 error.error_description || error.error || "Failed to get request URI", 366 ); 367 } 368 369 const { request_uri } = await parResponse.json(); 370 return request_uri; 371} 372 373export function getRequestUriFromUrl(): string | null { 374 const params = new URLSearchParams(globalThis.location.search); 375 return params.get("request_uri"); 376} 377 378export async function ensureRequestUri( 379 prompt = "create", 380): Promise<string | null> { 381 const existing = getRequestUriFromUrl(); 382 if (existing) return existing; 383 384 const newRequestUri = await getOAuthRequestUri(prompt); 385 const url = new URL(globalThis.location.href); 386 url.searchParams.set("request_uri", newRequestUri); 387 globalThis.location.href = url.toString(); 388 return null; 389} 390 391export interface OAuthTokens { 392 access_token: string; 393 refresh_token?: string; 394 token_type: string; 395 expires_in?: number; 396 scope?: string; 397 sub: string; 398} 399 400async function tokenRequest( 401 params: URLSearchParams, 402 retryWithNonce = true, 403): Promise<OAuthTokens> { 404 const keyPair = await getOrCreateDPoPKeyPair(); 405 const tokenEndpoint = `${globalThis.location.origin}/oauth/token`; 406 407 const dpopProof = await createDPoPProof( 408 keyPair, 409 "POST", 410 tokenEndpoint, 411 getDPoPNonce() ?? undefined, 412 ); 413 414 const response = await fetch("/oauth/token", { 415 method: "POST", 416 headers: { 417 "Content-Type": "application/x-www-form-urlencoded", 418 "DPoP": dpopProof, 419 }, 420 body: params, 421 }); 422 423 extractDPoPNonceFromResponse(response); 424 425 if (!response.ok) { 426 const error = await response.json().catch(() => ({ 427 error: "Unknown error", 428 })); 429 430 if (retryWithNonce && error.error === "use_dpop_nonce" && getDPoPNonce()) { 431 return tokenRequest(params, false); 432 } 433 434 throw new Error( 435 error.error_description || error.error || "Token request failed", 436 ); 437 } 438 439 return response.json(); 440} 441 442export async function handleOAuthCallback( 443 code: string, 444 state: string, 445): Promise<OAuthTokens> { 446 const savedState = getOAuthState(); 447 if (!savedState) { 448 throw new Error("No OAuth state found. Please try logging in again."); 449 } 450 451 if (savedState.state !== state) { 452 clearOAuthState(); 453 throw new Error("OAuth state mismatch. Please try logging in again."); 454 } 455 456 const params = new URLSearchParams({ 457 grant_type: "authorization_code", 458 client_id: CLIENT_ID, 459 code: code, 460 redirect_uri: REDIRECT_URI, 461 code_verifier: savedState.codeVerifier, 462 }); 463 464 clearOAuthState(); 465 466 return tokenRequest(params); 467} 468 469export async function refreshOAuthToken( 470 refreshToken: string, 471): Promise<OAuthTokens> { 472 const params = new URLSearchParams({ 473 grant_type: "refresh_token", 474 client_id: CLIENT_ID, 475 refresh_token: refreshToken, 476 }); 477 478 return tokenRequest(params); 479} 480 481export function checkForOAuthCallback(): 482 | { code: string; state: string } 483 | null { 484 if (globalThis.location.pathname === "/app/migrate") { 485 return null; 486 } 487 488 const params = new URLSearchParams(globalThis.location.search); 489 const code = params.get("code"); 490 const state = params.get("state"); 491 492 if (code && state) { 493 return { code, state }; 494 } 495 496 return null; 497} 498 499export function clearOAuthCallbackParams(): void { 500 const url = new URL(globalThis.location.href); 501 url.search = ""; 502 globalThis.history.replaceState({}, "", url.toString()); 503} 504 505export async function createDPoPProofForRequest( 506 method: string, 507 url: string, 508 accessToken: string, 509): Promise<string> { 510 const keyPair = await getOrCreateDPoPKeyPair(); 511 const tokenHash = await sha256(accessToken); 512 const ath = base64UrlEncode(tokenHash); 513 return createDPoPProof( 514 keyPair, 515 method, 516 url, 517 getDPoPNonce() ?? undefined, 518 ath, 519 ); 520}