a proof of concept realtime collaborative todo list
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}