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.

Lock when refreshing token

+97 -119
+1 -1
.editorconfig
··· 5 5 trim_trailing_whitespace = true 6 6 insert_final_newline = true 7 7 indent_style = tab 8 - max_line_length = 100 8 + max_line_length = 120
+48 -59
atsw.js
··· 67 67 } 68 68 69 69 async function generateDPoPKey() { 70 - const { privateKey, publicKey } = await crypto.subtle.generateKey( 71 - { name: "ECDSA", namedCurve: "P-256" }, 72 - true, 73 - ["sign"], 74 - ); 75 - const jwk = await crypto.subtle.exportKey("jwk", publicKey); 70 + const key = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"]); 71 + const jwk = await crypto.subtle.exportKey("jwk", key.publicKey); 76 72 77 - return { privateKey, jwk }; 73 + return { privateKey: key.privateKey, jwk }; 78 74 } 79 75 80 76 /** ··· 99 95 b64url(enc.encode(JSON.stringify(payload)).buffer), 100 96 ].join("."); 101 97 102 - const sig = await crypto.subtle.sign( 103 - { name: "ECDSA", hash: "SHA-256" }, 104 - dpopKey.privateKey, 105 - enc.encode(toSign), 106 - ); 98 + const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, dpopKey.privateKey, enc.encode(toSign)); 107 99 108 100 return toSign + "." + b64url(sig); 109 101 } ··· 142 134 const req = indexedDB.open(DB_NAME, DB_VERSION); 143 135 req.onupgradeneeded = () => { 144 136 const db = req.result; 145 - if (!db.objectStoreNames.contains("authing")) 146 - db.createObjectStore("authing", { keyPath: "state" }); 137 + if (!db.objectStoreNames.contains("authing")) db.createObjectStore("authing", { keyPath: "state" }); 147 138 148 139 if (db.objectStoreNames.contains("sessions")) db.deleteObjectStore("sessions"); 149 140 const ssns = db.createObjectStore("sessions", { keyPath: "did" }); ··· 189 180 export const getSession = (did) => idb("readonly", "sessions", (s) => s.get(did)); 190 181 191 182 /** @param {string} pds @returns {Promise<OAuthSession[]>} */ 192 - const getSessionsByPDS = (pds) => idb("readonly", "sessions", (s) => s.index("pds").getAll(pds)); 183 + const listSessionsByPDS = (pds) => idb("readonly", "sessions", (s) => s.index("pds").getAll(pds)); 193 184 194 185 /** @param {string} did */ 195 186 export const logOut = (did) => idb("readwrite", "sessions", (s) => s.delete(did)); ··· 204 195 } catch {} 205 196 // HTTP .well-known resolution is blocked by CORS from the browser, so fall 206 197 // back to the public AppView which exposes a CORS-enabled resolver. 207 - const r = await fetch( 208 - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`, 209 - ); 198 + const r = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`); 210 199 const j = await r.json(); 211 200 if (!j.did) throw new Error(`Could not resolve handle ${handle}: ${JSON.stringify(j)}`); 212 201 return /** @type {string} */ (j.did); ··· 285 274 login_hint: handle, 286 275 }); 287 276 288 - const { json: parJson } = await dpopPost( 289 - dpopKey, 290 - meta.pushed_authorization_request_endpoint, 291 - parBody, 292 - ); 277 + const { json: parJson } = await dpopPost(dpopKey, meta.pushed_authorization_request_endpoint, parBody); 293 278 if (parJson.error) throw new Error("PAR error: " + JSON.stringify(parJson)); 294 279 295 280 const authUrl = new URL(meta.authorization_endpoint); ··· 332 317 code_verifier: authing.verifier, 333 318 }); 334 319 335 - const { json: tokenJson, dpopNonce } = await dpopPost( 336 - authing.dpopKey, 337 - authing.tokenEndpoint, 338 - body, 339 - ); 320 + const { json: tokenJson, dpopNonce } = await dpopPost(authing.dpopKey, authing.tokenEndpoint, body); 340 321 if (tokenJson.error) { 341 322 return new Response("token error: " + JSON.stringify(tokenJson), { status: 400 }); 342 323 } ··· 362 343 return Response.redirect(dest.href, 302); 363 344 } 364 345 346 + /** @type {Map<string, Promise<OAuthSession>>} */ 347 + const refreshLocks = new Map(); 348 + 365 349 /** @param {OAuthSession} session */ 366 - async function refresh(session) { 367 - if (!session.refresh_token) throw new Error("No refresh_token in session"); 350 + async function ensureFresh(session) { 351 + // if the session is still valid or there's no refresh token, just return the session 352 + if (session.expiresAt > Date.now() || !session.refresh_token) return session; 353 + 354 + // see if this session is already being refreshed 355 + const lock = refreshLocks.get(session.did); 356 + if (lock) return lock; 357 + 358 + // lock the DID 359 + /** @type {PromiseWithResolvers<OAuthSession>} */ 360 + const { promise, resolve, reject } = Promise.withResolvers(); 361 + refreshLocks.set(session.did, promise); 368 362 369 - const body = new URLSearchParams({ 370 - grant_type: "refresh_token", 371 - refresh_token: session.refresh_token, 372 - client_id: session.clientId, 373 - }); 363 + try { 364 + // refresh the session 365 + const { tokenEndpoint, dpopKey, refresh_token, clientId: client_id } = session; 366 + const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token, client_id }); 367 + const { json, dpopNonce } = await dpopPost(dpopKey, tokenEndpoint, body, session.dpopNonce); 368 + if (json.error) throw new Error("Refresh error: " + JSON.stringify(json)); 374 369 375 - const { json, dpopNonce } = await dpopPost( 376 - session.dpopKey, 377 - session.tokenEndpoint, 378 - body, 379 - session.dpopNonce, 380 - ); 370 + session.access_token = json.access_token; 371 + session.expiresAt = Date.now() + (json.expires_in ?? 3600) * 1000; 372 + if (json.refresh_token) session.refresh_token = json.refresh_token; 373 + session.dpopNonce = dpopNonce; 381 374 382 - if (json.error) throw new Error("Refresh error: " + JSON.stringify(json)); 383 - session.access_token = json.access_token; 384 - session.expiresAt = Date.now() + (json.expires_in ?? 3600) * 1000; 385 - if (json.refresh_token) session.refresh_token = json.refresh_token; 386 - session.dpopNonce = dpopNonce; 375 + await putSession(session); 376 + resolve(session); 377 + return session; 378 + } catch (e) { 379 + reject(e); 380 + throw e; 381 + } finally { 382 + // release the lock 383 + refreshLocks.delete(session.did); 384 + } 387 385 } 388 386 389 - /** 390 - * Intercept requests to any PDS we have a session for, adding DPoP and 391 - * Authorization headers. Pass through everything else unchanged. 392 - * @param {Request} req 393 - */ 387 + /** @param {Request} req */ 394 388 async function authedFetch(req) { 395 389 const url = new URL(req.url); 396 390 const did = req.headers.get("x-atsw-did"); ··· 399 393 let session; 400 394 if (did) session = await getSession(did); 401 395 else { 402 - const sessions = await getSessionsByPDS(url.origin); 403 - if (sessions.length > 1) 404 - throw new Error(`Multiple sessions for ${url.origin}; set x-atsw-did header`); 396 + const sessions = await listSessionsByPDS(url.origin); 397 + if (sessions.length > 1) throw new Error(`Multiple sessions for ${url.origin}; set "x-atsw-did" header`); 405 398 session = sessions[0]; 406 399 } 407 400 408 401 if (!session) return fetch(req); 409 - 410 - if (session.expiresAt <= Date.now() && session.refresh_token) { 411 - await refresh(session); 412 - await putSession(session); 413 - } 402 + session = await ensureFresh(session); 414 403 415 404 const htu = url.origin + url.pathname; 416 405 const htm = req.method;
+48 -59
example/atsw.js
··· 67 67 } 68 68 69 69 async function generateDPoPKey() { 70 - const { privateKey, publicKey } = await crypto.subtle.generateKey( 71 - { name: "ECDSA", namedCurve: "P-256" }, 72 - true, 73 - ["sign"], 74 - ); 75 - const jwk = await crypto.subtle.exportKey("jwk", publicKey); 70 + const key = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign"]); 71 + const jwk = await crypto.subtle.exportKey("jwk", key.publicKey); 76 72 77 - return { privateKey, jwk }; 73 + return { privateKey: key.privateKey, jwk }; 78 74 } 79 75 80 76 /** ··· 99 95 b64url(enc.encode(JSON.stringify(payload)).buffer), 100 96 ].join("."); 101 97 102 - const sig = await crypto.subtle.sign( 103 - { name: "ECDSA", hash: "SHA-256" }, 104 - dpopKey.privateKey, 105 - enc.encode(toSign), 106 - ); 98 + const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, dpopKey.privateKey, enc.encode(toSign)); 107 99 108 100 return toSign + "." + b64url(sig); 109 101 } ··· 142 134 const req = indexedDB.open(DB_NAME, DB_VERSION); 143 135 req.onupgradeneeded = () => { 144 136 const db = req.result; 145 - if (!db.objectStoreNames.contains("authing")) 146 - db.createObjectStore("authing", { keyPath: "state" }); 137 + if (!db.objectStoreNames.contains("authing")) db.createObjectStore("authing", { keyPath: "state" }); 147 138 148 139 if (db.objectStoreNames.contains("sessions")) db.deleteObjectStore("sessions"); 149 140 const ssns = db.createObjectStore("sessions", { keyPath: "did" }); ··· 189 180 export const getSession = (did) => idb("readonly", "sessions", (s) => s.get(did)); 190 181 191 182 /** @param {string} pds @returns {Promise<OAuthSession[]>} */ 192 - const getSessionsByPDS = (pds) => idb("readonly", "sessions", (s) => s.index("pds").getAll(pds)); 183 + const listSessionsByPDS = (pds) => idb("readonly", "sessions", (s) => s.index("pds").getAll(pds)); 193 184 194 185 /** @param {string} did */ 195 186 export const logOut = (did) => idb("readwrite", "sessions", (s) => s.delete(did)); ··· 204 195 } catch {} 205 196 // HTTP .well-known resolution is blocked by CORS from the browser, so fall 206 197 // back to the public AppView which exposes a CORS-enabled resolver. 207 - const r = await fetch( 208 - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`, 209 - ); 198 + const r = await fetch(`https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${handle}`); 210 199 const j = await r.json(); 211 200 if (!j.did) throw new Error(`Could not resolve handle ${handle}: ${JSON.stringify(j)}`); 212 201 return /** @type {string} */ (j.did); ··· 285 274 login_hint: handle, 286 275 }); 287 276 288 - const { json: parJson } = await dpopPost( 289 - dpopKey, 290 - meta.pushed_authorization_request_endpoint, 291 - parBody, 292 - ); 277 + const { json: parJson } = await dpopPost(dpopKey, meta.pushed_authorization_request_endpoint, parBody); 293 278 if (parJson.error) throw new Error("PAR error: " + JSON.stringify(parJson)); 294 279 295 280 const authUrl = new URL(meta.authorization_endpoint); ··· 332 317 code_verifier: authing.verifier, 333 318 }); 334 319 335 - const { json: tokenJson, dpopNonce } = await dpopPost( 336 - authing.dpopKey, 337 - authing.tokenEndpoint, 338 - body, 339 - ); 320 + const { json: tokenJson, dpopNonce } = await dpopPost(authing.dpopKey, authing.tokenEndpoint, body); 340 321 if (tokenJson.error) { 341 322 return new Response("token error: " + JSON.stringify(tokenJson), { status: 400 }); 342 323 } ··· 362 343 return Response.redirect(dest.href, 302); 363 344 } 364 345 346 + /** @type {Map<string, Promise<OAuthSession>>} */ 347 + const refreshLocks = new Map(); 348 + 365 349 /** @param {OAuthSession} session */ 366 - async function refresh(session) { 367 - if (!session.refresh_token) throw new Error("No refresh_token in session"); 350 + async function ensureFresh(session) { 351 + // if the session is still valid or there's no refresh token, just return the session 352 + if (session.expiresAt > Date.now() || !session.refresh_token) return session; 353 + 354 + // see if this session is already being refreshed 355 + const lock = refreshLocks.get(session.did); 356 + if (lock) return lock; 357 + 358 + // lock the DID 359 + /** @type {PromiseWithResolvers<OAuthSession>} */ 360 + const { promise, resolve, reject } = Promise.withResolvers(); 361 + refreshLocks.set(session.did, promise); 368 362 369 - const body = new URLSearchParams({ 370 - grant_type: "refresh_token", 371 - refresh_token: session.refresh_token, 372 - client_id: session.clientId, 373 - }); 363 + try { 364 + // refresh the session 365 + const { tokenEndpoint, dpopKey, refresh_token, clientId: client_id } = session; 366 + const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token, client_id }); 367 + const { json, dpopNonce } = await dpopPost(dpopKey, tokenEndpoint, body, session.dpopNonce); 368 + if (json.error) throw new Error("Refresh error: " + JSON.stringify(json)); 374 369 375 - const { json, dpopNonce } = await dpopPost( 376 - session.dpopKey, 377 - session.tokenEndpoint, 378 - body, 379 - session.dpopNonce, 380 - ); 370 + session.access_token = json.access_token; 371 + session.expiresAt = Date.now() + (json.expires_in ?? 3600) * 1000; 372 + if (json.refresh_token) session.refresh_token = json.refresh_token; 373 + session.dpopNonce = dpopNonce; 381 374 382 - if (json.error) throw new Error("Refresh error: " + JSON.stringify(json)); 383 - session.access_token = json.access_token; 384 - session.expiresAt = Date.now() + (json.expires_in ?? 3600) * 1000; 385 - if (json.refresh_token) session.refresh_token = json.refresh_token; 386 - session.dpopNonce = dpopNonce; 375 + await putSession(session); 376 + resolve(session); 377 + return session; 378 + } catch (e) { 379 + reject(e); 380 + throw e; 381 + } finally { 382 + // release the lock 383 + refreshLocks.delete(session.did); 384 + } 387 385 } 388 386 389 - /** 390 - * Intercept requests to any PDS we have a session for, adding DPoP and 391 - * Authorization headers. Pass through everything else unchanged. 392 - * @param {Request} req 393 - */ 387 + /** @param {Request} req */ 394 388 async function authedFetch(req) { 395 389 const url = new URL(req.url); 396 390 const did = req.headers.get("x-atsw-did"); ··· 399 393 let session; 400 394 if (did) session = await getSession(did); 401 395 else { 402 - const sessions = await getSessionsByPDS(url.origin); 403 - if (sessions.length > 1) 404 - throw new Error(`Multiple sessions for ${url.origin}; set x-atsw-did header`); 396 + const sessions = await listSessionsByPDS(url.origin); 397 + if (sessions.length > 1) throw new Error(`Multiple sessions for ${url.origin}; set "x-atsw-did" header`); 405 398 session = sessions[0]; 406 399 } 407 400 408 401 if (!session) return fetch(req); 409 - 410 - if (session.expiresAt <= Date.now() && session.refresh_token) { 411 - await refresh(session); 412 - await putSession(session); 413 - } 402 + session = await ensureFresh(session); 414 403 415 404 const htu = url.origin + url.pathname; 416 405 const htm = req.method;