a tiny oauth browser client for atproto using a service worker
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}