a proof of concept realtime collaborative todo list
0

Configure Feed

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

Fix atsw?

+117 -33
+4 -2
app.js
··· 1 1 import { Component, render } from "preact"; 2 2 import { html } from "htm/preact"; 3 3 import { signal } from "@preact/signals"; 4 - import { logIn, listSessions, logOut } from "./atsw.js"; 4 + import { configure, logIn, listSessions, logOut } from "./atsw.js"; 5 5 import { 6 6 session, 7 7 listUri, ··· 31 31 "repo?collection=io.tngl.jake.atodo.tombstone", 32 32 }; 33 33 34 + configure(config); 35 + 34 36 const ready = signal(false); 35 37 36 38 class LoginForm extends Component { ··· 45 47 this.busy.value = true; 46 48 this.error.value = ""; 47 49 try { 48 - await logIn(config, handle); 50 + await logIn(handle); 49 51 } catch (err) { 50 52 this.error.value = err.message; 51 53 this.busy.value = false;
+2 -1
atproto.js
··· 74 74 try { 75 75 body = JSON.parse(text); 76 76 } catch {} 77 - const err = new Error(`${label} ${res.status}: ${body?.error ?? text}`); 77 + const detail = body ? [body.error, body.message].filter(Boolean).join(": ") : text; 78 + const err = new Error(`${label} ${res.status}: ${detail}`); 78 79 err.status = res.status; 79 80 err.atproto = body; 80 81 return err;
+111 -30
atsw.js
··· 100 100 return toSign + "." + b64url(sig); 101 101 } 102 102 103 - const MAX_DPOP_RETRIES = 2; 103 + // 3, not 2: recovering from a 401 that comes back with *no* fresh nonce 104 + // (server didn't think it owed us one) takes a full extra round trip — one 105 + // attempt to discover that, one to run the standard nonceless bootstrap 106 + // handshake, one to actually use the nonce that handshake returns. 107 + const MAX_DPOP_RETRIES = 3; 104 108 const DEFAULT_TOKEN_TTL = 3600; 105 109 const DID_HEADER = "x-atsw-did"; 106 110 ··· 133 137 } 134 138 135 139 const DB_NAME = "atproto:oauth"; 136 - const DB_VERSION = 4; 140 + const DB_VERSION = 5; 137 141 138 142 /** @type {Promise<IDBDatabase> | null} */ 139 143 let dbPromise = null; ··· 154 158 ? req.transaction.objectStore("sessions") 155 159 : db.createObjectStore("sessions", { keyPath: "did" }); 156 160 if (!ssns.indexNames.contains("pds")) ssns.createIndex("pds", "pds", { unique: false }); 161 + // IndexedDB is per-origin: a session belonging to a different OAuth 162 + // client (e.g. an older deploy at another path) can end up sharing this 163 + // store. Index on clientId so callers can ask for only their own. 164 + if (!ssns.indexNames.contains("clientId")) ssns.createIndex("clientId", "clientId", { unique: false }); 157 165 }; 158 166 req.onsuccess = () => resolve(req.result); 159 167 req.onerror = () => { ··· 194 202 const putSession = (v) => idb("readwrite", "sessions", (s) => s.put(v)); 195 203 196 204 /** @returns {Promise<OAuthSession[]>} */ 197 - export const listSessions = () => idb("readonly", "sessions", (s) => s.getAll()); 205 + export const listSessions = () => { 206 + if (!oauthConfig) throw new Error("call configure() before listSessions()"); 207 + return idb("readonly", "sessions", (s) => s.index("clientId").getAll(oauthConfig.clientId)); 208 + }; 198 209 199 210 /** @param {string} did @returns {Promise<OAuthSession | undefined>} */ 200 211 export const getSession = (did) => idb("readonly", "sessions", (s) => s.get(did)); ··· 245 256 * @returns {Promise<AuthServerMetadata>} 246 257 */ 247 258 async function discoverAuthServer(pds) { 259 + let meta; 248 260 try { 249 261 const res = await fetch(`${pds}/.well-known/oauth-protected-resource`).then((res) => res.json()); 250 262 const issuer = /** @type {string} */ (res.authorization_servers[0]); 251 - return await fetch(`${issuer}/.well-known/oauth-authorization-server`).then((res) => res.json()); 263 + meta = await fetch(`${issuer}/.well-known/oauth-authorization-server`).then((res) => res.json()); 252 264 } catch { 253 - return await fetch(`${pds}/.well-known/oauth-authorization-server`).then((res) => res.json()); 265 + meta = await fetch(`${pds}/.well-known/oauth-authorization-server`).then((res) => res.json()); 254 266 } 267 + 268 + // a failed/unexpected response can still be valid JSON (e.g. a 404 body), 269 + // so check for the fields we actually need rather than letting `undefined` 270 + // silently flow into a later `new URL(...)` call with a confusing error. 271 + if (!meta?.authorization_endpoint || !meta.token_endpoint || !meta.pushed_authorization_request_endpoint) { 272 + throw new Error(`Could not discover OAuth endpoints for ${pds}`); 273 + } 274 + 275 + return meta; 276 + } 277 + 278 + /** @type {OAuthConfig | null} */ 279 + let oauthConfig = null; 280 + 281 + /** @param {OAuthConfig} config */ 282 + export function configure(config) { 283 + oauthConfig = config; 255 284 } 256 285 257 286 /** ··· 259 288 * redirects the browser to the authorization server. When the auth server 260 289 * redirects back, the service worker will intercept the callback and complete 261 290 * the token exchange. 262 - * @param {OAuthConfig} config 263 291 * @param {string} handle 264 292 * @returns {Promise<void>} 265 293 */ 266 - export async function logIn(config, handle) { 294 + export async function logIn(handle) { 295 + if (!oauthConfig) throw new Error("call configure() before logIn()"); 296 + const config = oauthConfig; 297 + 267 298 const did = await resolveDID(handle); 268 299 const pds = await resolvePDS(did); 269 300 const [meta, pkce, dpopKey] = await Promise.all([discoverAuthServer(pds), generatePKCE(), generateDPoPKey()]); ··· 344 375 }); 345 376 346 377 const { json, dpopNonce } = await dpopPost(attempt.dpopKey, attempt.tokenEndpoint, body); 347 - if (json.error) return new Response("token error: " + JSON.stringify(json), { status: 400 }); 378 + if (json.error || !json.access_token) 379 + return new Response("token error: " + JSON.stringify(json), { status: 400 }); 348 380 349 381 /** @type {OAuthSession} */ 350 382 const session = { ··· 370 402 371 403 /** @param {OAuthSession} session */ 372 404 async function ensureFresh(session) { 373 - if (session.expiresAt > Date.now() || !session.refreshToken) return session; 405 + if (session.accessToken && session.expiresAt > Date.now()) return session; 406 + if (!session.refreshToken) return session; 374 407 375 408 // see if this session is already being refreshed 376 409 const lock = refreshLocks.get(session.did); ··· 386 419 const { tokenEndpoint, dpopKey, refreshToken: refresh_token, clientId: client_id } = session; 387 420 const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token, client_id }); 388 421 const { json, dpopNonce } = await dpopPost(dpopKey, tokenEndpoint, body, session.dpopNonce); 389 - if (json.error) throw new Error("Refresh error: " + JSON.stringify(json)); 422 + if (json.error || !json.access_token) throw new Error("Refresh error: " + JSON.stringify(json)); 390 423 391 424 session.accessToken = json.access_token; 392 425 session.expiresAt = Date.now() + (json.expires_in ?? DEFAULT_TOKEN_TTL) * 1000; ··· 405 438 } 406 439 } 407 440 408 - /** @param {Request} req */ 409 - async function authedFetch(req) { 410 - const url = new URL(req.url); 411 - const did = req.headers.get(DID_HEADER); 441 + /** @type {Map<string, Promise<any>>} */ 442 + const requestQueues = new Map(); 412 443 413 - /** @type {OAuthSession | undefined} */ 414 - let session; 415 - if (did) session = await getSession(did); 416 - else { 417 - const sessions = await listSessionsByPDS(url.origin); 418 - if (sessions.length > 1) throw new Error(`Multiple sessions for ${url.origin}; set "x-atsw-did" header`); 419 - session = sessions[0]; 420 - } 444 + /** 445 + * Serialize fetches per DID. The server hands out a fresh DPoP nonce on 446 + * every response, so it must be used in lockstep: if concurrent requests 447 + * (e.g. an offline-queue flush racing a user action right after the app 448 + * wakes from idle) all sign with the same stale nonce, only one can win 449 + * each round trip and the rest exhaust MAX_DPOP_RETRIES and fail outright. 450 + * @param {string} did 451 + * @param {() => Promise<Response>} fn 452 + */ 453 + function withRequestLock(did, fn) { 454 + const prev = requestQueues.get(did) ?? Promise.resolve(); 455 + const next = prev.then(fn, fn); 456 + requestQueues.set(did, next.catch(() => {})); 457 + return next; 458 + } 421 459 422 - if (!session) return fetch(req); 423 - session = await ensureFresh(session); 424 - 460 + /** 461 + * @param {Request} req 462 + * @param {OAuthSession} session 463 + */ 464 + async function doAuthedFetch(req, session) { 465 + const url = new URL(req.url); 425 466 const htu = url.origin + url.pathname; 426 467 const htm = req.method; 427 468 ··· 429 470 for (let attempt = 0; attempt < MAX_DPOP_RETRIES; attempt++) { 430 471 if (attempt > 0) session = (await getSession(session.did)) ?? session; 431 472 432 - const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(session.accessToken))); 433 - const dpop = await createDPoP(session.dpopKey, htm, htu, session.dpopNonce, ath); 473 + // snapshot once — `session` is a shared, mutable object (ensureFresh can 474 + // rewrite session.accessToken on it from a concurrent caller), so reading 475 + // it twice across the awaits below risks signing the proof's "ath" against 476 + // a different token than the one that ends up in the Authorization header. 477 + const accessToken = session.accessToken; 478 + const dpopNonce = session.dpopNonce; 479 + 480 + const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(accessToken))); 481 + const dpop = await createDPoP(session.dpopKey, htm, htu, dpopNonce, ath); 434 482 435 483 const headers = new Headers(req.headers); 436 484 headers.delete(DID_HEADER); 437 - headers.set("authorization", `DPoP ${session.accessToken}`); 485 + headers.set("authorization", `DPoP ${accessToken}`); 438 486 headers.set("dpop", dpop); 439 487 440 488 res = await fetch(new Request(req.clone(), { headers })); 441 489 const nonce = res.headers.get("dpop-nonce"); 442 - if (nonce && nonce !== session.dpopNonce) { 490 + if (res.status === 401) { 491 + // our proof was rejected, so the nonce we signed with (if any) is 492 + // proven bad — force-refresh it. Use whatever the server handed 493 + // back, or drop it entirely so the next attempt sends no nonce and 494 + // triggers the standard "use_dpop_nonce" bootstrap handshake. 495 + if (nonce !== dpopNonce) { 496 + session.dpopNonce = nonce || undefined; 497 + await putSession(session); 498 + } 499 + } else if (nonce && nonce !== dpopNonce) { 443 500 session.dpopNonce = nonce; 444 501 await putSession(session); 445 502 } ··· 447 504 if (res.status !== 401) break; 448 505 } 449 506 450 - return /** @type {Response} */ (res); 507 + return res; 508 + } 509 + 510 + /** @param {Request} req */ 511 + async function authedFetch(req) { 512 + const url = new URL(req.url); 513 + const did = req.headers.get(DID_HEADER); 514 + 515 + /** @type {OAuthSession | undefined} */ 516 + let session; 517 + if (did) session = await getSession(did); 518 + else if (url.pathname.startsWith("/xrpc/")) { 519 + // XRPC calls may omit the DID header as a convenience when there's 520 + // only one session for the PDS. Anything else (e.g. OAuth metadata 521 + // discovery under /.well-known/) is never meant to be authenticated, 522 + // so don't guess a session for it just because the origin matches. 523 + const sessions = await listSessionsByPDS(url.origin); 524 + if (sessions.length > 1) throw new Error(`Multiple sessions for ${url.origin}; set "x-atsw-did" header`); 525 + session = sessions[0]; 526 + } 527 + 528 + if (!session) return fetch(req); 529 + session = await ensureFresh(session); 530 + 531 + return withRequestLock(session.did, () => doAuthedFetch(req, /** @type {OAuthSession} */ (session))); 451 532 } 452 533 453 534 sw.oninstall = () => sw.skipWaiting();