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