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.

Simplify a bit

+54 -40
+27 -20
atsw.js
··· 34 34 * @property {string} pds 35 35 * @property {string} did 36 36 * @property {string} access_token 37 - * @property {string} token_type 38 37 * @property {DPoPKey} dpopKey 39 38 * @property {string} tokenEndpoint 40 39 * @property {string} clientId ··· 60 59 .replace(/\//g, "_") 61 60 .replace(/=+$/, ""); 62 61 62 + /** @param {number} n */ 63 + const randomB64url = (n) => b64url(crypto.getRandomValues(new Uint8Array(n)).buffer); 64 + 63 65 async function generatePKCE() { 64 - const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)).buffer); 66 + const verifier = randomB64url(32); 65 67 const challenge = b64url(await crypto.subtle.digest("SHA-256", enc.encode(verifier))); 66 68 return { verifier, challenge }; 67 69 } ··· 83 85 async function createDPoP(dpopKey, htm, htu, nonce, ath) { 84 86 const header = { alg: "ES256", typ: "dpop+jwt", jwk: dpopKey.jwk }; 85 87 86 - const jti = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 88 + const jti = randomB64url(16); 87 89 88 90 /** @type {Record<string, string | number>} */ 89 91 const payload = { jti, htm, htu, iat: Math.floor(Date.now() / 1000) }; ··· 101 103 } 102 104 103 105 const MAX_DPOP_RETRIES = 2; 106 + const DEFAULT_TOKEN_TTL = 3600; 107 + const DID_HEADER = "x-atsw-did"; 104 108 105 109 /** 106 110 * @param {DPoPKey} key ··· 134 138 const DB_NAME = "atproto:oauth"; 135 139 const DB_VERSION = 3; 136 140 141 + /** @type {Promise<IDBDatabase> | null} */ 142 + let dbPromise = null; 143 + 137 144 /** @returns {Promise<IDBDatabase>} */ 138 145 function openDb() { 139 - return new Promise((resolve, reject) => { 146 + if (dbPromise) return dbPromise; 147 + dbPromise = new Promise((resolve, reject) => { 140 148 const req = indexedDB.open(DB_NAME, DB_VERSION); 141 149 req.onupgradeneeded = () => { 142 150 const db = req.result; ··· 147 155 ssns.createIndex("pds", "pds", { unique: false }); 148 156 }; 149 157 req.onsuccess = () => resolve(req.result); 150 - req.onerror = () => reject(req.error); 158 + req.onerror = () => { 159 + dbPromise = null; 160 + reject(req.error); 161 + }; 151 162 }); 163 + 164 + return dbPromise; 152 165 } 153 166 154 167 /** ··· 252 265 export async function logIn(config, handle) { 253 266 const did = await resolveDID(handle); 254 267 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); 268 + const [meta, pkce, dpopKey] = await Promise.all([discoverAuthServer(pds), generatePKCE(), generateDPoPKey()]); 269 + const state = randomB64url(16); 260 270 261 271 await putAuthing({ 262 272 state, ··· 333 343 pds: authing.pds, 334 344 did: authing.did, 335 345 access_token: tokenJson.access_token, 336 - token_type: tokenJson.token_type, 337 346 refresh_token: tokenJson.refresh_token, 338 347 dpopKey: authing.dpopKey, 339 348 dpopNonce, 340 349 tokenEndpoint: authing.tokenEndpoint, 341 350 clientId: authing.config.clientId, 342 - expiresAt: Date.now() + (tokenJson.expires_in ?? 3600) * 1000, 351 + expiresAt: Date.now() + (tokenJson.expires_in ?? DEFAULT_TOKEN_TTL) * 1000, 343 352 }; 344 353 await putSession(session); 345 354 await deleteAuthing(state); 346 355 347 - // strip the query params and send the browser back to the redirect_uri 348 356 const dest = new URL(authing.config.redirectUri); 349 357 return Response.redirect(dest.href, 302); 350 358 } ··· 354 362 355 363 /** @param {OAuthSession} session */ 356 364 async function ensureFresh(session) { 357 - // if the session is still valid or there's no refresh token, just return the session 358 365 if (session.expiresAt > Date.now() || !session.refresh_token) return session; 359 366 360 367 // see if this session is already being refreshed ··· 374 381 if (json.error) throw new Error("Refresh error: " + JSON.stringify(json)); 375 382 376 383 session.access_token = json.access_token; 377 - session.expiresAt = Date.now() + (json.expires_in ?? 3600) * 1000; 384 + session.expiresAt = Date.now() + (json.expires_in ?? DEFAULT_TOKEN_TTL) * 1000; 378 385 if (json.refresh_token) session.refresh_token = json.refresh_token; 379 386 session.dpopNonce = dpopNonce; 380 387 ··· 393 400 /** @param {Request} req */ 394 401 async function authedFetch(req) { 395 402 const url = new URL(req.url); 396 - const did = req.headers.get("x-atsw-did"); 403 + const did = req.headers.get(DID_HEADER); 397 404 398 405 /** @type {OAuthSession | undefined} */ 399 406 let session; ··· 409 416 410 417 const htu = url.origin + url.pathname; 411 418 const htm = req.method; 412 - const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(session.access_token))); 413 419 414 420 let res = new Response(); 415 421 for (let attempt = 0; attempt < MAX_DPOP_RETRIES; attempt++) { 416 422 if (attempt > 0) session = (await getSession(session.did)) ?? session; 417 423 424 + const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(session.access_token))); 418 425 const dpop = await createDPoP(session.dpopKey, htm, htu, session.dpopNonce, ath); 419 426 420 427 const headers = new Headers(req.headers); 421 - headers.delete("x-atsw-did"); 428 + headers.delete(DID_HEADER); 422 429 headers.set("authorization", `DPoP ${session.access_token}`); 423 430 headers.set("dpop", dpop); 424 431 425 432 res = await fetch(new Request(req.clone(), { headers })); 426 433 const nonce = res.headers.get("dpop-nonce"); 427 - if (nonce) { 434 + if (nonce && nonce !== session.dpopNonce) { 428 435 session.dpopNonce = nonce; 429 436 await putSession(session); 430 437 } ··· 432 439 if (res.status !== 401) break; 433 440 } 434 441 435 - return res; 442 + return /** @type {Response} */ (res); 436 443 }
+27 -20
example/atsw.js
··· 34 34 * @property {string} pds 35 35 * @property {string} did 36 36 * @property {string} access_token 37 - * @property {string} token_type 38 37 * @property {DPoPKey} dpopKey 39 38 * @property {string} tokenEndpoint 40 39 * @property {string} clientId ··· 60 59 .replace(/\//g, "_") 61 60 .replace(/=+$/, ""); 62 61 62 + /** @param {number} n */ 63 + const randomB64url = (n) => b64url(crypto.getRandomValues(new Uint8Array(n)).buffer); 64 + 63 65 async function generatePKCE() { 64 - const verifier = b64url(crypto.getRandomValues(new Uint8Array(32)).buffer); 66 + const verifier = randomB64url(32); 65 67 const challenge = b64url(await crypto.subtle.digest("SHA-256", enc.encode(verifier))); 66 68 return { verifier, challenge }; 67 69 } ··· 83 85 async function createDPoP(dpopKey, htm, htu, nonce, ath) { 84 86 const header = { alg: "ES256", typ: "dpop+jwt", jwk: dpopKey.jwk }; 85 87 86 - const jti = b64url(crypto.getRandomValues(new Uint8Array(16)).buffer); 88 + const jti = randomB64url(16); 87 89 88 90 /** @type {Record<string, string | number>} */ 89 91 const payload = { jti, htm, htu, iat: Math.floor(Date.now() / 1000) }; ··· 101 103 } 102 104 103 105 const MAX_DPOP_RETRIES = 2; 106 + const DEFAULT_TOKEN_TTL = 3600; 107 + const DID_HEADER = "x-atsw-did"; 104 108 105 109 /** 106 110 * @param {DPoPKey} key ··· 134 138 const DB_NAME = "atproto:oauth"; 135 139 const DB_VERSION = 3; 136 140 141 + /** @type {Promise<IDBDatabase> | null} */ 142 + let dbPromise = null; 143 + 137 144 /** @returns {Promise<IDBDatabase>} */ 138 145 function openDb() { 139 - return new Promise((resolve, reject) => { 146 + if (dbPromise) return dbPromise; 147 + dbPromise = new Promise((resolve, reject) => { 140 148 const req = indexedDB.open(DB_NAME, DB_VERSION); 141 149 req.onupgradeneeded = () => { 142 150 const db = req.result; ··· 147 155 ssns.createIndex("pds", "pds", { unique: false }); 148 156 }; 149 157 req.onsuccess = () => resolve(req.result); 150 - req.onerror = () => reject(req.error); 158 + req.onerror = () => { 159 + dbPromise = null; 160 + reject(req.error); 161 + }; 151 162 }); 163 + 164 + return dbPromise; 152 165 } 153 166 154 167 /** ··· 252 265 export async function logIn(config, handle) { 253 266 const did = await resolveDID(handle); 254 267 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); 268 + const [meta, pkce, dpopKey] = await Promise.all([discoverAuthServer(pds), generatePKCE(), generateDPoPKey()]); 269 + const state = randomB64url(16); 260 270 261 271 await putAuthing({ 262 272 state, ··· 333 343 pds: authing.pds, 334 344 did: authing.did, 335 345 access_token: tokenJson.access_token, 336 - token_type: tokenJson.token_type, 337 346 refresh_token: tokenJson.refresh_token, 338 347 dpopKey: authing.dpopKey, 339 348 dpopNonce, 340 349 tokenEndpoint: authing.tokenEndpoint, 341 350 clientId: authing.config.clientId, 342 - expiresAt: Date.now() + (tokenJson.expires_in ?? 3600) * 1000, 351 + expiresAt: Date.now() + (tokenJson.expires_in ?? DEFAULT_TOKEN_TTL) * 1000, 343 352 }; 344 353 await putSession(session); 345 354 await deleteAuthing(state); 346 355 347 - // strip the query params and send the browser back to the redirect_uri 348 356 const dest = new URL(authing.config.redirectUri); 349 357 return Response.redirect(dest.href, 302); 350 358 } ··· 354 362 355 363 /** @param {OAuthSession} session */ 356 364 async function ensureFresh(session) { 357 - // if the session is still valid or there's no refresh token, just return the session 358 365 if (session.expiresAt > Date.now() || !session.refresh_token) return session; 359 366 360 367 // see if this session is already being refreshed ··· 374 381 if (json.error) throw new Error("Refresh error: " + JSON.stringify(json)); 375 382 376 383 session.access_token = json.access_token; 377 - session.expiresAt = Date.now() + (json.expires_in ?? 3600) * 1000; 384 + session.expiresAt = Date.now() + (json.expires_in ?? DEFAULT_TOKEN_TTL) * 1000; 378 385 if (json.refresh_token) session.refresh_token = json.refresh_token; 379 386 session.dpopNonce = dpopNonce; 380 387 ··· 393 400 /** @param {Request} req */ 394 401 async function authedFetch(req) { 395 402 const url = new URL(req.url); 396 - const did = req.headers.get("x-atsw-did"); 403 + const did = req.headers.get(DID_HEADER); 397 404 398 405 /** @type {OAuthSession | undefined} */ 399 406 let session; ··· 409 416 410 417 const htu = url.origin + url.pathname; 411 418 const htm = req.method; 412 - const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(session.access_token))); 413 419 414 420 let res = new Response(); 415 421 for (let attempt = 0; attempt < MAX_DPOP_RETRIES; attempt++) { 416 422 if (attempt > 0) session = (await getSession(session.did)) ?? session; 417 423 424 + const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(session.access_token))); 418 425 const dpop = await createDPoP(session.dpopKey, htm, htu, session.dpopNonce, ath); 419 426 420 427 const headers = new Headers(req.headers); 421 - headers.delete("x-atsw-did"); 428 + headers.delete(DID_HEADER); 422 429 headers.set("authorization", `DPoP ${session.access_token}`); 423 430 headers.set("dpop", dpop); 424 431 425 432 res = await fetch(new Request(req.clone(), { headers })); 426 433 const nonce = res.headers.get("dpop-nonce"); 427 - if (nonce) { 434 + if (nonce && nonce !== session.dpopNonce) { 428 435 session.dpopNonce = nonce; 429 436 await putSession(session); 430 437 } ··· 432 439 if (res.status !== 401) break; 433 440 } 434 441 435 - return res; 442 + return /** @type {Response} */ (res); 436 443 }