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.