a proof of concept realtime collaborative todo list
0

Configure Feed

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

atodo / atsw.js
18 kB 575 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 = "sessions"; 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 if (!db.objectStoreNames.contains("attempts")) 161 db.createObjectStore("attempts", { keyPath: "state" }); 162 163 const keyPath = ["scope", "did"]; 164 let ssns; 165 if (db.objectStoreNames.contains(SESSIONS_STORE)) { 166 ssns = tx.objectStore(SESSIONS_STORE); 167 if (JSON.stringify(ssns.keyPath) !== JSON.stringify(keyPath)) { 168 db.deleteObjectStore(SESSIONS_STORE); 169 ssns = db.createObjectStore(SESSIONS_STORE, { keyPath }); 170 } 171 } else { 172 ssns = db.createObjectStore(SESSIONS_STORE, { keyPath }); 173 } 174 175 if (!ssns.indexNames.contains("scope")) ssns.createIndex("scope", "scope", { unique: false }); 176 if (!ssns.indexNames.contains("scopePds")) 177 ssns.createIndex("scopePds", ["scope", "pds"], { unique: false }); 178 }; 179 req.onsuccess = () => resolve(req.result); 180 req.onerror = () => { 181 dbPromise = null; 182 reject(req.error); 183 }; 184 }); 185 186 return dbPromise; 187} 188 189/** 190 * @param {IDBTransactionMode} mode 191 * @param {string} store 192 * @param {(s: IDBObjectStore) => IDBRequest} fn 193 * @returns {Promise<any>} 194 */ 195async function idb(mode, store, fn) { 196 const db = await openDb(); 197 return new Promise((resolve, reject) => { 198 const tx = db.transaction(store, mode); 199 const req = fn(tx.objectStore(store)); 200 req.onsuccess = () => resolve(req.result); 201 req.onerror = () => reject(req.error); 202 }); 203} 204 205/** @param {AttemptSession} v */ 206const putAttempt = (v) => idb("readwrite", "attempts", (s) => s.put(v)); 207 208/** @param {string} state @returns {Promise<AttemptSession | undefined>} */ 209const getAttempt = (state) => idb("readonly", "attempts", (s) => s.get(state)); 210 211/** @param {string} state */ 212const deleteAttempt = (state) => idb("readwrite", "attempts", (s) => s.delete(state)); 213 214/** @param {OAuthSession} v */ 215const putSession = (v) => idb("readwrite", SESSIONS_STORE, (s) => s.put(v)); 216 217/** @returns {ServiceWorkerGlobalScope | undefined} */ 218function getServiceWorker() { 219 if ( 220 typeof ServiceWorkerGlobalScope === "undefined" || 221 !(globalThis instanceof ServiceWorkerGlobalScope) 222 ) 223 return; 224 return /** @type {ServiceWorkerGlobalScope} */ (globalThis); 225} 226 227/** @returns {Promise<string>} */ 228async function currentScope() { 229 const sw = getServiceWorker(); 230 if (sw) return sw.registration.scope; 231 232 const nav = /** @type {Navigator & { serviceWorker?: ServiceWorkerContainer }} */ ( 233 globalThis.navigator 234 ); 235 const registration = await nav.serviceWorker?.getRegistration(); 236 if (!registration) throw new Error("register a service worker before using sessions"); 237 return registration.scope; 238} 239 240/** @returns {Promise<OAuthSession[]>} */ 241export const listSessions = async () => { 242 const scope = await currentScope(); 243 return idb("readonly", SESSIONS_STORE, (s) => s.index("scope").getAll(scope)); 244}; 245 246/** @param {string} scope @param {string} did @returns {Promise<OAuthSession | undefined>} */ 247const readSession = (scope, did) => idb("readonly", SESSIONS_STORE, (s) => s.get([scope, did])); 248 249/** @param {string} did @returns {Promise<OAuthSession | undefined>} */ 250export const getSession = async (did) => readSession(await currentScope(), did); 251 252/** @param {string} scope @param {string} pds @returns {Promise<OAuthSession[]>} */ 253const listSessionsByPDS = (scope, pds) => 254 idb("readonly", SESSIONS_STORE, (s) => s.index("scopePds").getAll([scope, pds])); 255 256/** @param {string} did */ 257export const logOut = async (did) => { 258 const scope = await currentScope(); 259 return idb("readwrite", SESSIONS_STORE, (s) => s.delete([scope, did])); 260}; 261 262/** @param {string} handle */ 263export async function resolveDID(handle) { 264 // try to resolve DID using the DNS record 265 try { 266 const r = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`); 267 const j = await r.json(); 268 const txt = j.Answer?.find(/** @param {any} a */ (a) => a.data?.startsWith('"did=')); 269 if (txt) return /** @type {string} */ (txt.data.replace(/"/g, "").replace("did=", "")); 270 } catch {} 271 272 // HTTP .well-known resolution is blocked by CORS from the browser, so fall 273 // back to a public AppView which exposes a CORS-enabled resolver. 274 const r = await fetch( 275 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`, 276 ); 277 const j = await r.json(); 278 if (!j.did) throw new Error(`Could not resolve handle ${handle}: ${JSON.stringify(j)}`); 279 return /** @type {string} */ (j.did); 280} 281 282/** @param {string} did */ 283async function resolvePDS(did) { 284 // find URL of DID doc 285 const url = did.startsWith("did:web:") 286 ? `https://${did.split(":")[2]}/.well-known/did.json` 287 : `https://plc.directory/${did}`; 288 289 /** @type {{ service?: { type: string; serviceEndpoint: string }[] }} */ 290 const doc = await fetch(url).then((res) => res.json()); 291 292 // get service endpoint 293 const endpoint = doc.service?.find( 294 ({ type }) => type === "AtprotoPersonalDataServer", 295 )?.serviceEndpoint; 296 if (!endpoint) throw new Error(`No PDS found for ${did}`); 297 298 return endpoint; 299} 300 301/** 302 * @param {string} pds 303 * @returns {Promise<AuthServerMetadata>} 304 */ 305async function discoverAuthServer(pds) { 306 /** @type {AuthServerMetadata | undefined} */ 307 let meta; 308 try { 309 const res = await fetch(`${pds}/.well-known/oauth-protected-resource`).then((res) => 310 res.json(), 311 ); 312 const issuer = /** @type {string} */ (res.authorization_servers[0]); 313 meta = await fetch(`${issuer}/.well-known/oauth-authorization-server`).then((res) => 314 res.json(), 315 ); 316 } catch { 317 meta = await fetch(`${pds}/.well-known/oauth-authorization-server`).then((res) => res.json()); 318 } 319 320 if ( 321 !meta?.authorization_endpoint || 322 !meta.token_endpoint || 323 !meta.pushed_authorization_request_endpoint 324 ) { 325 throw new Error(`Could not discover OAuth endpoints for ${pds}`); 326 } 327 328 return meta; 329} 330 331/** 332 * Start the OAuth login flow. Stores an attempt session in IndexedDB and 333 * redirects the browser to the authorization server. When the auth server 334 * redirects back, the service worker will intercept the callback and complete 335 * the token exchange. 336 * @param {OAuthConfig} config 337 * @param {string} handle 338 * @returns {Promise<void>} 339 */ 340export async function logIn(config, handle) { 341 const scope = await currentScope(); 342 const did = await resolveDID(handle); 343 const pds = await resolvePDS(did); 344 const [meta, pkce, dpopKey] = await Promise.all([ 345 discoverAuthServer(pds), 346 generatePKCE(), 347 generateDPoPKey(), 348 ]); 349 const state = randomB64url(16); 350 351 await putAttempt({ 352 state, 353 verifier: pkce.verifier, 354 dpopKey, 355 tokenEndpoint: meta.token_endpoint, 356 issuer: meta.issuer, 357 did, 358 pds: new URL(pds).origin, 359 config, 360 scope, 361 }); 362 363 const body = new URLSearchParams({ 364 client_id: config.clientId, 365 redirect_uri: config.redirectUri, 366 response_type: "code", 367 scope: config.scope, 368 state, 369 code_challenge: pkce.challenge, 370 code_challenge_method: "S256", 371 login_hint: handle, 372 }); 373 374 const { json } = await dpopPost(dpopKey, meta.pushed_authorization_request_endpoint, body); 375 if (json.error) throw new Error("PAR error: " + JSON.stringify(json)); 376 377 const authUrl = new URL(meta.authorization_endpoint); 378 authUrl.searchParams.set("client_id", config.clientId); 379 authUrl.searchParams.set("request_uri", json.request_uri); 380 location.href = authUrl.href; 381} 382 383/** 384 * @param {URL} url 385 * @param {string} redirectUri 386 */ 387function matchesRedirectUri(url, redirectUri) { 388 const expected = new URL(redirectUri); 389 if (url.origin !== expected.origin || url.pathname !== expected.pathname) return false; 390 391 for (const [key, value] of expected.searchParams) { 392 if (url.searchParams.get(key) !== value) return false; 393 } 394 395 return true; 396} 397 398/** 399 * Run the OAuth callback handler. 400 * 401 * @param {AttemptSession} attempt 402 * @param {string} code 403 * @param {string} state 404 */ 405async function callback(attempt, code, state) { 406 const scope = attempt.scope ?? (await currentScope()); 407 const body = new URLSearchParams({ 408 grant_type: "authorization_code", 409 code, 410 redirect_uri: attempt.config.redirectUri, 411 client_id: attempt.config.clientId, 412 code_verifier: attempt.verifier, 413 }); 414 415 const { json, dpopNonce } = await dpopPost(attempt.dpopKey, attempt.tokenEndpoint, body); 416 if (json.error || !json.access_token) 417 return new Response("token error: " + JSON.stringify(json), { status: 400 }); 418 419 /** @type {OAuthSession} */ 420 const session = { 421 pds: attempt.pds, 422 did: attempt.did, 423 scope, 424 accessToken: json.access_token, 425 refreshToken: json.refresh_token, 426 dpopKey: attempt.dpopKey, 427 dpopNonce, 428 tokenEndpoint: attempt.tokenEndpoint, 429 clientId: attempt.config.clientId, 430 expiresAt: Date.now() + (json.expires_in ?? DEFAULT_TOKEN_TTL) * 1000, 431 }; 432 await putSession(session); 433 await deleteAttempt(state); 434 435 const dest = new URL(attempt.config.redirectUri); 436 return Response.redirect(dest.href, 302); 437} 438 439/** 440 * Given a request, run the OAuth callback handler if it matches a live login 441 * attempt. Returns null when the request is not an OAuth callback. 442 * 443 * @param {Request} req 444 * @returns {Promise<Response | null>} 445 */ 446export async function handleCallback(req) { 447 const url = new URL(req.url); 448 const state = url.searchParams.get("state"); 449 const code = url.searchParams.get("code"); 450 if (!state || !code) return null; 451 452 const attempt = await getAttempt(state); 453 if (!attempt || !matchesRedirectUri(url, attempt.config.redirectUri)) return null; 454 455 return callback(attempt, code, state); 456} 457 458/** @type {Map<string, Promise<OAuthSession>>} */ 459const refreshLocks = new Map(); 460 461/** @param {OAuthSession} session */ 462const sessionLockKey = (session) => `${session.scope}\n${session.did}`; 463 464/** @param {OAuthSession} session */ 465async function ensureFresh(session) { 466 if (session.accessToken && session.expiresAt > Date.now()) return session; 467 if (!session.refreshToken) return session; 468 469 // see if this session is already being refreshed 470 const key = sessionLockKey(session); 471 const lock = refreshLocks.get(key); 472 if (lock) return lock; 473 474 // lock the DID 475 /** @type {PromiseWithResolvers<OAuthSession>} */ 476 const { promise, resolve, reject } = Promise.withResolvers(); 477 refreshLocks.set(key, promise); 478 479 try { 480 // refresh the session 481 const { tokenEndpoint, dpopKey, refreshToken: refresh_token, clientId: client_id } = session; 482 const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token, client_id }); 483 const { json, dpopNonce } = await dpopPost(dpopKey, tokenEndpoint, body, session.dpopNonce); 484 if (json.error || !json.access_token) throw new Error("Refresh error: " + JSON.stringify(json)); 485 486 session.accessToken = json.access_token; 487 session.expiresAt = Date.now() + (json.expires_in ?? DEFAULT_TOKEN_TTL) * 1000; 488 if (json.refresh_token) session.refreshToken = json.refresh_token; 489 session.dpopNonce = dpopNonce; 490 491 await putSession(session); 492 resolve(session); 493 return session; 494 } catch (e) { 495 reject(e); 496 throw e; 497 } finally { 498 // release the lock 499 refreshLocks.delete(key); 500 } 501} 502 503/** @param {Request} req */ 504export async function authedFetch(req) { 505 const url = new URL(req.url); 506 const did = req.headers.get(DID_HEADER); 507 if (!did && !url.pathname.startsWith("/xrpc/")) return fetch(req); 508 509 const scope = await currentScope(); 510 511 /** @type {OAuthSession | undefined} */ 512 let session; 513 if (did) session = await readSession(scope, did); 514 else { 515 const sessions = await listSessionsByPDS(scope, url.origin); 516 if (sessions.length > 1) 517 throw new Error(`Multiple sessions for ${url.origin}; set "x-atsw-did" header`); 518 session = sessions[0]; 519 } 520 521 if (!session) return fetch(req); 522 session = await ensureFresh(session); 523 524 const htu = url.origin + url.pathname; 525 const htm = req.method; 526 527 let res = new Response(); 528 for (let attempt = 0; attempt < MAX_DPOP_RETRIES; attempt++) { 529 if (attempt > 0) session = (await readSession(scope, session.did)) ?? session; 530 531 const accessToken = session.accessToken; 532 const dpopNonce = session.dpopNonce; 533 const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(accessToken))); 534 const dpop = await createDPoP(session.dpopKey, htm, htu, dpopNonce, ath); 535 536 const headers = new Headers(req.headers); 537 headers.delete(DID_HEADER); 538 headers.set("authorization", `DPoP ${accessToken}`); 539 headers.set("dpop", dpop); 540 541 res = await fetch(new Request(req.clone(), { headers })); 542 const nonce = res.headers.get("dpop-nonce"); 543 if (nonce && nonce !== dpopNonce) { 544 session.dpopNonce = nonce; 545 await putSession(session); 546 } 547 548 if (res.status !== 401) break; 549 } 550 551 return /** @type {Response} */ (res); 552} 553 554/** 555 * Given a request, run the OAuth callback handler if it matches a live login 556 * attempt, pass navigations through, or attempt an authenticated fetch request. 557 * 558 * @param {Request} req 559 */ 560export async function handleFetch(req) { 561 const res = await handleCallback(req); 562 if (res) return res; 563 564 if (req.mode === "navigate") return fetch(req); 565 return authedFetch(req); 566} 567 568// if this is running in a service worker, set it up to handle 569// OAuth callbacks and to proxy authenticated requests to the PDS 570const sw = getServiceWorker(); 571if (sw && import.meta.url === sw.location.href) { 572 sw.oninstall = () => sw.skipWaiting(); 573 sw.onactivate = (e) => e.waitUntil(sw.clients.claim()); 574 sw.onfetch = (e) => e.respondWith(handleFetch(e.request)); 575}