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