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

Configure Feed

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

Initial commit

Jake Lazaroff dc6faa59

+944
+5
README.md
··· 1 + # atsw.js 2 + 3 + A minimal OAuth client for atproto using a service worker. 4 + 5 + **This is very experimental** — you probably want to use [atcute](https://codeberg.org/mary-ext/atcute) if you're building something real.
+425
atsw.js
··· 1 + /** 2 + * @typedef {Object} DPoPKey 3 + * @property {CryptoKey} privateKey 4 + * @property {JsonWebKey} jwk 5 + */ 6 + 7 + /** 8 + * @typedef {Object} OAuthConfig 9 + * @property {string} clientId 10 + * @property {string} redirectUri 11 + * @property {string} scope 12 + */ 13 + 14 + /** 15 + * @typedef {Object} AuthingSession 16 + * @property {string} state 17 + * @property {string} verifier 18 + * @property {DPoPKey} dpopKey 19 + * @property {string} tokenEndpoint 20 + * @property {string} issuer 21 + * @property {string} did 22 + * @property {string} pds 23 + * @property {OAuthConfig} config 24 + */ 25 + 26 + /** 27 + * @typedef {Object} OAuthSession 28 + * @property {string} pds 29 + * @property {string} did 30 + * @property {string} access_token 31 + * @property {string} token_type 32 + * @property {DPoPKey} dpopKey 33 + * @property {string} tokenEndpoint 34 + * @property {string} clientId 35 + * @property {number} expiresAt 36 + * @property {string} [refresh_token] 37 + * @property {string} [dpopNonce] 38 + */ 39 + 40 + /** 41 + * @typedef {Object} AuthServerMetadata 42 + * @property {string} issuer 43 + * @property {string} authorization_endpoint 44 + * @property {string} token_endpoint 45 + * @property {string} pushed_authorization_request_endpoint 46 + */ 47 + 48 + // --- crypto helpers --- 49 + 50 + const enc = new TextEncoder(); 51 + 52 + /** @param {ArrayBuffer | Uint8Array} buf */ 53 + export const b64url = (buf) => 54 + btoa(String.fromCharCode(...new Uint8Array(buf))) 55 + .replace(/\+/g, "-") 56 + .replace(/\//g, "_") 57 + .replace(/=+$/, ""); 58 + 59 + async function generatePKCE() { 60 + const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)).buffer); 61 + const challenge = b64url(await crypto.subtle.digest("SHA-256", enc.encode(verifier))); 62 + return { verifier, challenge }; 63 + } 64 + 65 + async function generateDPoPKey() { 66 + const { privateKey, publicKey } = await crypto.subtle.generateKey( 67 + { name: "ECDSA", namedCurve: "P-256" }, 68 + true, 69 + ["sign"], 70 + ); 71 + const jwk = await crypto.subtle.exportKey("jwk", publicKey); 72 + 73 + return { privateKey, jwk }; 74 + } 75 + 76 + /** 77 + * @param {DPoPKey} dpopKey 78 + * @param {string} htm 79 + * @param {string} htu 80 + * @param {string} [nonce] 81 + * @param {string} [ath] 82 + */ 83 + export async function createDPoP(dpopKey, htm, htu, nonce, ath) { 84 + const header = { alg: "ES256", typ: "dpop+jwt", jwk: dpopKey.jwk }; 85 + 86 + const jti = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 87 + 88 + /** @type {Record<string, string | number>} */ 89 + const payload = { jti, htm, htu, iat: Math.floor(Date.now() / 1000) }; 90 + if (nonce) payload["nonce"] = nonce; 91 + if (ath) payload["ath"] = ath; 92 + 93 + const toSign = [ 94 + b64url(enc.encode(JSON.stringify(header)).buffer), 95 + b64url(enc.encode(JSON.stringify(payload)).buffer), 96 + ].join("."); 97 + 98 + const sig = await crypto.subtle.sign( 99 + { name: "ECDSA", hash: "SHA-256" }, 100 + dpopKey.privateKey, 101 + enc.encode(toSign), 102 + ); 103 + 104 + return toSign + "." + b64url(sig); 105 + } 106 + 107 + /** 108 + * POST with a DPoP proof, retrying once if the server provides a nonce. 109 + * @param {DPoPKey} dpopKey 110 + * @param {string} url 111 + * @param {URLSearchParams} body 112 + * @param {string} [nonce] 113 + * @returns {Promise<{ json: any, dpopNonce: string | undefined }>} 114 + */ 115 + export async function dpopPost(dpopKey, url, body, nonce) { 116 + let dpopNonce = nonce; 117 + for (let attempts = 0; attempts < 2; attempts++) { 118 + const dpop = await createDPoP(dpopKey, "POST", url, dpopNonce); 119 + const res = await fetch(url, { 120 + method: "POST", 121 + headers: { "content-type": "application/x-www-form-urlencoded", DPoP: dpop }, 122 + body, 123 + }); 124 + dpopNonce = res.headers.get("dpop-nonce") ?? dpopNonce; 125 + if (res.ok || !res.headers.get("dpop-nonce")) return { json: await res.json(), dpopNonce }; 126 + } 127 + 128 + throw new Error("DPoP nonce retry failed"); 129 + } 130 + 131 + const DB_NAME = "atproto:oauth"; 132 + const DB_VERSION = 1; 133 + 134 + /** @returns {Promise<IDBDatabase>} */ 135 + function openDb() { 136 + return new Promise((resolve, reject) => { 137 + const req = indexedDB.open(DB_NAME, DB_VERSION); 138 + req.onupgradeneeded = () => { 139 + const db = req.result; 140 + if (!db.objectStoreNames.contains("authing")) 141 + db.createObjectStore("authing", { keyPath: "state" }); 142 + if (!db.objectStoreNames.contains("sessions")) 143 + db.createObjectStore("sessions", { keyPath: "pds" }); 144 + }; 145 + req.onsuccess = () => resolve(req.result); 146 + req.onerror = () => reject(req.error); 147 + }); 148 + } 149 + 150 + /** 151 + * @param {IDBTransactionMode} mode 152 + * @param {string} store 153 + * @param {(s: IDBObjectStore) => IDBRequest} fn 154 + * @returns {Promise<any>} 155 + */ 156 + async function idb(mode, store, fn) { 157 + const db = await openDb(); 158 + return new Promise((resolve, reject) => { 159 + const tx = db.transaction(store, mode); 160 + const req = fn(tx.objectStore(store)); 161 + req.onsuccess = () => resolve(req.result); 162 + req.onerror = () => reject(req.error); 163 + }); 164 + } 165 + 166 + /** @param {AuthingSession} v */ 167 + export const putAuthing = (v) => idb("readwrite", "authing", (s) => s.put(v)); 168 + 169 + /** @param {string} state @returns {Promise<AuthingSession | undefined>} */ 170 + export const getAuthing = (state) => idb("readonly", "authing", (s) => s.get(state)); 171 + 172 + /** @param {string} state */ 173 + export const deleteAuthing = (state) => idb("readwrite", "authing", (s) => s.delete(state)); 174 + 175 + /** @param {OAuthSession} v */ 176 + export const putSession = (v) => idb("readwrite", "sessions", (s) => s.put(v)); 177 + 178 + /** @returns {Promise<OAuthSession[]>} */ 179 + export const listSessions = () => idb("readonly", "sessions", (s) => s.getAll()); 180 + 181 + /** @param {string} pds @returns {Promise<OAuthSession | undefined>} */ 182 + export const getSession = (pds) => idb("readonly", "sessions", (s) => s.get(pds)); 183 + 184 + /** @param {string} pds */ 185 + export const deleteSession = (pds) => idb("readwrite", "sessions", (s) => s.delete(pds)); 186 + 187 + /** 188 + * @param {string} handle 189 + * @returns {Promise<string>} 190 + */ 191 + async function resolveHandle(handle) { 192 + try { 193 + const r = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`); 194 + const j = await r.json(); 195 + const txt = j.Answer?.find(/** @param {any} a */ (a) => a.data?.startsWith('"did=')); 196 + if (txt) return /** @type {string} */ (txt.data.replace(/"/g, "").replace("did=", "")); 197 + } catch {} 198 + // HTTP .well-known resolution is blocked by CORS from the browser, so fall 199 + // back to the public AppView which exposes a CORS-enabled resolver. 200 + const r = await fetch( 201 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`, 202 + ); 203 + const j = await r.json(); 204 + if (!j.did) throw new Error(`Could not resolve handle ${handle}: ${JSON.stringify(j)}`); 205 + return /** @type {string} */ (j.did); 206 + } 207 + 208 + /** @param {string} did */ 209 + export async function resolvePDS(did) { 210 + const url = did.startsWith("did:web:") 211 + ? `https://${did.split(":")[2]}/.well-known/did.json` 212 + : `https://plc.directory/${did}`; 213 + const doc = await (await fetch(url)).json(); 214 + const endpoint = doc.service?.find( 215 + /** @param {{type: string}} s */ (s) => s.type === "AtprotoPersonalDataServer", 216 + )?.serviceEndpoint; 217 + if (!endpoint) throw new Error(`No PDS found for ${did}`); 218 + return /** @type {string} */ (endpoint); 219 + } 220 + 221 + /** 222 + * @param {string} pds 223 + * @returns {Promise<AuthServerMetadata>} 224 + */ 225 + export async function discoverAuthServer(pds) { 226 + const res = await (await fetch(`${pds}/.well-known/oauth-protected-resource`)).json(); 227 + const issuer = /** @type {string} */ (res.authorization_servers[0]); 228 + return (await fetch(`${issuer}/.well-known/oauth-authorization-server`)).json(); 229 + } 230 + 231 + /** 232 + * Fetch client metadata and return an OAuth config. 233 + * @param {string} metadataUrl 234 + * @returns {Promise<OAuthConfig>} 235 + */ 236 + export async function configure(metadataUrl) { 237 + const m = await (await fetch(metadataUrl)).json(); 238 + return { clientId: m.client_id, redirectUri: m.redirect_uris[0], scope: m.scope }; 239 + } 240 + 241 + /** 242 + * Start the OAuth login flow. Stores an authing session in IndexedDB and 243 + * redirects the browser to the authorization server. When the auth server 244 + * redirects back, the service worker will intercept the callback and complete 245 + * the token exchange. 246 + * @param {OAuthConfig} config 247 + * @param {string} handle 248 + * @returns {Promise<void>} 249 + */ 250 + export async function login(config, handle) { 251 + if (!handle) return; 252 + 253 + const did = await resolveHandle(handle); 254 + const pds = await resolvePDS(did); 255 + const meta = await discoverAuthServer(pds); 256 + 257 + const pkce = await generatePKCE(); 258 + const dpopKey = await generateDPoPKey(); 259 + const state = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 260 + 261 + await putAuthing({ 262 + state, 263 + verifier: pkce.verifier, 264 + dpopKey, 265 + tokenEndpoint: meta.token_endpoint, 266 + issuer: meta.issuer, 267 + did, 268 + pds: new URL(pds).origin, 269 + config, 270 + }); 271 + 272 + const parBody = new URLSearchParams({ 273 + client_id: config.clientId, 274 + redirect_uri: config.redirectUri, 275 + response_type: "code", 276 + scope: config.scope, 277 + state, 278 + code_challenge: pkce.challenge, 279 + code_challenge_method: "S256", 280 + login_hint: handle, 281 + }); 282 + 283 + const { json: parJson } = await dpopPost( 284 + dpopKey, 285 + meta.pushed_authorization_request_endpoint, 286 + parBody, 287 + ); 288 + if (parJson.error) throw new Error("PAR error: " + JSON.stringify(parJson)); 289 + 290 + const authUrl = new URL(meta.authorization_endpoint); 291 + authUrl.searchParams.set("client_id", config.clientId); 292 + authUrl.searchParams.set("request_uri", parJson.request_uri); 293 + location.href = authUrl.href; 294 + } 295 + 296 + const sw = globalThis; 297 + if (typeof ServiceWorkerGlobalScope !== "undefined" && sw instanceof ServiceWorkerGlobalScope) { 298 + sw.addEventListener("install", () => sw.skipWaiting()); 299 + sw.addEventListener("activate", (/** @type {any} */ e) => e.waitUntil(sw.clients.claim())); 300 + 301 + sw.addEventListener("fetch", (/** @type {any} */ event) => { 302 + const url = new URL(event.request.url); 303 + const code = url.searchParams.get("code"); 304 + const state = url.searchParams.get("state"); 305 + if (code && state) { 306 + event.respondWith(maybeCallback(event.request, code, state)); 307 + return; 308 + } 309 + event.respondWith(authedFetch(event.request)); 310 + }); 311 + } 312 + 313 + /** 314 + * @param {Request} req 315 + * @param {string} code 316 + * @param {string} state 317 + */ 318 + async function maybeCallback(req, code, state) { 319 + const authing = await getAuthing(state); 320 + // Not our callback — pass through to the default handler 321 + if (!authing) return authedFetch(req); 322 + 323 + const body = new URLSearchParams({ 324 + grant_type: "authorization_code", 325 + code, 326 + redirect_uri: authing.config.redirectUri, 327 + client_id: authing.config.clientId, 328 + code_verifier: authing.verifier, 329 + }); 330 + 331 + const { json: tokenJson, dpopNonce } = await dpopPost( 332 + authing.dpopKey, 333 + authing.tokenEndpoint, 334 + body, 335 + ); 336 + if (tokenJson.error) { 337 + return new Response("token error: " + JSON.stringify(tokenJson), { status: 400 }); 338 + } 339 + 340 + /** @type {OAuthSession} */ 341 + const session = { 342 + pds: authing.pds, 343 + did: authing.did, 344 + access_token: tokenJson.access_token, 345 + token_type: tokenJson.token_type, 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 ?? 3600) * 1000, 352 + }; 353 + await putSession(session); 354 + await deleteAuthing(state); 355 + 356 + // Strip the query params and send the browser back to the redirect_uri 357 + const dest = new URL(authing.config.redirectUri); 358 + return Response.redirect(dest.href, 302); 359 + } 360 + 361 + /** @param {OAuthSession} session */ 362 + async function refresh(session) { 363 + if (!session.refresh_token) throw new Error("No refresh_token in session"); 364 + const body = new URLSearchParams({ 365 + grant_type: "refresh_token", 366 + refresh_token: session.refresh_token, 367 + client_id: session.clientId, 368 + }); 369 + const { json: tokenJson, dpopNonce } = await dpopPost( 370 + session.dpopKey, 371 + session.tokenEndpoint, 372 + body, 373 + session.dpopNonce, 374 + ); 375 + if (tokenJson.error) throw new Error("Refresh error: " + JSON.stringify(tokenJson)); 376 + session.access_token = tokenJson.access_token; 377 + session.expiresAt = Date.now() + (tokenJson.expires_in ?? 3600) * 1000; 378 + if (tokenJson.refresh_token) session.refresh_token = tokenJson.refresh_token; 379 + session.dpopNonce = dpopNonce; 380 + } 381 + 382 + /** 383 + * Intercept requests to any PDS we have a session for, adding DPoP and 384 + * Authorization headers. Pass through everything else unchanged. 385 + * @param {Request} req 386 + */ 387 + async function authedFetch(req) { 388 + const url = new URL(req.url); 389 + const maybeSession = await getSession(url.origin); 390 + if (!maybeSession) return fetch(req); 391 + const session = maybeSession; 392 + 393 + if (session.expiresAt <= Date.now() && session.refresh_token) { 394 + await refresh(session); 395 + await putSession(session); 396 + } 397 + 398 + const htu = url.origin + url.pathname; 399 + const htm = req.method; 400 + const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(session.access_token))); 401 + 402 + async function signedFetch() { 403 + const dpop = await createDPoP(session.dpopKey, htm, htu, session.dpopNonce, ath); 404 + const headers = new Headers(req.headers); 405 + headers.set("authorization", `DPoP ${session.access_token}`); 406 + headers.set("dpop", dpop); 407 + return fetch(new Request(req.clone(), { headers })); 408 + } 409 + 410 + let res = await signedFetch(); 411 + const nonce = res.headers.get("dpop-nonce"); 412 + if (nonce) { 413 + session.dpopNonce = nonce; 414 + await putSession(session); 415 + } 416 + if (res.status === 401 && session.dpopNonce) { 417 + res = await signedFetch(); 418 + const n2 = res.headers.get("dpop-nonce"); 419 + if (n2) { 420 + session.dpopNonce = n2; 421 + await putSession(session); 422 + } 423 + } 424 + return res; 425 + }
+425
example/atsw.js
··· 1 + /** 2 + * @typedef {Object} DPoPKey 3 + * @property {CryptoKey} privateKey 4 + * @property {JsonWebKey} jwk 5 + */ 6 + 7 + /** 8 + * @typedef {Object} OAuthConfig 9 + * @property {string} clientId 10 + * @property {string} redirectUri 11 + * @property {string} scope 12 + */ 13 + 14 + /** 15 + * @typedef {Object} AuthingSession 16 + * @property {string} state 17 + * @property {string} verifier 18 + * @property {DPoPKey} dpopKey 19 + * @property {string} tokenEndpoint 20 + * @property {string} issuer 21 + * @property {string} did 22 + * @property {string} pds 23 + * @property {OAuthConfig} config 24 + */ 25 + 26 + /** 27 + * @typedef {Object} OAuthSession 28 + * @property {string} pds 29 + * @property {string} did 30 + * @property {string} access_token 31 + * @property {string} token_type 32 + * @property {DPoPKey} dpopKey 33 + * @property {string} tokenEndpoint 34 + * @property {string} clientId 35 + * @property {number} expiresAt 36 + * @property {string} [refresh_token] 37 + * @property {string} [dpopNonce] 38 + */ 39 + 40 + /** 41 + * @typedef {Object} AuthServerMetadata 42 + * @property {string} issuer 43 + * @property {string} authorization_endpoint 44 + * @property {string} token_endpoint 45 + * @property {string} pushed_authorization_request_endpoint 46 + */ 47 + 48 + // --- crypto helpers --- 49 + 50 + const enc = new TextEncoder(); 51 + 52 + /** @param {ArrayBuffer | Uint8Array} buf */ 53 + export const b64url = (buf) => 54 + btoa(String.fromCharCode(...new Uint8Array(buf))) 55 + .replace(/\+/g, "-") 56 + .replace(/\//g, "_") 57 + .replace(/=+$/, ""); 58 + 59 + async function generatePKCE() { 60 + const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)).buffer); 61 + const challenge = b64url(await crypto.subtle.digest("SHA-256", enc.encode(verifier))); 62 + return { verifier, challenge }; 63 + } 64 + 65 + async function generateDPoPKey() { 66 + const { privateKey, publicKey } = await crypto.subtle.generateKey( 67 + { name: "ECDSA", namedCurve: "P-256" }, 68 + true, 69 + ["sign"], 70 + ); 71 + const jwk = await crypto.subtle.exportKey("jwk", publicKey); 72 + 73 + return { privateKey, jwk }; 74 + } 75 + 76 + /** 77 + * @param {DPoPKey} dpopKey 78 + * @param {string} htm 79 + * @param {string} htu 80 + * @param {string} [nonce] 81 + * @param {string} [ath] 82 + */ 83 + export async function createDPoP(dpopKey, htm, htu, nonce, ath) { 84 + const header = { alg: "ES256", typ: "dpop+jwt", jwk: dpopKey.jwk }; 85 + 86 + const jti = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 87 + 88 + /** @type {Record<string, string | number>} */ 89 + const payload = { jti, htm, htu, iat: Math.floor(Date.now() / 1000) }; 90 + if (nonce) payload["nonce"] = nonce; 91 + if (ath) payload["ath"] = ath; 92 + 93 + const toSign = [ 94 + b64url(enc.encode(JSON.stringify(header)).buffer), 95 + b64url(enc.encode(JSON.stringify(payload)).buffer), 96 + ].join("."); 97 + 98 + const sig = await crypto.subtle.sign( 99 + { name: "ECDSA", hash: "SHA-256" }, 100 + dpopKey.privateKey, 101 + enc.encode(toSign), 102 + ); 103 + 104 + return toSign + "." + b64url(sig); 105 + } 106 + 107 + /** 108 + * POST with a DPoP proof, retrying once if the server provides a nonce. 109 + * @param {DPoPKey} dpopKey 110 + * @param {string} url 111 + * @param {URLSearchParams} body 112 + * @param {string} [nonce] 113 + * @returns {Promise<{ json: any, dpopNonce: string | undefined }>} 114 + */ 115 + export async function dpopPost(dpopKey, url, body, nonce) { 116 + let dpopNonce = nonce; 117 + for (let attempts = 0; attempts < 2; attempts++) { 118 + const dpop = await createDPoP(dpopKey, "POST", url, dpopNonce); 119 + const res = await fetch(url, { 120 + method: "POST", 121 + headers: { "content-type": "application/x-www-form-urlencoded", DPoP: dpop }, 122 + body, 123 + }); 124 + dpopNonce = res.headers.get("dpop-nonce") ?? dpopNonce; 125 + if (res.ok || !res.headers.get("dpop-nonce")) return { json: await res.json(), dpopNonce }; 126 + } 127 + 128 + throw new Error("DPoP nonce retry failed"); 129 + } 130 + 131 + const DB_NAME = "atproto:oauth"; 132 + const DB_VERSION = 1; 133 + 134 + /** @returns {Promise<IDBDatabase>} */ 135 + function openDb() { 136 + return new Promise((resolve, reject) => { 137 + const req = indexedDB.open(DB_NAME, DB_VERSION); 138 + req.onupgradeneeded = () => { 139 + const db = req.result; 140 + if (!db.objectStoreNames.contains("authing")) 141 + db.createObjectStore("authing", { keyPath: "state" }); 142 + if (!db.objectStoreNames.contains("sessions")) 143 + db.createObjectStore("sessions", { keyPath: "pds" }); 144 + }; 145 + req.onsuccess = () => resolve(req.result); 146 + req.onerror = () => reject(req.error); 147 + }); 148 + } 149 + 150 + /** 151 + * @param {IDBTransactionMode} mode 152 + * @param {string} store 153 + * @param {(s: IDBObjectStore) => IDBRequest} fn 154 + * @returns {Promise<any>} 155 + */ 156 + async function idb(mode, store, fn) { 157 + const db = await openDb(); 158 + return new Promise((resolve, reject) => { 159 + const tx = db.transaction(store, mode); 160 + const req = fn(tx.objectStore(store)); 161 + req.onsuccess = () => resolve(req.result); 162 + req.onerror = () => reject(req.error); 163 + }); 164 + } 165 + 166 + /** @param {AuthingSession} v */ 167 + export const putAuthing = (v) => idb("readwrite", "authing", (s) => s.put(v)); 168 + 169 + /** @param {string} state @returns {Promise<AuthingSession | undefined>} */ 170 + export const getAuthing = (state) => idb("readonly", "authing", (s) => s.get(state)); 171 + 172 + /** @param {string} state */ 173 + export const deleteAuthing = (state) => idb("readwrite", "authing", (s) => s.delete(state)); 174 + 175 + /** @param {OAuthSession} v */ 176 + export const putSession = (v) => idb("readwrite", "sessions", (s) => s.put(v)); 177 + 178 + /** @returns {Promise<OAuthSession[]>} */ 179 + export const listSessions = () => idb("readonly", "sessions", (s) => s.getAll()); 180 + 181 + /** @param {string} pds @returns {Promise<OAuthSession | undefined>} */ 182 + export const getSession = (pds) => idb("readonly", "sessions", (s) => s.get(pds)); 183 + 184 + /** @param {string} pds */ 185 + export const deleteSession = (pds) => idb("readwrite", "sessions", (s) => s.delete(pds)); 186 + 187 + /** 188 + * @param {string} handle 189 + * @returns {Promise<string>} 190 + */ 191 + async function resolveHandle(handle) { 192 + try { 193 + const r = await fetch(`https://dns.google/resolve?name=_atproto.${handle}&type=TXT`); 194 + const j = await r.json(); 195 + const txt = j.Answer?.find(/** @param {any} a */ (a) => a.data?.startsWith('"did=')); 196 + if (txt) return /** @type {string} */ (txt.data.replace(/"/g, "").replace("did=", "")); 197 + } catch {} 198 + // HTTP .well-known resolution is blocked by CORS from the browser, so fall 199 + // back to the public AppView which exposes a CORS-enabled resolver. 200 + const r = await fetch( 201 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`, 202 + ); 203 + const j = await r.json(); 204 + if (!j.did) throw new Error(`Could not resolve handle ${handle}: ${JSON.stringify(j)}`); 205 + return /** @type {string} */ (j.did); 206 + } 207 + 208 + /** @param {string} did */ 209 + export async function resolvePDS(did) { 210 + const url = did.startsWith("did:web:") 211 + ? `https://${did.split(":")[2]}/.well-known/did.json` 212 + : `https://plc.directory/${did}`; 213 + const doc = await (await fetch(url)).json(); 214 + const endpoint = doc.service?.find( 215 + /** @param {{type: string}} s */ (s) => s.type === "AtprotoPersonalDataServer", 216 + )?.serviceEndpoint; 217 + if (!endpoint) throw new Error(`No PDS found for ${did}`); 218 + return /** @type {string} */ (endpoint); 219 + } 220 + 221 + /** 222 + * @param {string} pds 223 + * @returns {Promise<AuthServerMetadata>} 224 + */ 225 + export async function discoverAuthServer(pds) { 226 + const res = await (await fetch(`${pds}/.well-known/oauth-protected-resource`)).json(); 227 + const issuer = /** @type {string} */ (res.authorization_servers[0]); 228 + return (await fetch(`${issuer}/.well-known/oauth-authorization-server`)).json(); 229 + } 230 + 231 + /** 232 + * Fetch client metadata and return an OAuth config. 233 + * @param {string} metadataUrl 234 + * @returns {Promise<OAuthConfig>} 235 + */ 236 + export async function configure(metadataUrl) { 237 + const m = await (await fetch(metadataUrl)).json(); 238 + return { clientId: m.client_id, redirectUri: m.redirect_uris[0], scope: m.scope }; 239 + } 240 + 241 + /** 242 + * Start the OAuth login flow. Stores an authing session in IndexedDB and 243 + * redirects the browser to the authorization server. When the auth server 244 + * redirects back, the service worker will intercept the callback and complete 245 + * the token exchange. 246 + * @param {OAuthConfig} config 247 + * @param {string} handle 248 + * @returns {Promise<void>} 249 + */ 250 + export async function login(config, handle) { 251 + if (!handle) return; 252 + 253 + const did = await resolveHandle(handle); 254 + const pds = await resolvePDS(did); 255 + const meta = await discoverAuthServer(pds); 256 + 257 + const pkce = await generatePKCE(); 258 + const dpopKey = await generateDPoPKey(); 259 + const state = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 260 + 261 + await putAuthing({ 262 + state, 263 + verifier: pkce.verifier, 264 + dpopKey, 265 + tokenEndpoint: meta.token_endpoint, 266 + issuer: meta.issuer, 267 + did, 268 + pds: new URL(pds).origin, 269 + config, 270 + }); 271 + 272 + const parBody = new URLSearchParams({ 273 + client_id: config.clientId, 274 + redirect_uri: config.redirectUri, 275 + response_type: "code", 276 + scope: config.scope, 277 + state, 278 + code_challenge: pkce.challenge, 279 + code_challenge_method: "S256", 280 + login_hint: handle, 281 + }); 282 + 283 + const { json: parJson } = await dpopPost( 284 + dpopKey, 285 + meta.pushed_authorization_request_endpoint, 286 + parBody, 287 + ); 288 + if (parJson.error) throw new Error("PAR error: " + JSON.stringify(parJson)); 289 + 290 + const authUrl = new URL(meta.authorization_endpoint); 291 + authUrl.searchParams.set("client_id", config.clientId); 292 + authUrl.searchParams.set("request_uri", parJson.request_uri); 293 + location.href = authUrl.href; 294 + } 295 + 296 + const sw = globalThis; 297 + if (typeof ServiceWorkerGlobalScope !== "undefined" && sw instanceof ServiceWorkerGlobalScope) { 298 + sw.addEventListener("install", () => sw.skipWaiting()); 299 + sw.addEventListener("activate", (/** @type {any} */ e) => e.waitUntil(sw.clients.claim())); 300 + 301 + sw.addEventListener("fetch", (/** @type {any} */ event) => { 302 + const url = new URL(event.request.url); 303 + const code = url.searchParams.get("code"); 304 + const state = url.searchParams.get("state"); 305 + if (code && state) { 306 + event.respondWith(maybeCallback(event.request, code, state)); 307 + return; 308 + } 309 + event.respondWith(authedFetch(event.request)); 310 + }); 311 + } 312 + 313 + /** 314 + * @param {Request} req 315 + * @param {string} code 316 + * @param {string} state 317 + */ 318 + async function maybeCallback(req, code, state) { 319 + const authing = await getAuthing(state); 320 + // Not our callback — pass through to the default handler 321 + if (!authing) return authedFetch(req); 322 + 323 + const body = new URLSearchParams({ 324 + grant_type: "authorization_code", 325 + code, 326 + redirect_uri: authing.config.redirectUri, 327 + client_id: authing.config.clientId, 328 + code_verifier: authing.verifier, 329 + }); 330 + 331 + const { json: tokenJson, dpopNonce } = await dpopPost( 332 + authing.dpopKey, 333 + authing.tokenEndpoint, 334 + body, 335 + ); 336 + if (tokenJson.error) { 337 + return new Response("token error: " + JSON.stringify(tokenJson), { status: 400 }); 338 + } 339 + 340 + /** @type {OAuthSession} */ 341 + const session = { 342 + pds: authing.pds, 343 + did: authing.did, 344 + access_token: tokenJson.access_token, 345 + token_type: tokenJson.token_type, 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 ?? 3600) * 1000, 352 + }; 353 + await putSession(session); 354 + await deleteAuthing(state); 355 + 356 + // Strip the query params and send the browser back to the redirect_uri 357 + const dest = new URL(authing.config.redirectUri); 358 + return Response.redirect(dest.href, 302); 359 + } 360 + 361 + /** @param {OAuthSession} session */ 362 + async function refresh(session) { 363 + if (!session.refresh_token) throw new Error("No refresh_token in session"); 364 + const body = new URLSearchParams({ 365 + grant_type: "refresh_token", 366 + refresh_token: session.refresh_token, 367 + client_id: session.clientId, 368 + }); 369 + const { json: tokenJson, dpopNonce } = await dpopPost( 370 + session.dpopKey, 371 + session.tokenEndpoint, 372 + body, 373 + session.dpopNonce, 374 + ); 375 + if (tokenJson.error) throw new Error("Refresh error: " + JSON.stringify(tokenJson)); 376 + session.access_token = tokenJson.access_token; 377 + session.expiresAt = Date.now() + (tokenJson.expires_in ?? 3600) * 1000; 378 + if (tokenJson.refresh_token) session.refresh_token = tokenJson.refresh_token; 379 + session.dpopNonce = dpopNonce; 380 + } 381 + 382 + /** 383 + * Intercept requests to any PDS we have a session for, adding DPoP and 384 + * Authorization headers. Pass through everything else unchanged. 385 + * @param {Request} req 386 + */ 387 + async function authedFetch(req) { 388 + const url = new URL(req.url); 389 + const maybeSession = await getSession(url.origin); 390 + if (!maybeSession) return fetch(req); 391 + const session = maybeSession; 392 + 393 + if (session.expiresAt <= Date.now() && session.refresh_token) { 394 + await refresh(session); 395 + await putSession(session); 396 + } 397 + 398 + const htu = url.origin + url.pathname; 399 + const htm = req.method; 400 + const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(session.access_token))); 401 + 402 + async function signedFetch() { 403 + const dpop = await createDPoP(session.dpopKey, htm, htu, session.dpopNonce, ath); 404 + const headers = new Headers(req.headers); 405 + headers.set("authorization", `DPoP ${session.access_token}`); 406 + headers.set("dpop", dpop); 407 + return fetch(new Request(req.clone(), { headers })); 408 + } 409 + 410 + let res = await signedFetch(); 411 + const nonce = res.headers.get("dpop-nonce"); 412 + if (nonce) { 413 + session.dpopNonce = nonce; 414 + await putSession(session); 415 + } 416 + if (res.status === 401 && session.dpopNonce) { 417 + res = await signedFetch(); 418 + const n2 = res.headers.get("dpop-nonce"); 419 + if (n2) { 420 + session.dpopNonce = n2; 421 + await putSession(session); 422 + } 423 + } 424 + return res; 425 + }
+12
example/client-metadata.json
··· 1 + { 2 + "client_id": "https://jake.tngl.io/atsw/client-metadata.json", 3 + "client_uri": "https://jake.tngl.io/atsw/", 4 + "redirect_uris": ["https://jake.tngl.io/atsw/"], 5 + "application_type": "native", 6 + "client_name": "atsw", 7 + "dpop_bound_access_tokens": true, 8 + "grant_types": ["authorization_code", "refresh_token"], 9 + "response_types": ["code"], 10 + "scope": "atproto repo?collection=app.bsky.feed.post", 11 + "token_endpoint_auth_method": "none" 12 + }
+60
example/index.html
··· 1 + <!doctype html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8" /> 5 + <title>atproto oauth</title> 6 + <style> 7 + :root { 8 + color-scheme: light dark; 9 + font-family: system-ui, sans-serif; 10 + } 11 + </style> 12 + </head> 13 + <body> 14 + <div id="app"> 15 + <h1>atproto oauth</h1> 16 + <form id="login"> 17 + <input name="handle" placeholder="you.bsky.social" /> 18 + <button>Login</button> 19 + </form> 20 + <pre id="out"></pre> 21 + </div> 22 + <script type="module"> 23 + import { configure, login, listSessions } from "./atsw.js"; 24 + 25 + // Register the service worker that handles the callback and authenticates PDS requests. 26 + await navigator.serviceWorker.register("./atsw.js", { type: "module" }); 27 + await navigator.serviceWorker.ready; 28 + 29 + const config = await configure("./client-metadata.json"); 30 + const out = (msg) => (document.getElementById("out").textContent = msg); 31 + 32 + document.getElementById("login").onsubmit = async (e) => { 33 + const data = new FormData(e.target); 34 + const handle = data.get("handle").trim(); 35 + if (!handle) return; 36 + e.preventDefault(); 37 + try { 38 + out("Logging in..."); 39 + await login(config, handle); 40 + } catch (e) { 41 + out(e.message); 42 + } 43 + }; 44 + 45 + // Show any sessions the service worker has stored. 46 + const sessions = await listSessions(); 47 + if (sessions.length) { 48 + document.getElementById("login").hidden = true; 49 + const session = sessions[0]; 50 + out(`Logged in as ${session.did} @ ${session.pds}\n\nVerifying session...`); 51 + 52 + // The service worker intercepts this request and signs it with DPoP + auth headers. 53 + const res = await fetch(`${session.pds}/xrpc/com.atproto.server.getSession`); 54 + const data = await res.json(); 55 + 56 + out(`Session verified:\n${JSON.stringify(data, null, 2)}`); 57 + } 58 + </script> 59 + </body> 60 + </html>
+17
jsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "lib": ["esnext", "webworker", "dom"], 4 + "checkJs": true, 5 + "module": "preserve", 6 + "target": "esnext", 7 + "noImplicitReturns": true, 8 + "noImplicitOverride": true, 9 + "noUnusedLocals": true, 10 + "noUnusedParameters": true, 11 + "noFallthroughCasesInSwitch": true, 12 + "noPropertyAccessFromIndexSignature": true, 13 + "strict": true, 14 + "isolatedModules": true, 15 + "noUncheckedSideEffectImports": true 16 + } 17 + }