PageSourceSearch

https://impro.social/js/oauth.16d187959c.js

js impro.social collected 2026-09-25 22:27:46 UTC 24,100 bytes, 813 lines download raw bytes

1import { HandleNotFoundError } from "/js/atproto.js";
2import { KVIndexedDB } from "/js/utils.js";
3
4export { HandleNotFoundError } from "/js/atproto.js";
5
6// Inspiration from:
7// https://www.npmjs.com/package/@atproto/oauth-client-browser
8// https://www.npmjs.com/package/@atcute/oauth-browser-client
9
10function base64UrlEncode(buffer) {
11  const bytes = new Uint8Array(buffer);
12  let binary = "";
13  for (let i = 0; i < bytes.length; i++) {
14    binary += String.fromCharCode(bytes[i]);
15  }
16  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
17}
18
19function generateRandomString(length) {
20  const array = new Uint8Array(length);
21  window.crypto.getRandomValues(array);
22  return base64UrlEncode(array);
23}
24
25function encodeUtf8(str) {
26  return new TextEncoder().encode(str);
27}
28
29async function signJWT(header, payload, privateKey) {
30  const headerB64 = base64UrlEncode(encodeUtf8(JSON.stringify(header)));
31  const payloadB64 = base64UrlEncode(encodeUtf8(JSON.stringify(payload)));
32
33  const signatureInput = `${headerB64}.${payloadB64}`;
34  const signatureBuffer = await window.crypto.subtle.sign(
35    { name: "ECDSA", hash: "SHA-256" },
36    privateKey,
37    encodeUtf8(signatureInput),
38  );
39
40  const signatureB64 = base64UrlEncode(signatureBuffer);
41  return `${headerB64}.${payloadB64}.${signatureB64}`;
42}
43
44async function sha256(data) {
45  const dataBuffer = encodeUtf8(data);
46  const hashBuffer = await window.crypto.subtle.digest("SHA-256", dataBuffer);
47  return base64UrlEncode(hashBuffer);
48}
49
50async function fetchResourceServerMetadata(pdsUrl) {
51  const metadataUrl = new URL("/.well-known/oauth-protected-resource", pdsUrl);
52  const response = await fetch(metadataUrl);
53  if (!response.ok) {
54    throw new Error("Failed to fetch resource server metadata");
55  }
56  return await response.json();
57}
58
59async function fetchAuthServerMetadata(authServerUrl) {
60  const metadataUrl = new URL(
61    "/.well-known/oauth-authorization-server",
62    authServerUrl,
63  );
64  const response = await fetch(metadataUrl);
65  if (!response.ok) {
66    throw new Error("Failed to fetch auth server metadata");
67  }
68  return await response.json();
69}
70
71const CLIENT_ASSERTION_TYPE =
72  "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
73
74// If confidential oauth is enabled, include a server-signed client assertion
75async function clientAuthParams(authServerMetadata) {
76  if (!window.env?.useConfidentialOauth) return {};
77  const response = await fetch("/oauth/assertion", {
78    method: "POST",
79    headers: { "Content-Type": "application/json" },
80    body: JSON.stringify({ aud: authServerMetadata.issuer }),
81  });
82  if (!response.ok) {
83    throw new Error(`Client assertion request failed: ${response.status}`);
84  }
85  const { assertion } = await response.json();
86  return {
87    client_assertion_type: CLIENT_ASSERTION_TYPE,
88    client_assertion: assertion,
89  };
90}
91
92const SESSION_KEY_PREFIX = "oauth_session:";
93const ACCOUNTS_KEY = "oauth_accounts";
94const CURRENT_DID_KEY = "oauth_current_did";
95const IN_FLIGHT_PREFIX = "oauth_in_flight_";
96const IN_FLIGHT_MAX_AGE_MS = 10 * 60 * 1000;
97const LEGACY_SESSION_KEY = "oauth_session";
98
99function readAccounts() {
100  const raw = localStorage.getItem(ACCOUNTS_KEY);
101  if (!raw) return [];
102  try {
103    const parsed = JSON.parse(raw);
104    return Array.isArray(parsed) ? parsed : [];
105  } catch {
106    return [];
107  }
108}
109
110function writeAccounts(accounts) {
111  if (accounts.length === 0) {
112    localStorage.removeItem(ACCOUNTS_KEY);
113  } else {
114    localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts));
115  }
116}
117
118function upsertAccount(account) {
119  const accounts = readAccounts();
120  const index = accounts.findIndex((entry) => entry.did === account.did);
121  if (index >= 0) {
122    accounts[index] = { ...accounts[index], ...account };
123  } else {
124    accounts.push(account);
125  }
126  writeAccounts(accounts);
127}
128
129function getAccount(did) {
130  return readAccounts().find((entry) => entry.did === did) ?? null;
131}
132
133function deleteAccount(did) {
134  const accounts = readAccounts();
135  writeAccounts(accounts.filter((entry) => entry.did !== did));
136}
137
138function migrateLegacySession() {
139  const legacySessionData = localStorage.getItem(LEGACY_SESSION_KEY);
140  if (!legacySessionData) return;
141  if (localStorage.getItem(ACCOUNTS_KEY) !== null) {
142    // This shouldn't happen
143    localStorage.removeItem(LEGACY_SESSION_KEY);
144    return;
145  }
146  try {
147    const sessionData = JSON.parse(legacySessionData);
148    if (!sessionData?.did) {
149      localStorage.removeItem(LEGACY_SESSION_KEY);
150      return;
151    }
152    localStorage.setItem(
153      SESSION_KEY_PREFIX + sessionData.did,
154      legacySessionData,
155    );
156    writeAccounts([
157      {
158        did: sessionData.did,
159        handle: null,
160        pdsUrl: sessionData.serviceEndpoint ?? null,
161      },
162    ]);
163    localStorage.setItem(CURRENT_DID_KEY, sessionData.did);
164    localStorage.removeItem(LEGACY_SESSION_KEY);
165  } catch {
166    localStorage.removeItem(LEGACY_SESSION_KEY);
167  }
168}
169
170function removeStaleInFlightData() {
171  const now = Date.now();
172  for (let index = localStorage.length - 1; index >= 0; index--) {
173    const key = localStorage.key(index);
174    if (!key?.startsWith(IN_FLIGHT_PREFIX)) continue;
175    let stale = false;
176    try {
177      const data = JSON.parse(localStorage.getItem(key));
178      stale = !data?.createdAt || now - data.createdAt > IN_FLIGHT_MAX_AGE_MS;
179    } catch {
180      stale = true;
181    }
182    if (stale) localStorage.removeItem(key);
183  }
184}
185
186const LEGACY_DPOP_KEYPAIR_KEY = "dpop_keypair";
187const DPOP_DB_NAME = "oauth-dpop";
188const DPOP_STORE_NAME = "keys";
189const DPOP_KEYPAIR_RECORD_KEY = "keypair";
190
191class DPoPKeyStore {
192  constructor() {
193    this._db = new KVIndexedDB(DPOP_DB_NAME, DPOP_STORE_NAME);
194  }
195
196  async get() {
197    return (await this._db.get(DPOP_KEYPAIR_RECORD_KEY)) ?? null;
198  }
199
200  async put(keypair) {
201    await this._db.put(DPOP_KEYPAIR_RECORD_KEY, keypair);
202  }
203}
204
205async function migrateDPoPKeypairFromLocalStorage(keyStore) {
206  const dpopKeypairStr = localStorage.getItem(LEGACY_DPOP_KEYPAIR_KEY);
207  if (!dpopKeypairStr) return null;
208  let keypair;
209  try {
210    const { privkey, pubkey } = JSON.parse(dpopKeypairStr);
211    const privateKey = await window.crypto.subtle.importKey(
212      "jwk",
213      privkey,
214      { name: "ECDSA", namedCurve: "P-256" },
215      false,
216      ["sign"],
217    );
218    keypair = { privateKey, publicJwk: pubkey };
219  } catch (error) {
220    console.warn("oauth: DPoP keypair migration failed", error);
221    localStorage.removeItem(LEGACY_DPOP_KEYPAIR_KEY);
222    return null;
223  }
224  try {
225    await keyStore.put(keypair);
226    localStorage.removeItem(LEGACY_DPOP_KEYPAIR_KEY);
227  } catch (error) {
228    console.warn("oauth: failed to persist migrated DPoP keypair", error);
229  }
230  return keypair;
231}
232
233async function loadOrGenerateDPoPKeypair() {
234  const keyStore = new DPoPKeyStore();
235  const migrated = await migrateDPoPKeypairFromLocalStorage(keyStore);
236  if (migrated) return migrated;
237  try {
238    const stored = await keyStore.get();
239    if (stored) return stored;
240  } catch (error) {
241    console.warn("oauth: failed to read DPoP keypair from storage", error);
242  }
243  const generatedPair = await window.crypto.subtle.generateKey(
244    {
245      name: "ECDSA",
246      namedCurve: "P-256",
247    },
248    false,
249    ["sign", "verify"],
250  );
251  const publicJwk = await window.crypto.subtle.exportKey(
252    "jwk",
253    generatedPair.publicKey,
254  );
255  const keypair = { privateKey: generatedPair.privateKey, publicJwk };
256  try {
257    await keyStore.put(keypair);
258  } catch (error) {
259    console.warn("oauth: failed to persist DPoP keypair", error);
260  }
261  return keypair;
262}
263
264class DPoPRequests {
265  constructor(dpopKeypair) {
266    this.dpopKeypair = dpopKeypair;
267    this.nonces = new Map();
268  }
269
270  async fetch(url, options, retryCount = 0) {
271    const origin = new URL(url).origin;
272    const nonce = this.nonces.get(origin) ?? null;
273    const method = options.method ?? "GET";
274    const authHeader = options.headers?.Authorization;
275    const accessToken = authHeader?.includes("DPoP ")
276      ? authHeader.split(" ")[1]
277      : null;
278    const proof = await this.createProof(method, url, nonce, accessToken);
279    const response = await fetch(url, {
280      ...options,
281      headers: {
282        ...options.headers,
283        DPoP: proof,
284      },
285    });
286    // Set nonce if provided
287    const dpopNonce = response.headers.get("DPoP-Nonce");
288    if (dpopNonce) {
289      this.nonces.set(origin, dpopNonce);
290    }
291    // Handle nonce errors - retry once with the new nonce
292    if (
293      !response.ok &&
294      [401, 400].includes(response.status) &&
295      retryCount === 0 &&
296      !options.noDpopRetry // NOTE: special option if we just want to get the dpop nonce
297    ) {
298      try {
299        const errorData = await response.json();
300        // attach consumed body to the response, in case the parent needs it
301        response.data = errorData;
302        if (errorData.error === "use_dpop_nonce") {
303          return await this.fetch(url, options, retryCount + 1);
304        }
305      } catch {
306        // pass
307      }
308    }
309    return response;
310  }
311
312  async createProof(method, url, dpopNonce = null, accessToken = null) {
313    const publicJwk = this.dpopKeypair.publicJwk;
314    const header = {
315      typ: "dpop+jwt",
316      alg: "ES256",
317      jwk: {
318        kty: publicJwk.kty,
319        crv: publicJwk.crv,
320        x: publicJwk.x,
321        y: publicJwk.y,
322      },
323    };
324
325    const payload = {
326      jti: generateRandomString(32),
327      htm: method,
328      htu: url,
329      iat: Math.floor(Date.now() / 1000),
330    };
331
332    if (dpopNonce) {
333      payload.nonce = dpopNonce;
334    }
335
336    if (accessToken) {
337      payload.ath = await sha256(accessToken);
338    }
339
340    return signJWT(header, payload, this.dpopKeypair.privateKey);
341  }
342}
343
344export class TokenRefreshError extends Error {
345  constructor(message) {
346    super(message);
347    this.name = "TokenRefreshError";
348  }
349}
350
351export class Session {
352  static refreshBackoffMs = 500;
353  static concurrentRefreshRecoveryMs = 1000;
354  static refreshTimeoutMs = 30000;
355
356  constructor(sessionData, dpopRequests) {
357    this.sessionData = sessionData;
358    this.dpopRequests = dpopRequests;
359    this.pendingRefresh = null;
360  }
361
362  static async load(dpopRequests, did) {
363    if (!did) {
364      return null;
365    }
366    const sessionDataStr = localStorage.getItem(SESSION_KEY_PREFIX + did);
367    if (!sessionDataStr) {
368      return null;
369    }
370    const sessionData = JSON.parse(sessionDataStr);
371    return new Session(sessionData, dpopRequests);
372  }
373
374  save() {
375    localStorage.setItem(
376      SESSION_KEY_PREFIX + this.sessionData.did,
377      JSON.stringify(this.sessionData),
378    );
379  }
380
381  async refreshToken({ retryCount = 0 } = {}) {
382    const authServer = new AuthServer(
383      this.sessionData.authServerMetadata,
384      this.dpopRequests,
385    );
386
387    const usedRefreshToken = this.sessionData.refreshToken;
388    const params = {
389      grant_type: "refresh_token",
390      refresh_token: usedRefreshToken,
391      client_id: this.sessionData.clientId,
392    };
393
394    const maxRetries = 2;
395    const backoffMs = Session.refreshBackoffMs * Math.pow(2, retryCount);
396
397    let response;
398    try {
399      response = await authServer.refresh(params, {
400        signal: AbortSignal.timeout(Session.refreshTimeoutMs),
401      });
402    } catch (error) {
403      if (retryCount < maxRetries) {
404        await new Promise((resolve) => setTimeout(resolve, backoffMs));
405        return await this.refreshToken({ retryCount: retryCount + 1 });
406      }
407      // Transient network error after retries — surface as a non-logout error
408      throw new Error(`Token refresh failed (network): ${error.message}`);
409    }
410
411    if (!response.ok) {
412      if (response.status >= 500) {
413        if (retryCount < maxRetries) {
414          await new Promise((resolve) => setTimeout(resolve, backoffMs));
415          return await this.refreshToken({ retryCount: retryCount + 1 });
416        }
417        const body = response.data
418          ? JSON.stringify(response.data)
419          : await response.text();
420        throw new Error(`Token refresh failed (server): ${body}`);
421      }
422      // invalid_grant can mean another tab rotated the refresh token, so re-check storage and adopt if so
423      if (response.status === 400 && response.data?.error === "invalid_grant") {
424        if (this.adoptRotatedSession(usedRefreshToken)) return;
425        // attempt twice with delay
426        await new Promise((resolve) =>
427          setTimeout(resolve, Session.concurrentRefreshRecoveryMs),
428        );
429        if (this.adoptRotatedSession(usedRefreshToken)) return;
430      }
431      const body = response.data
432        ? JSON.stringify(response.data)
433        : await response.text();
434      throw new TokenRefreshError(`Token refresh failed: ${body}`);
435    }
436
437    const newTokenResponse = await response.json();
438    this.sessionData.accessToken = newTokenResponse.access_token;
439    this.sessionData.refreshToken = newTokenResponse.refresh_token;
440    this.sessionData.expiresAt =
441      Date.now() + newTokenResponse.expires_in * 1000;
442
443    this.save();
444  }
445
446  adoptRotatedSession(usedRefreshToken) {
447    const stored = localStorage.getItem(
448      SESSION_KEY_PREFIX + this.sessionData.did,
449    );
450    if (!stored) return false;
451    try {
452      const data = JSON.parse(stored);
453      if (data?.refreshToken && data.refreshToken !== usedRefreshToken) {
454        this.sessionData = data;
455        return true;
456      }
457    } catch {
458      // pass
459    }
460    return false;
461  }
462
463  async fetch(url, { headers = {}, ...options } = {}) {
464    // refresh session if needed
465    if (Date.now() > this.sessionData.expiresAt - 60000) {
466      if (!this.pendingRefresh) {
467        this.pendingRefresh = this.refreshToken().finally(() => {
468          this.pendingRefresh = null;
469        });
470      }
471      await this.pendingRefresh;
472    }
473    return this.dpopRequests.fetch(url, {
474      headers: {
475        Authorization: `DPoP ${this.sessionData.accessToken}`,
476        ...headers,
477      },
478      ...options,
479    });
480  }
481
482  get did() {
483    return this.sessionData.did;
484  }
485
486  get handle() {
487    return getAccount(this.sessionData.did)?.handle ?? null;
488  }
489
490  get serviceEndpoint() {
491    return this.sessionData.serviceEndpoint;
492  }
493
494  get scope() {
495    return this.sessionData.scope ?? null;
496  }
497}
498
499class AuthServer {
500  constructor(authServerMetadata, dpopRequests) {
501    this.authServerMetadata = authServerMetadata;
502    this.dpopRequests = dpopRequests;
503  }
504
505  async refresh(params, { signal = null } = {}) {
506    const tokenEndpoint = this.authServerMetadata.token_endpoint;
507    const authParams = await clientAuthParams(this.authServerMetadata);
508    return this.dpopRequests.fetch(tokenEndpoint, {
509      method: "POST",
510      headers: {
511        "Content-Type": "application/x-www-form-urlencoded",
512      },
513      body: new URLSearchParams({ ...params, ...authParams }).toString(),
514      signal,
515    });
516  }
517
518  async exchangeCodeForToken(clientId, code, codeVerifier, redirectUri) {
519    const tokenEndpoint = this.authServerMetadata.token_endpoint;
520    const authParams = await clientAuthParams(this.authServerMetadata);
521    const params = {
522      grant_type: "authorization_code",
523      code,
524      redirect_uri: redirectUri,
525      code_verifier: codeVerifier,
526      client_id: clientId,
527      ...authParams,
528    };
529
530    const response = await this.dpopRequests.fetch(tokenEndpoint, {
531      method: "POST",
532      headers: {
533        "Content-Type": "application/x-www-form-urlencoded",
534      },
535      body: new URLSearchParams(params).toString(),
536    });
537
538    if (!response.ok) {
539      const error = response.data
540        ? JSON.stringify(response.data)
541        : await response.text();
542      throw new Error(`Token exchange failed: ${error}`);
543    }
544
545    return await response.json();
546  }
547
548  async sendPAR(params) {
549    const endpoint =
550      this.authServerMetadata.pushed_authorization_request_endpoint;
551    const authParams = await clientAuthParams(this.authServerMetadata);
552    const body = new URLSearchParams({ ...params, ...authParams });
553    const response = await this.dpopRequests.fetch(endpoint, {
554      method: "POST",
555      headers: {
556        "Content-Type": "application/x-www-form-urlencoded",
557      },
558      body: body.toString(),
559    });
560
561    if (!response.ok) {
562      console.error("PAR request failed", response);
563      throw new Error("PAR request failed");
564    }
565
566    return await response.json();
567  }
568}
569
570export class InvalidAuthUrlError extends Error {
571  constructor(message) {
572    super(message);
573    this.name = "InvalidAuthUrlError";
574  }
575}
576
577export class OauthClient {
578  constructor({ clientId, redirectUri, dpopKeypair, identityResolver }) {
579    this.clientId = clientId;
580    this.redirectUri = redirectUri;
581    this.dpopRequests = new DPoPRequests(dpopKeypair);
582    this.sessionsByDid = new Map();
583    this.identityResolver = identityResolver;
584  }
585
586  static async load({ clientId, redirectUri, identityResolver }) {
587    const dpopKeypair = await loadOrGenerateDPoPKeypair();
588    migrateLegacySession();
589    return new OauthClient({
590      clientId,
591      redirectUri,
592      dpopKeypair,
593      identityResolver,
594    });
595  }
596
597  async getAuthorizationUrl(handle, { scope = "atproto", state = {} } = {}) {
598    const result = await this.identityResolver.resolveEndpoint(handle);
599    if (!result) {
600      throw new HandleNotFoundError("DID not found for handle: " + handle);
601    }
602    const { did, pds: pdsEndpoint } = result;
603    const resourceMetadata = await fetchResourceServerMetadata(pdsEndpoint);
604    if (
605      !resourceMetadata.authorization_servers ||
606      resourceMetadata.authorization_servers.length !== 1
607    ) {
608      throw new Error("Expected exactly one authorization server");
609    }
610    const authServerUrl = resourceMetadata.authorization_servers[0];
611    const authServerMetadata = await fetchAuthServerMetadata(authServerUrl);
612
613    const codeVerifier = generateRandomString(64);
614    const codeChallenge = await sha256(codeVerifier);
615    const requestId = generateRandomString(32);
616
617    const inFlightData = {
618      codeVerifier,
619      did,
620      handle,
621      serviceEndpoint: pdsEndpoint,
622      authServerUrl,
623      authServerMetadata,
624      redirectUri: this.redirectUri,
625      createdAt: Date.now(),
626    };
627    localStorage.setItem(
628      `${IN_FLIGHT_PREFIX}${requestId}`,
629      JSON.stringify(inFlightData),
630    );
631
632    const authServer = new AuthServer(authServerMetadata, this.dpopRequests);
633    const parResponse = await authServer.sendPAR({
634      client_id: this.clientId,
635      response_type: "code",
636      response_mode: "query",
637      redirect_uri: this.redirectUri,
638      state: encodeURIComponent(JSON.stringify({ requestId, ...state })),
639      code_challenge: codeChallenge,
640      code_challenge_method: "S256",
641      scope,
642      login_hint: handle,
643    });
644    let authUrl = null;
645    try {
646      authUrl = new URL(authServerMetadata.authorization_endpoint);
647    } catch (error) {
648      throw new InvalidAuthUrlError("Error parsing authorization URL");
649    }
650    if (authUrl.protocol !== "https:") {
651      throw new InvalidAuthUrlError("Authorization URL protocol must be HTTPS");
652    }
653    authUrl.searchParams.set("client_id", this.clientId);
654    authUrl.searchParams.set("request_uri", parResponse.request_uri);
655    return authUrl.toString();
656  }
657
658  async handleCallback({ code, state: stateStr, iss }) {
659    if (!code || !stateStr) {
660      throw new Error("Missing code or state in callback");
661    }
662
663    const { requestId } = JSON.parse(decodeURIComponent(stateStr));
664    const inFlightDataStr = localStorage.getItem(
665      `${IN_FLIGHT_PREFIX}${requestId}`,
666    );
667    if (!inFlightDataStr) {
668      throw new Error("No in-flight data found for requestId");
669    }
670
671    const inFlightData = JSON.parse(inFlightDataStr);
672
673    if (iss !== inFlightData.authServerUrl) {
674      throw new Error("Issuer mismatch");
675    }
676
677    const authServer = new AuthServer(
678      inFlightData.authServerMetadata,
679      this.dpopRequests,
680    );
681    const tokenResponse = await authServer.exchangeCodeForToken(
682      this.clientId,
683      code,
684      inFlightData.codeVerifier,
685      inFlightData.redirectUri,
686    );
687
688    if (tokenResponse.sub !== inFlightData.did) {
689      throw new Error("DID mismatch in token response");
690    }
691
692    const sessionData = {
693      accessToken: tokenResponse.access_token,
694      refreshToken: tokenResponse.refresh_token,
695      expiresAt: Date.now() + tokenResponse.expires_in * 1000,
696      did: tokenResponse.sub,
697      scope: tokenResponse.scope,
698      serviceEndpoint: inFlightData.serviceEndpoint,
699      authServerUrl: inFlightData.authServerUrl,
700      authServerMetadata: inFlightData.authServerMetadata,
701      clientId: this.clientId,
702    };
703
704    const session = new Session(sessionData, this.dpopRequests);
705    session.save();
706    this.sessionsByDid.set(tokenResponse.sub, session);
707
708    upsertAccount({
709      did: tokenResponse.sub,
710      handle: inFlightData.handle ?? null,
711      pdsUrl: inFlightData.serviceEndpoint,
712    });
713    localStorage.setItem(CURRENT_DID_KEY, tokenResponse.sub);
714
715    localStorage.removeItem(`${IN_FLIGHT_PREFIX}${requestId}`);
716    removeStaleInFlightData();
717
718    return session;
719  }
720
721  async getSession(did = null) {
722    const targetDid = did ?? localStorage.getItem(CURRENT_DID_KEY);
723    if (!targetDid) return null;
724    const cached = this.sessionsByDid.get(targetDid);
725    if (cached) return cached;
726    const session = await Session.load(this.dpopRequests, targetDid);
727    if (session) this.sessionsByDid.set(targetDid, session);
728    return session;
729  }
730
731  listAccounts() {
732    return readAccounts().map((account) => {
733      const sessionDataStr = localStorage.getItem(
734        SESSION_KEY_PREFIX + account.did,
735      );
736      if (sessionDataStr === null) {
737        return { ...account, scope: null, needsReauth: true };
738      }
739      let scope = null;
740      try {
741        scope = JSON.parse(sessionDataStr)?.scope ?? null;
742      } catch {
743        // pass
744      }
745      return { ...account, scope, needsReauth: false };
746    });
747  }
748
749  switchToAccount(did) {
750    const accounts = readAccounts();
751    if (!accounts.some((entry) => entry.did === did)) {
752      throw new Error(`No stored account for did: ${did}`);
753    }
754    localStorage.setItem(CURRENT_DID_KEY, did);
755  }
756
757  async _sendRevokeRequest(did) {
758    const sessionDataStr = localStorage.getItem(SESSION_KEY_PREFIX + did);
759    if (!sessionDataStr) return;
760    let sessionData;
761    try {
762      sessionData = JSON.parse(sessionDataStr);
763    } catch {
764      return;
765    }
766    const revocationEndpoint =
767      sessionData?.authServerMetadata?.revocation_endpoint;
768    const refreshToken = sessionData?.refreshToken;
769    if (!revocationEndpoint || !refreshToken) return;
770    try {
771      const authParams = await clientAuthParams(sessionData.authServerMetadata);
772      await this.dpopRequests.fetch(revocationEndpoint, {
773        method: "POST",
774        headers: {
775          "Content-Type": "application/x-www-form-urlencoded",
776        },
777        body: new URLSearchParams({
778          token: refreshToken,
779          token_type_hint: "refresh_token",
780          client_id: sessionData.clientId ?? this.clientId,
781          ...authParams,
782        }).toString(),
783      });
784    } catch (error) {
785      console.warn("oauth: revoke on sign-out failed", error);
786    }
787  }
788
789  async revoke(did = null) {
790    const targetDid = did ?? localStorage.getItem(CURRENT_DID_KEY);
791    if (!targetDid) return;
792    await this._sendRevokeRequest(targetDid);
793    localStorage.removeItem(SESSION_KEY_PREFIX + targetDid);
794    this.sessionsByDid.delete(targetDid);
795  }
796
797  async removeAccount(did) {
798    const targetDid = did ?? localStorage.getItem(CURRENT_DID_KEY);
799    if (!targetDid) return;
800    const accounts = readAccounts();
801    if (!accounts.some((entry) => entry.did === targetDid)) return;
802    await this.revoke(targetDid);
803    deleteAccount(targetDid);
804    const remaining = readAccounts();
805    if (localStorage.getItem(CURRENT_DID_KEY) === targetDid) {
806      if (remaining.length === 0) {
807        localStorage.removeItem(CURRENT_DID_KEY);
808      } else {
809        localStorage.setItem(CURRENT_DID_KEY, remaining[0].did);
810      }
811    }
812  }
813}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.