a tiny oauth browser client for atproto using a service worker
12
fork

Configure Feed

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

at main 443 lines 14 kB view raw
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}