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