a proof of concept realtime collaborative text editor using atproto as a sync server jake.tngl.io/y-pds/
0

Configure Feed

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

ypds / atsw.js
18 kB 577 lines
1// This Source Code Form is subject to the terms of the Mozilla Public 2// License, v. 2.0. If a copy of the MPL was not distributed with this 3// file, You can obtain one at https://mozilla.org/MPL/2.0/. 4// 5// Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/atsw 6 7/** 8 * @typedef {Object} DPoPKey 9 * @property {CryptoKey} privateKey 10 * @property {JsonWebKey} jwk 11 */ 12 13/** 14 * @typedef {Object} OAuthConfig 15 * @property {string} clientId 16 * @property {string} redirectUri 17 * @property {string} scope 18 */ 19 20/** 21 * @typedef {Object} AttemptSession 22 * @property {string} state 23 * @property {string} verifier 24 * @property {DPoPKey} dpopKey 25 * @property {string} tokenEndpoint 26 * @property {string} issuer 27 * @property {string} did 28 * @property {string} pds 29 * @property {OAuthConfig} config 30 * @property {string} scope 31 */ 32 33/** 34 * @typedef {Object} OAuthSession 35 * @property {string} pds 36 * @property {string} did 37 * @property {string} accessToken 38 * @property {DPoPKey} dpopKey 39 * @property {string} tokenEndpoint 40 * @property {string} clientId 41 * @property {string} scope 42 * @property {number} expiresAt 43 * @property {string} [refreshToken] 44 * @property {string} [dpopNonce] 45 */ 46 47/** 48 * @typedef {Object} AuthServerMetadata 49 * @property {string} issuer 50 * @property {string} authorization_endpoint 51 * @property {string} token_endpoint 52 * @property {string} pushed_authorization_request_endpoint 53 */ 54 55const enc = new TextEncoder(); 56 57/** @param {ArrayBuffer | Uint8Array} buf */ 58const b64url = (buf) => 59 btoa(String.fromCharCode(...new Uint8Array(buf))) 60 .replace(/\+/g, "-") 61 .replace(/\//g, "_") 62 .replace(/=+$/, ""); 63 64/** @param {number} n */ 65const randomB64url = (n) => b64url(crypto.getRandomValues(new Uint8Array(n)).buffer); 66 67async function generatePKCE() { 68 const verifier = randomB64url(32); 69 const challenge = b64url(await crypto.subtle.digest("SHA-256", enc.encode(verifier))); 70 return { verifier, challenge }; 71} 72 73async function generateDPoPKey() { 74 const key = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ 75 "sign", 76 ]); 77 const jwk = await crypto.subtle.exportKey("jwk", key.publicKey); 78 79 return { privateKey: key.privateKey, jwk }; 80} 81 82/** 83 * @param {DPoPKey} dpopKey 84 * @param {string} htm 85 * @param {string} htu 86 * @param {string} [nonce] 87 * @param {string} [ath] 88 */ 89async function createDPoP({ privateKey, jwk }, htm, htu, nonce, ath) { 90 const header = { alg: "ES256", typ: "dpop+jwt", jwk }; 91 92 /** @type {Record<string, string | number>} */ 93 const payload = { htm, htu, jti: randomB64url(16), iat: Math.floor(Date.now() / 1000) }; 94 if (nonce) payload["nonce"] = nonce; 95 if (ath) payload["ath"] = ath; 96 97 const toSign = [header, payload] 98 .map((part) => JSON.stringify(part)) 99 .map((part) => enc.encode(part)) 100 .map((part) => b64url(part.buffer)) 101 .join("."); 102 const sig = await crypto.subtle.sign( 103 { name: "ECDSA", hash: "SHA-256" }, 104 privateKey, 105 enc.encode(toSign), 106 ); 107 108 return toSign + "." + b64url(sig); 109} 110 111const MAX_DPOP_RETRIES = 2; 112const DEFAULT_TOKEN_TTL = 3600; 113const DID_HEADER = "x-atsw-did"; 114 115/** 116 * @param {DPoPKey} key 117 * @param {string} url 118 * @param {URLSearchParams} body 119 * @param {string} [nonce] 120 */ 121async function dpopPost(key, url, body, nonce) { 122 let dpopNonce = nonce; 123 for (let attempts = 0; attempts < MAX_DPOP_RETRIES; attempts++) { 124 const dpop = await createDPoP(key, "POST", url, dpopNonce); 125 const res = await fetch(url, { 126 method: "POST", 127 headers: { "content-type": "application/x-www-form-urlencoded", DPoP: dpop }, 128 body, 129 }); 130 131 const newNonce = res.headers.get("dpop-nonce"); 132 const nonceChanged = newNonce && newNonce !== dpopNonce; 133 dpopNonce = newNonce ?? dpopNonce; 134 135 if (nonceChanged && 400 <= res.status && res.status <= 499) continue; 136 137 return { json: await res.json(), dpopNonce }; 138 } 139 140 throw new Error("DPoP nonce retry failed"); 141} 142 143const DB_NAME = "atproto:oauth"; 144const DB_VERSION = 6; 145const SESSIONS_STORE = "sessionsByScope"; 146 147/** @type {Promise<IDBDatabase> | null} */ 148let dbPromise = null; 149 150/** @returns {Promise<IDBDatabase>} */ 151function openDb() { 152 if (dbPromise) return dbPromise; 153 dbPromise = new Promise((resolve, reject) => { 154 const req = indexedDB.open(DB_NAME, DB_VERSION); 155 req.onupgradeneeded = () => { 156 const db = req.result; 157 const tx = req.transaction; 158 if (!tx) throw new Error("Missing IndexedDB upgrade transaction"); 159 160 const hadLegacySessions = db.objectStoreNames.contains("sessions"); 161 const hadSessionsByScope = db.objectStoreNames.contains(SESSIONS_STORE); 162 163 if (!db.objectStoreNames.contains("attempts")) 164 db.createObjectStore("attempts", { keyPath: "state" }); 165 166 const ssns = hadSessionsByScope 167 ? tx.objectStore(SESSIONS_STORE) 168 : db.createObjectStore(SESSIONS_STORE, { keyPath: ["scope", "did"] }); 169 if (!ssns.indexNames.contains("scope")) ssns.createIndex("scope", "scope", { unique: false }); 170 if (!ssns.indexNames.contains("scopePds")) 171 ssns.createIndex("scopePds", ["scope", "pds"], { unique: false }); 172 173 if (hadLegacySessions && !hadSessionsByScope) { 174 const copyReq = tx.objectStore("sessions").getAll(); 175 copyReq.onsuccess = () => { 176 for (const session of copyReq.result) { 177 if (session?.scope && session?.did) ssns.put(session); 178 } 179 }; 180 } 181 }; 182 req.onsuccess = () => resolve(req.result); 183 req.onerror = () => { 184 dbPromise = null; 185 reject(req.error); 186 }; 187 }); 188 189 return dbPromise; 190} 191 192/** 193 * @param {IDBTransactionMode} mode 194 * @param {string} store 195 * @param {(s: IDBObjectStore) => IDBRequest} fn 196 * @returns {Promise<any>} 197 */ 198async function idb(mode, store, fn) { 199 const db = await openDb(); 200 return new Promise((resolve, reject) => { 201 const tx = db.transaction(store, mode); 202 const req = fn(tx.objectStore(store)); 203 req.onsuccess = () => resolve(req.result); 204 req.onerror = () => reject(req.error); 205 }); 206} 207 208/** @param {AttemptSession} v */ 209const putAttempt = (v) => idb("readwrite", "attempts", (s) => s.put(v)); 210 211/** @param {string} state @returns {Promise<AttemptSession | undefined>} */ 212const getAttempt = (state) => idb("readonly", "attempts", (s) => s.get(state)); 213 214/** @param {string} state */ 215const deleteAttempt = (state) => idb("readwrite", "attempts", (s) => s.delete(state)); 216 217/** @param {OAuthSession} v */ 218const putSession = (v) => idb("readwrite", SESSIONS_STORE, (s) => s.put(v)); 219 220/** @returns {Promise<string>} */ 221async function currentScope() { 222 if ( 223 typeof ServiceWorkerGlobalScope !== "undefined" && 224 globalThis instanceof ServiceWorkerGlobalScope 225 ) { 226 return globalThis.registration.scope; 227 } 228 229 const nav = /** @type {Navigator & { serviceWorker?: ServiceWorkerContainer }} */ ( 230 globalThis.navigator 231 ); 232 const registration = await nav.serviceWorker?.getRegistration(); 233 if (!registration) throw new Error("register a service worker before using sessions"); 234 return registration.scope; 235} 236 237/** @returns {Promise<OAuthSession[]>} */ 238export const listSessions = async () => { 239 const scope = await currentScope(); 240 return idb("readonly", SESSIONS_STORE, (s) => s.index("scope").getAll(scope)); 241}; 242 243/** @param {string} scope @param {string} did @returns {Promise<OAuthSession | undefined>} */ 244const getSessionForScope = (scope, did) => 245 idb("readonly", SESSIONS_STORE, (s) => s.get([scope, did])); 246 247/** @param {string} did @returns {Promise<OAuthSession | undefined>} */ 248export const getSession = async (did) => getSessionForScope(await currentScope(), did); 249 250/** @param {string} scope @param {string} pds @returns {Promise<OAuthSession[]>} */ 251const listSessionsByPDS = (scope, pds) => 252 idb("readonly", SESSIONS_STORE, (s) => s.index("scopePds").getAll([scope, pds])); 253 254/** @param {string} did */ 255export const logOut = async (did) => { 256 const scope = await currentScope(); 257 return idb("readwrite", SESSIONS_STORE, (s) => s.delete([scope, did])); 258}; 259 260/** @param {string} handle */ 261export async function resolveDID(handle) { 262 // try to resolve DID using the DNS record 263 try { 264 const r = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`); 265 const j = await r.json(); 266 const txt = j.Answer?.find(/** @param {any} a */ (a) => a.data?.startsWith('"did=')); 267 if (txt) return /** @type {string} */ (txt.data.replace(/"/g, "").replace("did=", "")); 268 } catch {} 269 270 // HTTP .well-known resolution is blocked by CORS from the browser, so fall 271 // back to a public AppView which exposes a CORS-enabled resolver. 272 const r = await fetch( 273 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`, 274 ); 275 const j = await r.json(); 276 if (!j.did) throw new Error(`Could not resolve handle ${handle}: ${JSON.stringify(j)}`); 277 return /** @type {string} */ (j.did); 278} 279 280/** @param {string} did */ 281async function resolvePDS(did) { 282 // find URL of DID doc 283 const url = did.startsWith("did:web:") 284 ? `https://${did.split(":")[2]}/.well-known/did.json` 285 : `https://plc.directory/${did}`; 286 287 /** @type {{ service?: { type: string; serviceEndpoint: string }[] }} */ 288 const doc = await fetch(url).then((res) => res.json()); 289 290 // get service endpoint 291 const endpoint = doc.service?.find( 292 ({ type }) => type === "AtprotoPersonalDataServer", 293 )?.serviceEndpoint; 294 if (!endpoint) throw new Error(`No PDS found for ${did}`); 295 296 return endpoint; 297} 298 299/** 300 * @param {string} pds 301 * @returns {Promise<AuthServerMetadata>} 302 */ 303async function discoverAuthServer(pds) { 304 /** @type {AuthServerMetadata | undefined} */ 305 let meta; 306 try { 307 const res = await fetch(`${pds}/.well-known/oauth-protected-resource`).then((res) => 308 res.json(), 309 ); 310 const issuer = /** @type {string} */ (res.authorization_servers[0]); 311 meta = await fetch(`${issuer}/.well-known/oauth-authorization-server`).then((res) => 312 res.json(), 313 ); 314 } catch { 315 meta = await fetch(`${pds}/.well-known/oauth-authorization-server`).then((res) => res.json()); 316 } 317 318 if ( 319 !meta?.authorization_endpoint || 320 !meta.token_endpoint || 321 !meta.pushed_authorization_request_endpoint 322 ) { 323 throw new Error(`Could not discover OAuth endpoints for ${pds}`); 324 } 325 326 return meta; 327} 328 329/** 330 * Start the OAuth login flow. Stores an attempt session in IndexedDB and 331 * redirects the browser to the authorization server. When the auth server 332 * redirects back, the service worker will intercept the callback and complete 333 * the token exchange. 334 * @param {OAuthConfig} config 335 * @param {string} handle 336 * @returns {Promise<void>} 337 */ 338export async function logIn(config, handle) { 339 const scope = await currentScope(); 340 const did = await resolveDID(handle); 341 const pds = await resolvePDS(did); 342 const [meta, pkce, dpopKey] = await Promise.all([ 343 discoverAuthServer(pds), 344 generatePKCE(), 345 generateDPoPKey(), 346 ]); 347 const state = randomB64url(16); 348 349 await putAttempt({ 350 state, 351 verifier: pkce.verifier, 352 dpopKey, 353 tokenEndpoint: meta.token_endpoint, 354 issuer: meta.issuer, 355 did, 356 pds: new URL(pds).origin, 357 config, 358 scope, 359 }); 360 361 const body = new URLSearchParams({ 362 client_id: config.clientId, 363 redirect_uri: config.redirectUri, 364 response_type: "code", 365 scope: config.scope, 366 state, 367 code_challenge: pkce.challenge, 368 code_challenge_method: "S256", 369 login_hint: handle, 370 }); 371 372 const { json } = await dpopPost(dpopKey, meta.pushed_authorization_request_endpoint, body); 373 if (json.error) throw new Error("PAR error: " + JSON.stringify(json)); 374 375 const authUrl = new URL(meta.authorization_endpoint); 376 authUrl.searchParams.set("client_id", config.clientId); 377 authUrl.searchParams.set("request_uri", json.request_uri); 378 location.href = authUrl.href; 379} 380 381/** 382 * @param {URL} url 383 * @param {string} redirectUri 384 */ 385function matchesRedirectUri(url, redirectUri) { 386 const expected = new URL(redirectUri); 387 if (url.origin !== expected.origin || url.pathname !== expected.pathname) return false; 388 389 for (const [key, value] of expected.searchParams) { 390 if (url.searchParams.get(key) !== value) return false; 391 } 392 393 return true; 394} 395 396/** 397 * Run the OAuth callback handler. 398 * 399 * @param {AttemptSession} attempt 400 * @param {string} code 401 * @param {string} state 402 */ 403async function callback(attempt, code, state) { 404 const scope = attempt.scope ?? (await currentScope()); 405 const body = new URLSearchParams({ 406 grant_type: "authorization_code", 407 code, 408 redirect_uri: attempt.config.redirectUri, 409 client_id: attempt.config.clientId, 410 code_verifier: attempt.verifier, 411 }); 412 413 const { json, dpopNonce } = await dpopPost(attempt.dpopKey, attempt.tokenEndpoint, body); 414 if (json.error || !json.access_token) 415 return new Response("token error: " + JSON.stringify(json), { status: 400 }); 416 417 /** @type {OAuthSession} */ 418 const session = { 419 pds: attempt.pds, 420 did: attempt.did, 421 scope, 422 accessToken: json.access_token, 423 refreshToken: json.refresh_token, 424 dpopKey: attempt.dpopKey, 425 dpopNonce, 426 tokenEndpoint: attempt.tokenEndpoint, 427 clientId: attempt.config.clientId, 428 expiresAt: Date.now() + (json.expires_in ?? DEFAULT_TOKEN_TTL) * 1000, 429 }; 430 await putSession(session); 431 await deleteAttempt(state); 432 433 const dest = new URL(attempt.config.redirectUri); 434 return Response.redirect(dest.href, 302); 435} 436 437/** 438 * Given a request, run the OAuth callback handler if it matches a live login 439 * attempt. Returns null when the request is not an OAuth callback. 440 * 441 * @param {Request} req 442 * @returns {Promise<Response | null>} 443 */ 444export async function handleCallback(req) { 445 const url = new URL(req.url); 446 const state = url.searchParams.get("state"); 447 const code = url.searchParams.get("code"); 448 if (!state || !code) return null; 449 450 const attempt = await getAttempt(state); 451 if (!attempt || !matchesRedirectUri(url, attempt.config.redirectUri)) return null; 452 453 return callback(attempt, code, state); 454} 455 456/** @type {Map<string, Promise<OAuthSession>>} */ 457const refreshLocks = new Map(); 458 459/** @param {OAuthSession} session */ 460const sessionLockKey = (session) => `${session.scope}\n${session.did}`; 461 462/** @param {OAuthSession} session */ 463async function ensureFresh(session) { 464 if (session.accessToken && session.expiresAt > Date.now()) return session; 465 if (!session.refreshToken) return session; 466 467 // see if this session is already being refreshed 468 const key = sessionLockKey(session); 469 const lock = refreshLocks.get(key); 470 if (lock) return lock; 471 472 // lock the DID 473 /** @type {PromiseWithResolvers<OAuthSession>} */ 474 const { promise, resolve, reject } = Promise.withResolvers(); 475 refreshLocks.set(key, promise); 476 477 try { 478 // refresh the session 479 const { tokenEndpoint, dpopKey, refreshToken: refresh_token, clientId: client_id } = session; 480 const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token, client_id }); 481 const { json, dpopNonce } = await dpopPost(dpopKey, tokenEndpoint, body, session.dpopNonce); 482 if (json.error || !json.access_token) throw new Error("Refresh error: " + JSON.stringify(json)); 483 484 session.accessToken = json.access_token; 485 session.expiresAt = Date.now() + (json.expires_in ?? DEFAULT_TOKEN_TTL) * 1000; 486 if (json.refresh_token) session.refreshToken = json.refresh_token; 487 session.dpopNonce = dpopNonce; 488 489 await putSession(session); 490 resolve(session); 491 return session; 492 } catch (e) { 493 reject(e); 494 throw e; 495 } finally { 496 // release the lock 497 refreshLocks.delete(key); 498 } 499} 500 501/** @param {Request} req */ 502export async function authedFetch(req) { 503 const url = new URL(req.url); 504 const did = req.headers.get(DID_HEADER); 505 if (!did && !url.pathname.startsWith("/xrpc/")) return fetch(req); 506 507 const scope = await currentScope(); 508 509 /** @type {OAuthSession | undefined} */ 510 let session; 511 if (did) session = await getSessionForScope(scope, did); 512 else { 513 const sessions = await listSessionsByPDS(scope, url.origin); 514 if (sessions.length > 1) 515 throw new Error(`Multiple sessions for ${url.origin}; set "x-atsw-did" header`); 516 session = sessions[0]; 517 } 518 519 if (!session) return fetch(req); 520 session = await ensureFresh(session); 521 522 const htu = url.origin + url.pathname; 523 const htm = req.method; 524 525 let res = new Response(); 526 for (let attempt = 0; attempt < MAX_DPOP_RETRIES; attempt++) { 527 if (attempt > 0) session = (await getSessionForScope(scope, session.did)) ?? session; 528 529 const accessToken = session.accessToken; 530 const dpopNonce = session.dpopNonce; 531 const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(accessToken))); 532 const dpop = await createDPoP(session.dpopKey, htm, htu, dpopNonce, ath); 533 534 const headers = new Headers(req.headers); 535 headers.delete(DID_HEADER); 536 headers.set("authorization", `DPoP ${accessToken}`); 537 headers.set("dpop", dpop); 538 539 res = await fetch(new Request(req.clone(), { headers })); 540 const nonce = res.headers.get("dpop-nonce"); 541 if (nonce && nonce !== dpopNonce) { 542 session.dpopNonce = nonce; 543 await putSession(session); 544 } 545 546 if (res.status !== 401) break; 547 } 548 549 return /** @type {Response} */ (res); 550} 551 552/** 553 * Given a request, run the OAuth callback handler if it matches a live login 554 * attempt, pass navigations through, or attempt an authenticated fetch request. 555 * 556 * @param {Request} req 557 */ 558export async function handleFetch(req) { 559 const res = await handleCallback(req); 560 if (res) return res; 561 562 if (req.mode === "navigate") return fetch(req); 563 return authedFetch(req); 564} 565 566// if this is running in a service worker, configure it to handle 567// OAuth callbacks and to proxy authenticated requests to the PDS 568const sw = globalThis; 569if ( 570 typeof ServiceWorkerGlobalScope !== "undefined" && 571 sw instanceof ServiceWorkerGlobalScope && 572 import.meta.url === sw.location.href 573) { 574 sw.oninstall = () => sw.skipWaiting(); 575 sw.onactivate = (e) => e.waitUntil(sw.clients.claim()); 576 sw.onfetch = (e) => e.respondWith(handleFetch(e.request)); 577}