a tiny oauth browser client for atproto using a service worker
0

Configure Feed

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

atsw / atsw.js
23 kB 728 lines
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} AttemptSession 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 * @property {string} swScope 31 * @property {number} createdAt 32 * @property {string} [revocationEndpoint] 33 */ 34 35/** 36 * @typedef {Object} OAuthSession 37 * @property {string} pds 38 * @property {string} did 39 * @property {string} accessToken 40 * @property {DPoPKey} dpopKey 41 * @property {string} tokenEndpoint 42 * @property {string} clientId 43 * @property {string} swScope 44 * @property {number} expiresAt 45 * @property {string} [refreshToken] 46 * @property {string} [dpopNonce] 47 * @property {string} [revocationEndpoint] 48 */ 49 50/** 51 * @typedef {Object} AuthServerMetadata 52 * @property {string} issuer 53 * @property {string} authorization_endpoint 54 * @property {string} token_endpoint 55 * @property {string} pushed_authorization_request_endpoint 56 * @property {string} [revocation_endpoint] 57 */ 58 59/** 60 * @typedef {Object} TokenResponse 61 * @property {string} access_token 62 * @property {string} [refresh_token] 63 * @property {number} [expires_in] 64 * @property {string} [sub] 65 * @property {string} [scope] 66 */ 67 68const enc = new TextEncoder(); 69 70/** @param {ArrayBuffer | Uint8Array} buf */ 71const b64url = buf => 72 btoa(String.fromCharCode(...new Uint8Array(buf))) 73 .replace(/\+/g, "-") 74 .replace(/\//g, "_") 75 .replace(/=+$/, ""); 76 77/** @param {number} n */ 78const randomB64url = n => b64url(crypto.getRandomValues(new Uint8Array(n))); 79 80async function generatePKCE() { 81 const verifier = randomB64url(32); 82 const challenge = b64url(await crypto.subtle.digest("SHA-256", enc.encode(verifier))); 83 return { verifier, challenge }; 84} 85 86async function generateDPoPKey() { 87 const key = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]); 88 const jwk = await crypto.subtle.exportKey("jwk", key.publicKey); 89 90 return { privateKey: key.privateKey, jwk }; 91} 92 93/** 94 * @param {DPoPKey} dpopKey 95 * @param {string} htm 96 * @param {string} htu 97 * @param {string} [nonce] 98 * @param {string} [ath] 99 */ 100async function createDPoP({ privateKey, jwk }, htm, htu, nonce, ath) { 101 const header = { alg: "ES256", typ: "dpop+jwt", jwk }; 102 103 /** @type {Record<string, string | number>} */ 104 const payload = { htm, htu, jti: randomB64url(16), iat: Math.floor(Date.now() / 1000) }; 105 if (nonce) payload["nonce"] = nonce; 106 if (ath) payload["ath"] = ath; 107 108 const toSign = [header, payload] 109 .map(part => JSON.stringify(part)) 110 .map(part => enc.encode(part)) 111 .map(part => b64url(part)) 112 .join("."); 113 const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, enc.encode(toSign)); 114 115 return toSign + "." + b64url(sig); 116} 117 118const MAX_DPOP_RETRIES = 2; 119const DEFAULT_TOKEN_TTL = 3600; 120const EXPIRY_MARGIN = 30_000; 121const DID_HEADER = "x-atsw-did"; 122const ATTEMPT_TTL = 60 * 60 * 1000; 123 124/** 125 * @param {DPoPKey} key 126 * @param {string} url 127 * @param {URLSearchParams} body 128 * @param {string} [nonce] 129 */ 130async function dpopPost(key, url, body, nonce) { 131 let dpopNonce = nonce; 132 for (let attempts = 0; attempts < MAX_DPOP_RETRIES; attempts++) { 133 const dpop = await createDPoP(key, "POST", url, dpopNonce); 134 const res = await fetch(url, { 135 method: "POST", 136 headers: { "content-type": "application/x-www-form-urlencoded", DPoP: dpop }, 137 body 138 }); 139 140 const newNonce = res.headers.get("dpop-nonce"); 141 const nonceChanged = newNonce && newNonce !== dpopNonce; 142 dpopNonce = newNonce ?? dpopNonce; 143 144 if (nonceChanged && 400 <= res.status && res.status <= 499 && attempts < MAX_DPOP_RETRIES - 1) continue; 145 146 const text = await res.text(); 147 try { 148 return { json: JSON.parse(text), dpopNonce }; 149 } catch { 150 throw new Error(`Unexpected response (${res.status}): ${text.slice(0, 200)}`); 151 } 152 } 153 154 throw new Error("DPoP nonce retry failed"); 155} 156 157const DB_NAME = "atproto:oauth"; 158const DB_VERSION = 7; 159const SESSIONS_STORE = "sessions"; 160 161/** @type {Promise<IDBDatabase> | null} */ 162let dbPromise = null; 163 164/** @returns {Promise<IDBDatabase>} */ 165function openDb() { 166 if (dbPromise) return dbPromise; 167 dbPromise = new Promise((resolve, reject) => { 168 const req = indexedDB.open(DB_NAME, DB_VERSION); 169 req.onupgradeneeded = () => { 170 const db = req.result; 171 const tx = req.transaction; 172 if (!tx) throw new Error("Missing IndexedDB upgrade transaction"); 173 174 if (!db.objectStoreNames.contains("attempts")) db.createObjectStore("attempts", { keyPath: "state" }); 175 176 const keyPath = ["swScope", "did"]; 177 let ssns; 178 if (db.objectStoreNames.contains(SESSIONS_STORE)) { 179 ssns = tx.objectStore(SESSIONS_STORE); 180 if (JSON.stringify(ssns.keyPath) !== JSON.stringify(keyPath)) { 181 db.deleteObjectStore(SESSIONS_STORE); 182 ssns = db.createObjectStore(SESSIONS_STORE, { keyPath }); 183 } 184 } else { 185 ssns = db.createObjectStore(SESSIONS_STORE, { keyPath }); 186 } 187 188 if (!ssns.indexNames.contains("swScope")) ssns.createIndex("swScope", "swScope", { unique: false }); 189 if (!ssns.indexNames.contains("swScopePds")) 190 ssns.createIndex("swScopePds", ["swScope", "pds"], { unique: false }); 191 }; 192 req.onsuccess = () => resolve(req.result); 193 req.onerror = () => { 194 dbPromise = null; 195 reject(req.error); 196 }; 197 }); 198 199 return dbPromise; 200} 201 202/** 203 * @param {IDBTransactionMode} mode 204 * @param {string} store 205 * @param {(s: IDBObjectStore) => IDBRequest} fn 206 * @returns {Promise<any>} 207 */ 208async function idb(mode, store, fn) { 209 const db = await openDb(); 210 return new Promise((resolve, reject) => { 211 const tx = db.transaction(store, mode); 212 const req = fn(tx.objectStore(store)); 213 req.onsuccess = () => resolve(req.result); 214 req.onerror = () => reject(req.error); 215 }); 216} 217 218/** @param {AttemptSession} v */ 219const putAttempt = v => idb("readwrite", "attempts", s => s.put(v)); 220 221/** @param {string} state @returns {Promise<AttemptSession | undefined>} */ 222const getAttempt = state => idb("readonly", "attempts", s => s.get(state)); 223 224/** @param {string} state */ 225const deleteAttempt = state => idb("readwrite", "attempts", s => s.delete(state)); 226 227/** @returns {Promise<AttemptSession[]>} */ 228const getAllAttempts = () => idb("readonly", "attempts", s => s.getAll()); 229 230/** Deletes abandoned login attempts older than ATTEMPT_TTL */ 231async function expireAttempts() { 232 const attempts = await getAllAttempts(); 233 const now = Date.now(); 234 await Promise.all( 235 attempts 236 .filter(attempt => !attempt.createdAt || attempt.createdAt < now - ATTEMPT_TTL) 237 .map(attempt => deleteAttempt(attempt.state)) 238 ); 239} 240 241/** @param {OAuthSession} v */ 242const putSession = v => idb("readwrite", SESSIONS_STORE, s => s.put(v)); 243 244/** @param {string} swScope @param {string} did */ 245const deleteSession = (swScope, did) => idb("readwrite", SESSIONS_STORE, s => s.delete([swScope, did])); 246 247/** @returns {ServiceWorkerGlobalScope | undefined} */ 248function getServiceWorker() { 249 if (typeof ServiceWorkerGlobalScope === "undefined" || !(globalThis instanceof ServiceWorkerGlobalScope)) return; 250 return /** @type {ServiceWorkerGlobalScope} */ (/** @type {unknown} */ (globalThis)); 251} 252 253/** @returns {Promise<string>} */ 254async function currentScope() { 255 const sw = getServiceWorker(); 256 if (sw) return sw.registration.scope; 257 258 const nav = /** @type {Navigator & { serviceWorker?: ServiceWorkerContainer }} */ (globalThis.navigator); 259 const registration = await nav.serviceWorker?.getRegistration(); 260 if (!registration) throw new Error("register a service worker before using sessions"); 261 return registration.scope; 262} 263 264/** @returns {Promise<OAuthSession[]>} */ 265export const listSessions = async () => { 266 const swScope = await currentScope(); 267 return idb("readonly", SESSIONS_STORE, s => s.index("swScope").getAll(swScope)); 268}; 269 270/** @param {string} swScope @param {string} did @returns {Promise<OAuthSession | undefined>} */ 271const readSession = (swScope, did) => idb("readonly", SESSIONS_STORE, s => s.get([swScope, did])); 272 273/** @param {string} did @returns {Promise<OAuthSession | undefined>} */ 274export const getSession = async did => readSession(await currentScope(), did); 275 276/** @param {string} swScope @param {string} pds @returns {Promise<OAuthSession[]>} */ 277const listSessionsByPDS = (swScope, pds) => 278 idb("readonly", SESSIONS_STORE, s => s.index("swScopePds").getAll([swScope, pds])); 279 280/** @param {string} did */ 281export async function logOut(did) { 282 const swScope = await currentScope(); 283 const session = await readSession(swScope, did); 284 285 if (session?.revocationEndpoint) { 286 try { 287 const token = session.refreshToken ?? session.accessToken; 288 const body = new URLSearchParams({ token, client_id: session.clientId }); 289 await dpopPost(session.dpopKey, session.revocationEndpoint, body, session.dpopNonce); 290 } catch {} 291 } 292 293 return deleteSession(swScope, did); 294} 295 296/** 297 * @typedef {Object} SessionEndedMessage 298 * @property {string} did 299 * @property {string} swScope 300 */ 301 302/** 303 * Registers a callback for when a session ends because its refresh token was rejected. Returns a function that 304 * unsubscribes the callback. 305 * 306 * @param {(msg: SessionEndedMessage) => void} fn 307 * @returns {() => void} 308 */ 309export function onSessionEnded(fn) { 310 const nav = /** @type {Navigator & { serviceWorker?: ServiceWorkerContainer }} */ (globalThis.navigator); 311 if (!nav.serviceWorker) throw new Error("register a service worker before using sessions"); 312 const serviceWorker = nav.serviceWorker; 313 314 /** @param {MessageEvent} e */ 315 const listener = async e => { 316 if (e.data?.type !== "atsw:session-ended") return; 317 if (e.data.swScope !== (await currentScope())) return; 318 fn(e.data); 319 }; 320 321 serviceWorker.addEventListener("message", listener); 322 return () => serviceWorker.removeEventListener("message", listener); 323} 324 325/** @param {string} handle */ 326export async function resolveDID(handle) { 327 // try to resolve DID using the DNS record 328 try { 329 const r = await fetch(`https://dns.google/resolve?name=_atproto.${encodeURIComponent(handle)}&type=TXT`); 330 const j = await r.json(); 331 const txt = j.Answer?.find(/** @param {any} a */ a => a.data?.startsWith('"did=')); 332 if (txt) return /** @type {string} */ (txt.data.replace(/"/g, "").replace("did=", "")); 333 } catch {} 334 335 // HTTP .well-known resolution is blocked by CORS from the browser, so fall 336 // back to a public AppView which exposes a CORS-enabled resolver. 337 const r = await fetch( 338 `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` 339 ); 340 const j = await r.json(); 341 if (!j.did) throw new Error(`Could not resolve handle ${handle}: ${JSON.stringify(j)}`); 342 return /** @type {string} */ (j.did); 343} 344 345/** @param {string} url */ 346function assertHttps(url) { 347 if (new URL(url).protocol !== "https:") throw new Error(`Expected https URL, got ${url}`); 348 return url; 349} 350 351/** 352 * @param {string} did 353 * @param {string} [handle] When given, verifies the DID document lists it in alsoKnownAs 354 */ 355async function resolvePDS(did, handle) { 356 // find URL of DID doc 357 const url = did.startsWith("did:web:") 358 ? `https://${did.split(":")[2]}/.well-known/did.json` 359 : `https://plc.directory/${did}`; 360 361 /** @type {{ service?: { type: string; serviceEndpoint: string }[]; alsoKnownAs?: string[] }} */ 362 const doc = await fetch(url).then(res => res.json()); 363 364 if (handle && !doc.alsoKnownAs?.includes(`at://${handle}`)) { 365 throw new Error(`DID document for ${did} does not list handle ${handle}`); 366 } 367 368 // get service endpoint 369 const endpoint = doc.service?.find(({ type }) => type === "AtprotoPersonalDataServer")?.serviceEndpoint; 370 if (!endpoint) throw new Error(`No PDS found for ${did}`); 371 372 return assertHttps(endpoint); 373} 374 375/** 376 * @param {string} pds 377 * @returns {Promise<AuthServerMetadata>} 378 */ 379async function discoverAuthServer(pds) { 380 /** @type {AuthServerMetadata | undefined} */ 381 let meta; 382 try { 383 const res = await fetch(`${pds}/.well-known/oauth-protected-resource`).then(res => res.json()); 384 const issuer = /** @type {string} */ (res.authorization_servers[0]); 385 meta = await fetch(`${issuer}/.well-known/oauth-authorization-server`).then(res => res.json()); 386 } catch { 387 meta = await fetch(`${pds}/.well-known/oauth-authorization-server`).then(res => res.json()); 388 } 389 390 if (!meta?.authorization_endpoint || !meta.token_endpoint || !meta.pushed_authorization_request_endpoint) { 391 throw new Error(`Could not discover OAuth endpoints for ${pds}`); 392 } 393 394 assertHttps(meta.issuer); 395 assertHttps(meta.authorization_endpoint); 396 assertHttps(meta.token_endpoint); 397 assertHttps(meta.pushed_authorization_request_endpoint); 398 if (meta.revocation_endpoint) assertHttps(meta.revocation_endpoint); 399 400 return meta; 401} 402 403/** 404 * Start the OAuth login flow. Stores an attempt session in IndexedDB and redirects the browser to the authorization 405 * server. When the auth server redirects back, the service worker will intercept the callback and complete the token 406 * exchange. 407 * 408 * @param {OAuthConfig} config 409 * @param {string} handle A handle, or an https:// URL of a PDS/entryway to start login without one 410 * @returns {Promise<void>} 411 */ 412export async function logIn(config, handle) { 413 const input = handle.trim(); 414 415 const swScope = await currentScope(); 416 417 /** @type {string | undefined} */ 418 let did; 419 /** @type {string} */ 420 let pds; 421 if (input.startsWith("https://")) { 422 pds = assertHttps(input); 423 } else { 424 handle = input.toLowerCase(); 425 did = await resolveDID(handle); 426 pds = await resolvePDS(did, handle); 427 } 428 429 const [meta, pkce, dpopKey] = await Promise.all([discoverAuthServer(pds), generatePKCE(), generateDPoPKey()]); 430 const state = randomB64url(16); 431 432 await expireAttempts(); 433 await putAttempt({ 434 state, 435 verifier: pkce.verifier, 436 dpopKey, 437 tokenEndpoint: meta.token_endpoint, 438 issuer: meta.issuer, 439 ...(did ? { did } : {}), 440 pds: new URL(pds).origin, 441 config, 442 swScope, 443 createdAt: Date.now(), 444 revocationEndpoint: meta.revocation_endpoint 445 }); 446 447 const body = new URLSearchParams({ 448 client_id: config.clientId, 449 redirect_uri: config.redirectUri, 450 response_type: "code", 451 scope: config.scope, 452 state, 453 code_challenge: pkce.challenge, 454 code_challenge_method: "S256" 455 }); 456 if (did) body.set("login_hint", handle); 457 458 const { json } = await dpopPost(dpopKey, meta.pushed_authorization_request_endpoint, body); 459 if (json.error) throw new Error("PAR error: " + JSON.stringify(json)); 460 461 const authUrl = new URL(meta.authorization_endpoint); 462 authUrl.searchParams.set("client_id", config.clientId); 463 authUrl.searchParams.set("request_uri", json.request_uri); 464 location.href = authUrl.href; 465} 466 467/** 468 * @param {URL} url 469 * @param {string} redirectUri 470 */ 471function matchesRedirectUri(url, redirectUri) { 472 const expected = new URL(redirectUri); 473 if (url.origin !== expected.origin || url.pathname !== expected.pathname) return false; 474 475 for (const [key, value] of expected.searchParams) { 476 if (url.searchParams.get(key) !== value) return false; 477 } 478 479 return true; 480} 481 482/** 483 * @param {OAuthSession} session 484 * @param {TokenResponse} json 485 * @param {string} [dpopNonce] 486 */ 487function applyTokenResponse(session, json, dpopNonce) { 488 session.accessToken = json.access_token; 489 session.expiresAt = Date.now() + (json.expires_in ?? DEFAULT_TOKEN_TTL) * 1000; 490 if (json.refresh_token) session.refreshToken = json.refresh_token; 491 session.dpopNonce = dpopNonce; 492 493 return session; 494} 495 496/** 497 * Run the OAuth callback handler. 498 * 499 * @param {AttemptSession} attempt 500 * @param {string} code 501 * @param {string} state 502 */ 503async function callback(attempt, code, state) { 504 const swScope = attempt.swScope; 505 const body = new URLSearchParams({ 506 grant_type: "authorization_code", 507 code, 508 redirect_uri: attempt.config.redirectUri, 509 client_id: attempt.config.clientId, 510 code_verifier: attempt.verifier 511 }); 512 513 const { json, dpopNonce } = await dpopPost(attempt.dpopKey, attempt.tokenEndpoint, body); 514 if (json.error || !json.access_token) { 515 await deleteAttempt(state); 516 return new Response("token error: " + JSON.stringify(json), { status: 400 }); 517 } 518 if (attempt.did && json.sub !== attempt.did) { 519 await deleteAttempt(state); 520 return new Response("token error: sub does not match requested did", { status: 400 }); 521 } 522 if (json.scope) { 523 const requested = attempt.config.scope.split(" ").filter(Boolean); 524 const granted = new Set(json.scope.split(" ").filter(Boolean)); 525 if (!requested.every(scope => granted.has(scope))) { 526 await deleteAttempt(state); 527 return new Response("token error: granted scope does not match requested scope", { status: 400 }); 528 } 529 } 530 531 let did = attempt.did; 532 let pds = attempt.pds; 533 if (!did) { 534 if (!json.sub) { 535 await deleteAttempt(state); 536 return new Response("token error: missing sub", { status: 400 }); 537 } 538 539 const resolved = await resolvePDS(json.sub); 540 const meta = await discoverAuthServer(resolved); 541 if (meta.issuer !== attempt.issuer) { 542 await deleteAttempt(state); 543 return new Response("token error: auth server is not authoritative for sub", { status: 400 }); 544 } 545 546 did = /** @type {string} */ (json.sub); 547 pds = new URL(resolved).origin; 548 } 549 550 /** @type {OAuthSession} */ 551 const session = { 552 pds, 553 did, 554 swScope, 555 dpopKey: attempt.dpopKey, 556 tokenEndpoint: attempt.tokenEndpoint, 557 clientId: attempt.config.clientId, 558 accessToken: "", 559 expiresAt: 0, 560 revocationEndpoint: attempt.revocationEndpoint 561 }; 562 applyTokenResponse(session, json, dpopNonce); 563 await putSession(session); 564 await deleteAttempt(state); 565 566 const dest = new URL(attempt.config.redirectUri); 567 return Response.redirect(dest.href, 302); 568} 569 570/** 571 * Given a request, run the OAuth callback handler if it matches a live login attempt. Returns null when the request is 572 * not an OAuth callback. 573 * 574 * @param {Request} req 575 * @returns {Promise<Response | null>} 576 */ 577export async function handleCallback(req) { 578 const url = new URL(req.url); 579 const state = url.searchParams.get("state"); 580 const code = url.searchParams.get("code"); 581 if (!state || !code) return null; 582 583 const attempt = await getAttempt(state); 584 if (!attempt || !matchesRedirectUri(url, attempt.config.redirectUri)) return null; 585 if (url.searchParams.get("iss") !== attempt.issuer) return new Response("iss mismatch", { status: 400 }); 586 587 return callback(attempt, code, state); 588} 589 590/** @type {Map<string, Promise<OAuthSession>>} */ 591const refreshLocks = new Map(); 592 593/** @param {OAuthSession} session */ 594const sessionLockKey = session => `${session.swScope}\n${session.did}`; 595 596/** @param {OAuthSession} session */ 597async function refresh(session) { 598 const { tokenEndpoint, dpopKey, refreshToken, clientId: client_id } = session; 599 const refresh_token = /** @type {string} */ (refreshToken); 600 const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token, client_id }); 601 const { json, dpopNonce } = await dpopPost(dpopKey, tokenEndpoint, body, session.dpopNonce); 602 if (json.error || !json.access_token) { 603 if (json.error === "invalid_grant") { 604 await deleteSession(session.swScope, session.did); 605 606 const sw = getServiceWorker(); 607 if (sw) { 608 sw.clients 609 .matchAll() 610 .then(clients => 611 clients.forEach(client => 612 client.postMessage({ type: "atsw:session-ended", did: session.did, swScope: session.swScope }) 613 ) 614 ); 615 } 616 } 617 throw new Error("Refresh error: " + JSON.stringify(json)); 618 } 619 620 applyTokenResponse(session, json, dpopNonce); 621 await putSession(session); 622 return session; 623} 624 625/** @param {OAuthSession} session */ 626async function ensureFresh(session) { 627 if (session.accessToken && session.expiresAt > Date.now() + EXPIRY_MARGIN) return session; 628 if (!session.refreshToken) return session; 629 630 // see if this session is already being refreshed 631 const key = sessionLockKey(session); 632 let lock = refreshLocks.get(key); 633 if (!lock) { 634 lock = refresh(session).finally(() => refreshLocks.delete(key)); 635 refreshLocks.set(key, lock); 636 } 637 638 return lock; 639} 640 641/** @param {Request} req */ 642export async function authedFetch(req) { 643 const url = new URL(req.url); 644 const did = req.headers.get(DID_HEADER); 645 if (!did && !url.pathname.startsWith("/xrpc/")) return fetch(req); 646 647 const swScope = await currentScope(); 648 649 /** @type {OAuthSession | undefined} */ 650 let session; 651 if (did) { 652 session = await readSession(swScope, did); 653 if (session && session.pds !== url.origin) session = undefined; 654 } else { 655 const sessions = await listSessionsByPDS(swScope, url.origin); 656 if (sessions.length > 1) throw new Error(`Multiple sessions for ${url.origin}; set "x-atsw-did" header`); 657 session = sessions[0]; 658 } 659 660 if (!session) { 661 if (!did) return fetch(req); 662 663 const headers = new Headers(req.headers); 664 headers.delete(DID_HEADER); 665 return fetch(new Request(req.clone(), { headers })); 666 } 667 session = await ensureFresh(session); 668 669 const htu = url.origin + url.pathname; 670 const htm = req.method; 671 672 let res = new Response(); 673 let wwwAuthenticate = ""; 674 for (let attempt = 0; attempt < MAX_DPOP_RETRIES; attempt++) { 675 if (attempt > 0) { 676 session = (await readSession(swScope, session.did)) ?? session; 677 if (!wwwAuthenticate.includes("use_dpop_nonce")) { 678 session.expiresAt = 0; 679 session = await ensureFresh(session); 680 } 681 } 682 683 const accessToken = session.accessToken; 684 const dpopNonce = session.dpopNonce; 685 const ath = b64url(await crypto.subtle.digest("SHA-256", enc.encode(accessToken))); 686 const dpop = await createDPoP(session.dpopKey, htm, htu, dpopNonce, ath); 687 688 const headers = new Headers(req.headers); 689 headers.delete(DID_HEADER); 690 headers.set("authorization", `DPoP ${accessToken}`); 691 headers.set("dpop", dpop); 692 693 res = await fetch(new Request(req.clone(), { headers })); 694 const nonce = res.headers.get("dpop-nonce"); 695 if (nonce && nonce !== dpopNonce) { 696 session.dpopNonce = nonce; 697 await putSession(session); 698 } 699 700 if (res.status !== 401) break; 701 wwwAuthenticate = res.headers.get("www-authenticate") ?? ""; 702 } 703 704 return /** @type {Response} */ (res); 705} 706 707/** 708 * Given a request, run the OAuth callback handler if it matches a live login attempt, pass navigations through, or 709 * attempt an authenticated fetch request. 710 * 711 * @param {Request} req 712 */ 713export async function handleFetch(req) { 714 const res = await handleCallback(req); 715 if (res) return res; 716 717 if (req.mode === "navigate") return fetch(req); 718 return authedFetch(req); 719} 720 721// if this is running in a service worker, set it up to handle 722// OAuth callbacks and to proxy authenticated requests to the PDS 723const sw = getServiceWorker(); 724if (sw && import.meta.url === sw.location.href) { 725 sw.oninstall = () => sw.skipWaiting(); 726 sw.onactivate = e => e.waitUntil(sw.clients.claim()); 727 sw.onfetch = e => e.respondWith(handleFetch(e.request)); 728}