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
15 kB 452 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 */ 31 32/** 33 * @typedef {Object} OAuthSession 34 * @property {string} pds 35 * @property {string} did 36 * @property {string} accessToken 37 * @property {DPoPKey} dpopKey 38 * @property {string} tokenEndpoint 39 * @property {string} clientId 40 * @property {number} expiresAt 41 * @property {string} [refreshToken] 42 * @property {string} [dpopNonce] 43 */ 44 45/** 46 * @typedef {Object} AuthServerMetadata 47 * @property {string} issuer 48 * @property {string} authorization_endpoint 49 * @property {string} token_endpoint 50 * @property {string} pushed_authorization_request_endpoint 51 */ 52 53const enc = new TextEncoder(); 54 55/** @param {ArrayBuffer | Uint8Array} buf */ 56const b64url = (buf) => 57 btoa(String.fromCharCode(...new Uint8Array(buf))) 58 .replace(/\+/g, "-") 59 .replace(/\//g, "_") 60 .replace(/=+$/, ""); 61 62/** @param {number} n */ 63const randomB64url = (n) => b64url(crypto.getRandomValues(new Uint8Array(n)).buffer); 64 65async function generatePKCE() { 66 const verifier = randomB64url(32); 67 const challenge = b64url(await crypto.subtle.digest("SHA-256", enc.encode(verifier))); 68 return { verifier, challenge }; 69} 70 71async function generateDPoPKey() { 72 const key = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"]); 73 const jwk = await crypto.subtle.exportKey("jwk", key.publicKey); 74 75 return { privateKey: key.privateKey, jwk }; 76} 77 78/** 79 * @param {DPoPKey} dpopKey 80 * @param {string} htm 81 * @param {string} htu 82 * @param {string} [nonce] 83 * @param {string} [ath] 84 */ 85async function createDPoP({ privateKey, jwk }, htm, htu, nonce, ath) { 86 const header = { alg: "ES256", typ: "dpop+jwt", jwk }; 87 88 /** @type {Record<string, string | number>} */ 89 const payload = { htm, htu, jti: randomB64url(16), iat: Math.floor(Date.now() / 1000) }; 90 if (nonce) payload["nonce"] = nonce; 91 if (ath) payload["ath"] = ath; 92 93 const toSign = [header, payload] 94 .map((part) => JSON.stringify(part)) 95 .map((part) => enc.encode(part)) 96 .map((part) => b64url(part.buffer)) 97 .join("."); 98 const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, enc.encode(toSign)); 99 100 return toSign + "." + b64url(sig); 101} 102 103const MAX_DPOP_RETRIES = 2; 104const DEFAULT_TOKEN_TTL = 3600; 105const DID_HEADER = "x-atsw-did"; 106 107/** 108 * @param {DPoPKey} key 109 * @param {string} url 110 * @param {URLSearchParams} body 111 * @param {string} [nonce] 112 */ 113async function dpopPost(key, url, body, nonce) { 114 let dpopNonce = nonce; 115 for (let attempts = 0; attempts < MAX_DPOP_RETRIES; attempts++) { 116 const dpop = await createDPoP(key, "POST", url, dpopNonce); 117 const res = await fetch(url, { 118 method: "POST", 119 headers: { "content-type": "application/x-www-form-urlencoded", DPoP: dpop }, 120 body, 121 }); 122 123 const newNonce = res.headers.get("dpop-nonce"); 124 const nonceChanged = newNonce && newNonce !== dpopNonce; 125 dpopNonce = newNonce ?? dpopNonce; 126 127 if (nonceChanged && 400 <= res.status && res.status <= 499) continue; 128 129 return { json: await res.json(), dpopNonce }; 130 } 131 132 throw new Error("DPoP nonce retry failed"); 133} 134 135const DB_NAME = "atproto:oauth"; 136const DB_VERSION = 4; 137 138/** @type {Promise<IDBDatabase> | null} */ 139let dbPromise = null; 140 141/** @returns {Promise<IDBDatabase>} */ 142function openDb() { 143 if (dbPromise) return dbPromise; 144 dbPromise = new Promise((resolve, reject) => { 145 const req = indexedDB.open(DB_NAME, DB_VERSION); 146 req.onupgradeneeded = () => { 147 const db = req.result; 148 if (!db.objectStoreNames.contains("attempts")) db.createObjectStore("attempts", { keyPath: "state" }); 149 150 if (db.objectStoreNames.contains("sessions")) db.deleteObjectStore("sessions"); 151 const ssns = db.createObjectStore("sessions", { keyPath: "did" }); 152 ssns.createIndex("pds", "pds", { unique: false }); 153 }; 154 req.onsuccess = () => resolve(req.result); 155 req.onerror = () => { 156 dbPromise = null; 157 reject(req.error); 158 }; 159 }); 160 161 return dbPromise; 162} 163 164/** 165 * @param {IDBTransactionMode} mode 166 * @param {string} store 167 * @param {(s: IDBObjectStore) => IDBRequest} fn 168 * @returns {Promise<any>} 169 */ 170async function idb(mode, store, fn) { 171 const db = await openDb(); 172 return new Promise((resolve, reject) => { 173 const tx = db.transaction(store, mode); 174 const req = fn(tx.objectStore(store)); 175 req.onsuccess = () => resolve(req.result); 176 req.onerror = () => reject(req.error); 177 }); 178} 179 180/** @param {AttemptSession} v */ 181const putAttempt = (v) => idb("readwrite", "attempts", (s) => s.put(v)); 182 183/** @param {string} state @returns {Promise<AttemptSession | undefined>} */ 184const getAttempt = (state) => idb("readonly", "attempts", (s) => s.get(state)); 185 186/** @param {string} state */ 187const deleteAttempt = (state) => idb("readwrite", "attempts", (s) => s.delete(state)); 188 189/** @param {OAuthSession} v */ 190const putSession = (v) => idb("readwrite", "sessions", (s) => s.put(v)); 191 192/** @returns {Promise<OAuthSession[]>} */ 193export const listSessions = () => idb("readonly", "sessions", (s) => s.getAll()); 194 195/** @param {string} did @returns {Promise<OAuthSession | undefined>} */ 196export const getSession = (did) => idb("readonly", "sessions", (s) => s.get(did)); 197 198/** @param {string} pds @returns {Promise<OAuthSession[]>} */ 199const listSessionsByPDS = (pds) => idb("readonly", "sessions", (s) => s.index("pds").getAll(pds)); 200 201/** @param {string} did */ 202export const logOut = (did) => idb("readwrite", "sessions", (s) => s.delete(did)); 203 204/** @param {string} handle */ 205export async function resolveDID(handle) { 206 // try to resolve DID using the DNS record 207 try { 208 const r = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`); 209 const j = await r.json(); 210 const txt = j.Answer?.find(/** @param {any} a */ (a) => a.data?.startsWith('"did=')); 211 if (txt) return /** @type {string} */ (txt.data.replace(/"/g, "").replace("did=", "")); 212 } catch {} 213 214 // HTTP .well-known resolution is blocked by CORS from the browser, so fall 215 // back to a public AppView which exposes a CORS-enabled resolver. 216 const r = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`); 217 const j = await r.json(); 218 if (!j.did) throw new Error(`Could not resolve handle ${handle}: ${JSON.stringify(j)}`); 219 return /** @type {string} */ (j.did); 220} 221 222/** @param {string} did */ 223async function resolvePDS(did) { 224 // find URL of DID doc 225 const url = did.startsWith("did:web:") 226 ? `https://${did.split(":")[2]}/.well-known/did.json` 227 : `https://plc.directory/${did}`; 228 229 /** @type {{ service?: { type: string; serviceEndpoint: string }[] }} */ 230 const doc = await fetch(url).then((res) => res.json()); 231 232 // get service endpoint 233 const endpoint = doc.service?.find(({ type }) => type === "AtprotoPersonalDataServer")?.serviceEndpoint; 234 if (!endpoint) throw new Error(`No PDS found for ${did}`); 235 236 return endpoint; 237} 238 239/** 240 * @param {string} pds 241 * @returns {Promise<AuthServerMetadata>} 242 */ 243async function discoverAuthServer(pds) { 244 try { 245 const res = await fetch(`${pds}/.well-known/oauth-protected-resource`).then((res) => res.json()); 246 const issuer = /** @type {string} */ (res.authorization_servers[0]); 247 return await fetch(`${issuer}/.well-known/oauth-authorization-server`).then((res) => res.json()); 248 } catch { 249 return await fetch(`${pds}/.well-known/oauth-authorization-server`).then((res) => res.json()); 250 } 251} 252 253/** 254 * Start the OAuth login flow. Stores an attempt session in IndexedDB and 255 * redirects the browser to the authorization server. When the auth server 256 * redirects back, the service worker will intercept the callback and complete 257 * the token exchange. 258 * @param {OAuthConfig} config 259 * @param {string} handle 260 * @returns {Promise<void>} 261 */ 262export async function logIn(config, handle) { 263 const did = await resolveDID(handle); 264 const pds = await resolvePDS(did); 265 const [meta, pkce, dpopKey] = await Promise.all([discoverAuthServer(pds), generatePKCE(), generateDPoPKey()]); 266 const state = randomB64url(16); 267 268 await putAttempt({ 269 state, 270 verifier: pkce.verifier, 271 dpopKey, 272 tokenEndpoint: meta.token_endpoint, 273 issuer: meta.issuer, 274 did, 275 pds: new URL(pds).origin, 276 config, 277 }); 278 279 const body = new URLSearchParams({ 280 client_id: config.clientId, 281 redirect_uri: config.redirectUri, 282 response_type: "code", 283 scope: config.scope, 284 state, 285 code_challenge: pkce.challenge, 286 code_challenge_method: "S256", 287 login_hint: handle, 288 }); 289 290 const { json } = await dpopPost(dpopKey, meta.pushed_authorization_request_endpoint, body); 291 if (json.error) throw new Error("PAR error: " + JSON.stringify(json)); 292 293 const authUrl = new URL(meta.authorization_endpoint); 294 authUrl.searchParams.set("client_id", config.clientId); 295 authUrl.searchParams.set("request_uri", json.request_uri); 296 location.href = authUrl.href; 297} 298 299// if this is running in a service worker, configure it to handle 300// OAuth callbacks and to proxy authenticated requests to the PDS 301const sw = globalThis; 302if (typeof ServiceWorkerGlobalScope !== "undefined" && sw instanceof ServiceWorkerGlobalScope) { 303 /** 304 * Given a request, run the OAuth callback handler (if the login attempt matches) 305 * or attempt an authenticated fetch request. 306 * 307 * @param {Request} req 308 */ 309 async function handleFetch(req) { 310 // if the request URL doesn't contain both `state` and `code` URL search parameters, 311 // it can't be a callback; attempt an authenticated fetch 312 const url = new URL(req.url); 313 const state = url.searchParams.get("state"); 314 const code = url.searchParams.get("code"); 315 if (!state || !code) return authedFetch(req); 316 317 // if there's no authentication attempt whose redirect URI matches the request URL, 318 // it can't be a callback; attempt an authenticated fetch 319 const attempt = await getAttempt(state); 320 if (!attempt || !url.href.startsWith(attempt.config.redirectUri)) return authedFetch(req); 321 322 // otherwise, run the callback handler 323 return callback(attempt, code, state); 324 } 325 326 /** 327 * Run the OAuth callback handler. 328 * 329 * @param {AttemptSession} attempt 330 * @param {string} code 331 * @param {string} state 332 */ 333 async function callback(attempt, code, state) { 334 const body = new URLSearchParams({ 335 grant_type: "authorization_code", 336 code, 337 redirect_uri: attempt.config.redirectUri, 338 client_id: attempt.config.clientId, 339 code_verifier: attempt.verifier, 340 }); 341 342 const { json, dpopNonce } = await dpopPost(attempt.dpopKey, attempt.tokenEndpoint, body); 343 if (json.error) return new Response("token error: " + JSON.stringify(json), { status: 400 }); 344 345 /** @type {OAuthSession} */ 346 const session = { 347 pds: attempt.pds, 348 did: attempt.did, 349 accessToken: json.access_token, 350 refreshToken: json.refresh_token, 351 dpopKey: attempt.dpopKey, 352 dpopNonce, 353 tokenEndpoint: attempt.tokenEndpoint, 354 clientId: attempt.config.clientId, 355 expiresAt: Date.now() + (json.expires_in ?? DEFAULT_TOKEN_TTL) * 1000, 356 }; 357 await putSession(session); 358 await deleteAttempt(state); 359 360 const dest = new URL(attempt.config.redirectUri); 361 return Response.redirect(dest.href, 302); 362 } 363 364 /** @type {Map<string, Promise<OAuthSession>>} */ 365 const refreshLocks = new Map(); 366 367 /** @param {OAuthSession} session */ 368 async function ensureFresh(session) { 369 if (session.expiresAt > Date.now() || !session.refreshToken) return session; 370 371 // see if this session is already being refreshed 372 const lock = refreshLocks.get(session.did); 373 if (lock) return lock; 374 375 // lock the DID 376 /** @type {PromiseWithResolvers<OAuthSession>} */ 377 const { promise, resolve, reject } = Promise.withResolvers(); 378 refreshLocks.set(session.did, promise); 379 380 try { 381 // refresh the session 382 const { tokenEndpoint, dpopKey, refreshToken: refresh_token, clientId: client_id } = session; 383 const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token, client_id }); 384 const { json, dpopNonce } = await dpopPost(dpopKey, tokenEndpoint, body, session.dpopNonce); 385 if (json.error) throw new Error("Refresh error: " + JSON.stringify(json)); 386 387 session.accessToken = json.access_token; 388 session.expiresAt = Date.now() + (json.expires_in ?? DEFAULT_TOKEN_TTL) * 1000; 389 if (json.refresh_token) session.refreshToken = json.refresh_token; 390 session.dpopNonce = dpopNonce; 391 392 await putSession(session); 393 resolve(session); 394 return session; 395 } catch (e) { 396 reject(e); 397 throw e; 398 } finally { 399 // release the lock 400 refreshLocks.delete(session.did); 401 } 402 } 403 404 /** @param {Request} req */ 405 async function authedFetch(req) { 406 const url = new URL(req.url); 407 const did = req.headers.get(DID_HEADER); 408 409 /** @type {OAuthSession | undefined} */ 410 let session; 411 if (did) session = await getSession(did); 412 else { 413 const sessions = await listSessionsByPDS(url.origin); 414 if (sessions.length > 1) throw new Error(`Multiple sessions for ${url.origin}; set "x-atsw-did" header`); 415 session = sessions[0]; 416 } 417 418 if (!session) return fetch(req); 419 session = await ensureFresh(session); 420 421 const htu = url.origin + url.pathname; 422 const htm = req.method; 423 424 let res = new Response(); 425 for (let attempt = 0; attempt < MAX_DPOP_RETRIES; attempt++) { 426 if (attempt > 0) session = (await getSession(session.did)) ?? session; 427 428 const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(session.accessToken))); 429 const dpop = await createDPoP(session.dpopKey, htm, htu, session.dpopNonce, ath); 430 431 const headers = new Headers(req.headers); 432 headers.delete(DID_HEADER); 433 headers.set("authorization", `DPoP ${session.accessToken}`); 434 headers.set("dpop", dpop); 435 436 res = await fetch(new Request(req.clone(), { headers })); 437 const nonce = res.headers.get("dpop-nonce"); 438 if (nonce && nonce !== session.dpopNonce) { 439 session.dpopNonce = nonce; 440 await putSession(session); 441 } 442 443 if (res.status !== 401) break; 444 } 445 446 return /** @type {Response} */ (res); 447 } 448 449 sw.oninstall = () => sw.skipWaiting(); 450 sw.onactivate = (e) => e.waitUntil(sw.clients.claim()); 451 sw.onfetch = (e) => e.respondWith(handleFetch(e.request)); 452}