Source: accounts.js

import { subjectMatches } from './caep/subjects.js';
import { OAuthError, OIDCClientError } from './errors.js';
import { IndexedDBTokenStore, MemoryTokenStore, WebStorageTokenStore } from './stores.js';
import { TokenManager } from './token-manager.js';
import { base64url, digest, Emitter, withLock } from './util.js';

/** ID token claims an account record keeps, for display and for naming the account to the OP. No tokens. */
const PROFILE_CLAIMS = ['iss', 'sub', 'sid', 'name', 'given_name', 'family_name', 'preferred_username', 'email',
  'email_verified', 'phone_number', 'phone_number_verified', 'picture', 'acr', 'amr', 'auth_time'];

/**
 * Errors the OP answers a `prompt=none` request with when it would have to show the person a screen
 * (OpenID Connect Core §3.1.2.6). `account_selection_required` is what an OP holding several signed-in
 * accounts answers when nothing in the request says which one.
 */
export const INTERACTION_REQUIRED = Object.freeze(['login_required', 'interaction_required', 'account_selection_required', 'consent_required']);

/**
 * True when `err` is an OAuthError that a request showing the sign-in screen (no `prompt=none`) would get past.
 * @param {unknown} err
 * @returns {boolean}
 */
export function needsInteraction(err) {
  return err instanceof OAuthError && INTERACTION_REQUIRED.includes(err.error);
}

/**
 * @typedef {object} Account  One person signed in through this client in this browser. Holds no token.
 * @property {string} key          Stable id derived from `iss` and `sub`.
 * @property {string} iss
 * @property {string} sub
 * @property {string} [sid]        The OP session (OpenID Connect Front-/Back-Channel Logout `sid`).
 * @property {string} [name]
 * @property {string} [email]
 * @property {string} [preferred_username]
 * @property {'signed-in'|'tokens-missing'|'signed-out'|'ended'} status
 *           `signed-in`: its tokens are in the cache. `tokens-missing`: not signed out, but its cached tokens
 *           could not be used (e.g. expired with no refresh token); `resume()` asks the OP for new ones
 *           without a screen while its session there lasts. `ended`: ended by the OP, a security event or a
 *           refused refresh; see `endedReason`.
 * @property {string} [endedReason]
 * @property {number} addedAt
 * @property {number} lastUsedAt
 * @property {number} [changedAt]  When the status last left `signed-in`.
 */

/**
 * @callback AccountHook
 * @param {Account} account
 * @param {{ getTokenSet: (opts?: { forceRefresh?: boolean }) => Promise<import('./token-set.js').TokenSet> }} access
 *        Reaches this account's tokens during the hook (and, for `onActivate`, for as long as it stays active).
 * @returns {unknown} Anything; a promise is awaited.
 */

/**
 * @typedef {object} AccountManagerOptions
 * @property {import('./stores.js').TokenStore} [store]  The cache shared by every tab: the account list and
 *           each account's tokens. Default: IndexedDB (`IndexedDBTokenStore`) when the client has a DPoP key,
 *           else memory. A persistent store without DPoP is allowed, with a console warning.
 * @property {import('./stores.js').TokenStore} [tabStore]  Where this tab remembers its active account.
 *           Default: sessionStorage where it exists, else memory.
 * @property {string} [storeKey]                Default "accounts".
 * @property {number} [refreshSkewSec]          Default 60.
 * @property {number} [maxAccounts]             Default 10. Past it the account signed out longest ago is
 *           forgotten; when every place is held by a signed-in account, a further sign-in is refused.
 * @property {number} [rememberSignedOutDays]   How long a signed-out account stays listed. Default 30.
 * @property {(account: Account) => string|undefined} [loginHint]  What names an account to the OP in
 *           `login_hint`. Default: `preferred_username`, else `email`.
 * @property {AccountHook} [onActivate]  Runs when an account is about to become this tab's active account,
 *           after its tokens are refreshed if due and before "activated" is emitted — e.g. to poll its CAEP
 *           stream and act on the signals queued while it was dormant. Throwing aborts the activation.
 * @property {(account: Account) => void|Promise<void>} [onDeactivate]  Runs when an account stops being
 *           this tab's active account (e.g. to stop polling its stream).
 * @property {AccountHook} [onLeave]  Runs before an account's tokens are deleted by `signOut()` or `end()`
 *           (e.g. to delete its CAEP stream while its tokens still work).
 * @property {boolean} [broadcast]  Tell this origin's other tabs about changes (BroadcastChannel). Default true.
 */

/**
 * Every account signed in through one client in this browser, with ONE active account per tab — for an
 * OP that keeps several signed-in accounts per browser (e.g. iya-sts `authn.multipleAccounts`).
 *
 * - **One cache, shared by every tab.** Each account's tokens are kept (IndexedDB by default) until it is
 *   signed out or its session ends, so any tab can make any signed-in account active with no trip to the OP.
 * - **One active account per tab.** Only its tokens are reachable (`getTokenSet()`), and only its tokens are
 *   refreshed in the background. The others are dormant: stored, not used, not refreshed. `activate(key)`
 *   refreshes a dormant account's tokens if they are due and runs `onActivate` before it becomes active.
 * - Refreshes hold a lock shared by the tabs (Web Locks), so two tabs never present one rotating refresh
 *   token twice; sign-outs and ended sessions are announced to the other tabs (BroadcastChannel).
 *
 * Signing in: `signIn()` lets the OP decide (its account picker when several are signed in there);
 * `chooseAccount()` always shows the picker (`prompt=select_account`); `addAccount()` is a fresh sign-in
 * (`prompt=login`). `resume(key)` and `reauthenticate(key)` name the account and refuse anybody else.
 * `signOut(key)` revokes that account's tokens and names its ID token at logout, so the OP ends that
 * account's session only.
 *
 * Events: "added" (account), "updated" (account), "activated" (account|undefined, previous|undefined),
 * "refreshed" (account) for the active account, "signed-out" (account), "ended" (account, reason),
 * "forgotten" (account), "activation_error" (account, error), and "change" after any of them, including
 * changes made in another tab.
 */
export class AccountManager extends Emitter {
  #client;
  #tokens;
  #store;
  #tabStore;
  #storeKey;
  #opts;
  /** @type {Map<string, Account>} */
  #accounts = new Map();
  #active;
  #activating;
  /** The change to the shared list this tab is making, if any (see #load). */
  #mutating;
  #channel;

  /**
   * @param {import('./oidc-client.js').OIDCClient} client
   * @param {AccountManagerOptions} [opts]
   */
  constructor(client, {
    store, tabStore, storeKey = 'accounts', refreshSkewSec = 60, maxAccounts = 10, rememberSignedOutDays = 30,
    loginHint, onActivate, onDeactivate, onLeave, broadcast = true,
  } = {}) {
    super();
    this.#client = client;
    // TOKENS AT REST NEED A SENDER CONSTRAINT (RFC 9700 §2.2.2, OAuth for browser-based apps §8). Refresh
    // tokens kept across browser restarts are only defensible when they are DPoP-bound to a key script
    // can't read out. Without DPoP the default is memory: a reload resumes with prompt=none instead.
    const bound = !!client.dpopKey;
    this.#store = store ?? (bound && typeof indexedDB !== 'undefined' ? new IndexedDBTokenStore() : new MemoryTokenStore());
    if (store && !bound && !(store instanceof MemoryTokenStore)) {
      console.warn('[oidc-caep-client] AccountManager: tokens are kept in persistent browser storage without DPoP. '
        + 'Any script in the page can read them and use them anywhere; turn on DPoP or use MemoryTokenStore.');
    }
    this.#tabStore = tabStore ?? (globalThis.sessionStorage ? new WebStorageTokenStore({ prefix: 'oidc-caep-client:tab:' }) : new MemoryTokenStore());
    this.#storeKey = storeKey;
    this.#opts = {
      maxAccounts, rememberSignedOutDays, refreshSkewSec, onActivate, onDeactivate, onLeave,
      loginHint: loginHint ?? ((a) => a.preferred_username ?? a.email),
    };
    this.#tokens = new TokenManager(client, { store: this.#store, refreshSkewSec });
    this.#tokens.on('refreshed', (key, ts) => { this.#onRefreshed(key, ts).catch(() => {}); });
    this.#tokens.on('expired', (key) => { this.end(key, 'Its tokens could not be renewed.').catch(() => {}); });
    if (broadcast && typeof BroadcastChannel !== 'undefined') {
      this.#channel = new BroadcastChannel(`oidc-caep-client:${storeKey}`);
      this.#channel.onmessage = () => { this.#onRemoteChange().catch(() => {}); };
      this.#channel.unref?.();
    }
  }

  /**
   * Loads the shared account list and reactivates the account this tab had active (refreshing its tokens
   * if due and running `onActivate`). A failed reactivation leaves the tab with no active account and is
   * emitted as "activation_error".
   */
  async init() {
    const forgetBefore = Date.now() - this.#opts.rememberSignedOutDays * 86_400_000;
    await this.#mutate(() => {
      for (const a of [...this.#accounts.values()]) {
        if (a.status === 'signed-out' && (a.changedAt ?? 0) < forgetBefore) this.#accounts.delete(a.key);
      }
    });
    const remembered = await this.#tabStore.get(this.#tabKey());
    if (remembered && this.#accounts.has(remembered)) {
      try {
        await this.#activate(remembered);
      } catch (err) {
        this.emit('activation_error', this.get(remembered), err);
      }
    }
    return this;
  }

  /** @returns {Account[]} Every account in this browser, in the order added. */
  list() {
    return [...this.#accounts.values()].sort((a, b) => a.addedAt - b.addedAt).map((a) => ({ ...a }));
  }

  /** @param {string} key @returns {Account|undefined} */
  get(key) {
    const a = this.#accounts.get(key);
    return a && { ...a };
  }

  /** @returns {Account|undefined} This tab's active account (whatever its status). */
  get active() { return this.get(this.#active); }
  get activeKey() { return this.#active; }

  /** The identity `subjectMatches()` compares a security event's subject with. */
  /**
   * @param {string} key
   * @returns {{ iss: string, sub: string, sid?: string, email?: string, email_verified?: boolean,
   *   phone_number?: string, phone_number_verified?: boolean }|undefined}
   */
  identity(key) {
    const a = this.#accounts.get(key);
    return a && { iss: a.iss, sub: a.sub, sid: a.sid, email: a.email, email_verified: a.email_verified,
      phone_number: a.phone_number, phone_number_verified: a.phone_number_verified };
  }

  /**
   * The accounts a security event's subject (`sub_id`) is about. A subject naming a session (an SSF
   * complex subject with `session`) matches only the account signed in with that session.
   * @param {object} subId
   * @returns {Account[]}
   */
  accountsForSubject(subId) {
    return this.list().filter((a) => subjectMatches(subId, this.identity(a.key)));
  }

  // ---------------------------------------------------------------------------
  // The active account's tokens: the only ones reachable
  // ---------------------------------------------------------------------------

  /**
   * The active account's tokens, refreshed first when (nearly) expired. A dormant account's tokens cannot
   * be reached: activate the account first.
   * @param {{ forceRefresh?: boolean }} [opts]
   * @returns {Promise<import('./token-set.js').TokenSet>}
   */
  getTokenSet(opts) {
    return this.#tokensFor(this.#active, opts);
  }

  /**
   * Refreshes the active account's tokens now.
   * @returns {Promise<import('./token-set.js').TokenSet>}
   */
  refresh() {
    return this.#tokensFor(this.#active, { forceRefresh: true });
  }

  async #tokensFor(key, opts) {
    if (!key || (key !== this.#active && key !== this.#activating)) {
      throw new OIDCClientError('Only the active account\'s tokens can be used; activate the account first', { code: 'not_active' });
    }
    if (this.#accounts.get(key)?.status !== 'signed-in') {
      throw new OIDCClientError('The active account is not signed in', { code: 'no_tokens' });
    }
    return this.#tokens.getTokenSet(key, opts);
  }

  // ---------------------------------------------------------------------------
  // Signing in
  // ---------------------------------------------------------------------------

  /**
   * A sign-in that leaves the choice to the OP: with several accounts signed in there, it shows its
   * account picker; with one, it answers as that account.
   * @param {Parameters<import('./oidc-client.js').OIDCClient['createAuthorizationRequest']>[0]} [opts]
   * @returns {Promise<{ url: string, state: string }>}
   */
  signIn(opts = {}) {
    return this.#client.createAuthorizationRequest({ ...opts, context: { intent: 'sign-in' } });
  }

  /**
   * A sign-in that always shows the OP's account picker (`prompt=select_account`).
   * @param {Parameters<import('./oidc-client.js').OIDCClient['createAuthorizationRequest']>[0]} [opts]
   * @returns {Promise<{ url: string, state: string }>}
   */
  chooseAccount(opts = {}) {
    return this.#client.createAuthorizationRequest({ ...opts, prompt: 'select_account', context: { intent: 'choose' } });
  }

  /**
   * A fresh sign-in (`prompt=login`), never the OP's picker: whoever signs in is added as an account.
   * @param {Parameters<import('./oidc-client.js').OIDCClient['createAuthorizationRequest']>[0]} [opts]
   * @returns {Promise<{ url: string, state: string }>}
   */
  addAccount(opts = {}) {
    return this.#client.createAuthorizationRequest({ ...opts, prompt: 'login', context: { intent: 'add' } });
  }

  /**
   * Asks the OP for this account's tokens again without showing a screen (`prompt=none`, naming the
   * account with `login_hint` and, where cached, `id_token_hint`). Answers with an OAuthError for which
   * `needsInteraction()` is true when the account's session at the OP has ended; `reauthenticate()` then.
   * @param {string} key
   * @param {Parameters<import('./oidc-client.js').OIDCClient['createAuthorizationRequest']>[0]} [opts]
   * @returns {Promise<{ url: string, state: string }>}
   */
  resume(key, opts = {}) {
    return this.#accountRequest(key, 'resume', { prompt: 'none', ...opts });
  }

  /**
   * Signs this account's person in again at the OP (`prompt=login` by default), e.g. for step-up with
   * `acrValues`/`maxAge`. A sign-in as anybody else is refused at the callback (`account_mismatch`).
   * @param {string} key
   * @param {Parameters<import('./oidc-client.js').OIDCClient['createAuthorizationRequest']>[0]} [opts]
   * @returns {Promise<{ url: string, state: string }>}
   */
  reauthenticate(key, opts = {}) {
    return this.#accountRequest(key, 'reauth', { prompt: 'login', ...opts });
  }

  async #accountRequest(key, intent, opts) {
    const a = this.#mustGet(key);
    const cached = await this.#tokens.get(key);
    return this.#client.createAuthorizationRequest({
      loginHint: this.#opts.loginHint(a),
      idTokenHint: cached?.id_token,
      ...opts,
      expectedSub: a.sub,
      context: { intent, key },
    });
  }

  /**
   * Completes any of the requests above from the redirect. The account signed in is added to the cache
   * (or, when it is already listed, signed in again) and becomes this tab's active account.
   *
   * An error the OP returned is thrown as an OAuthError with `err.context` (`{ intent, key? }`) and, for
   * `resume`/`reauth`, `err.account`.
   * @param {URLSearchParams|Record<string,string>|string} response
   * @returns {Promise<{ account: Account, tokenSet: import('./token-set.js').TokenSet, intent: string, added: boolean }>}
   */
  async handleCallback(response) {
    let result;
    try {
      result = await this.#client.completeAuthorization(response);
    } catch (err) {
      if (err.context?.key) {
        await this.#load();
        err.account = this.get(err.context.key);
      }
      throw err;
    }
    const { tokenSet, context } = result;
    const claims = tokenSet.claims;
    const key = await accountKey(claims.iss, claims.sub);
    let added = false;
    await this.#mutate(async () => {
      let account = this.#accounts.get(key);
      added = !account;
      if (added) {
        this.#makeRoom(tokenSet);
        account = { key, addedAt: Date.now() };
        this.#accounts.set(key, account);
      }
      for (const c of PROFILE_CLAIMS) delete account[c];
      Object.assign(account, pick(claims, PROFILE_CLAIMS), { status: 'signed-in', lastUsedAt: Date.now() });
      delete account.endedReason;
      delete account.changedAt;
      await this.#tokens.set(key, tokenSet, { schedule: false });
    });
    this.emit(added ? 'added' : 'updated', this.get(key));
    await this.#activate(key);
    return { account: this.get(key), tokenSet, intent: context?.intent ?? 'sign-in', added };
  }

  // ---------------------------------------------------------------------------
  // Activating
  // ---------------------------------------------------------------------------

  /**
   * Makes a listed account this tab's active account; the previous one becomes dormant. Nothing is sent to
   * the OP unless the account's tokens are due for a refresh. An account that is not `signed-in` becomes
   * active too, with no tokens; `resume()` or `reauthenticate()` it.
   *
   * When the refresh or `onActivate` fails, the previously active account is reactivated and the error thrown.
   * @param {string} key
   * @returns {Promise<Account|undefined>}
   */
  async activate(key) {
    this.#mustGet(key);
    const previous = this.#active;
    try {
      await this.#activate(key);
    } catch (err) {
      if (previous && previous !== key && this.#active !== previous) await this.#activate(previous).catch(() => {});
      throw err;
    }
    return this.active;
  }

  async #activate(key) {
    await this.#load();
    const previous = this.#active;
    if (previous && previous !== key) await this.#deactivate();
    this.#mustGet(key);
    if (this.#accounts.get(key).status === 'signed-in') {
      let ts = await this.#tokens.get(key);
      if (ts?.isExpired(this.#opts.refreshSkewSec)) {
        ts = ts.refresh_token ? await this.#tokens.refresh(key) : undefined;
      }
      if (!ts) {
        await this.#tokens.delete(key).catch(() => {});
        await this.#mutate(() => this.#setStatus(key, 'tokens-missing'));
      } else if (this.#opts.onActivate) {
        this.#activating = key;
        try {
          await this.#opts.onActivate(this.get(key), { getTokenSet: (opts) => this.#tokensFor(key, opts) });
        } finally {
          this.#activating = undefined;
        }
        await this.#load(); // a signal acted on during onActivate may have ended the account
      }
    }
    this.#active = key;
    await this.#tabStore.set(this.#tabKey(), key);
    if (this.#accounts.get(key)?.status === 'signed-in') {
      await this.#mutate(() => { const a = this.#accounts.get(key); if (a) a.lastUsedAt = Date.now(); });
      await this.#tokens.schedule(key);
    }
    if (previous !== key) this.emit('activated', this.get(key), this.get(previous));
    this.emit('change');
  }

  async #deactivate() {
    const key = this.#active;
    if (!key) return;
    this.#tokens.unschedule(key);
    this.#active = undefined;
    await this.#tabStore.delete(this.#tabKey());
    const a = this.get(key);
    if (a) await Promise.resolve(this.#opts.onDeactivate?.(a)).catch(() => {});
  }

  // ---------------------------------------------------------------------------
  // Signing out, ending, forgetting
  // ---------------------------------------------------------------------------

  /**
   * Signs one account out of this browser: `onLeave` runs, its tokens are revoked (with `revoke`) and
   * removed from the cache, and it stays listed as `signed-out`. When it was this tab's active account,
   * the most recently added signed-in account becomes active. Answers an RP-Initiated Logout request
   * naming that account's ID token, so the OP ends that account's session only (or undefined: no
   * end_session_endpoint, or `endSession: false`).
   * @param {string} [key]  Default: the active account.
   * @param {{ revoke?: boolean, endSession?: boolean, postLogoutRedirectUri?: string }} [opts]
   * @returns {Promise<{ url: string, state: string }|undefined>}
   */
  async signOut(key = this.#active, { revoke = true, endSession = true, postLogoutRedirectUri } = {}) {
    const { account, idToken } = await this.#leave(key, 'signed-out', { revoke });
    await this.#afterLeaving(key);
    this.emit('signed-out', account);
    this.emit('change');
    if (!endSession) return undefined;
    return this.#client.createEndSessionRequest({
      idTokenHint: idToken,
      logoutHint: idToken ? undefined : this.#opts.loginHint(account),
      postLogoutRedirectUri,
      context: { key },
    });
  }

  /**
   * Signs every account out of this browser. Answers one logout request that names nobody, so an OP
   * holding several accounts asks the person which to end (or all of them).
   * @param {{ revoke?: boolean, endSession?: boolean, postLogoutRedirectUri?: string }} [opts]
   */
  async signOutAll({ revoke = true, endSession = true, postLogoutRedirectUri } = {}) {
    await this.#load();
    const previous = this.active;
    for (const a of this.list().filter((x) => x.status === 'signed-in' || x.status === 'tokens-missing')) {
      const { account } = await this.#leave(a.key, 'signed-out', { revoke });
      this.emit('signed-out', account);
    }
    if (this.#active) await this.#deactivate();
    if (previous) this.emit('activated', undefined, this.get(previous.key));
    this.emit('change');
    if (!endSession) return undefined;
    return this.#client.createEndSessionRequest({ postLogoutRedirectUri, context: { all: true } });
  }

  /**
   * Ends an account's session in this browser because the OP or a security event says it has ended:
   * `onLeave` runs, its tokens are removed from the cache (not revoked: the OP already knows), and it stays
   * listed as `ended` with the reason. When it was this tab's active account, the most recently added
   * signed-in account becomes active. Does nothing for an account that is not signed in.
   * @param {string} key
   * @param {string} [reason]
   * @returns {Promise<Account|undefined>}
   */
  async end(key, reason) {
    await this.#load();
    const a = this.#accounts.get(key);
    if (!a || (a.status !== 'signed-in' && a.status !== 'tokens-missing')) return a && { ...a };
    const { account } = await this.#leave(key, 'ended', { revoke: false, reason });
    // During its onActivate the account is not active yet; #activate sees the status when the hook returns.
    if (this.#activating !== key) await this.#afterLeaving(key);
    this.emit('ended', account, reason);
    this.emit('change');
    return this.get(key);
  }

  /**
   * Removes an account that is not signed in from the list. A signed-in account must be signed out first.
   * @param {string} key
   * @returns {Promise<void>}
   */
  async forget(key) {
    let removed;
    await this.#mutate(() => {
      const a = this.#mustGet(key);
      if (a.status === 'signed-in') throw new OIDCClientError('Sign the account out before forgetting it', { code: 'account_signed_in' });
      this.#accounts.delete(key);
      removed = a;
    });
    await this.#tokens.delete(key).catch(() => {});
    if (this.#active === key) {
      this.#active = undefined;
      await this.#tabStore.delete(this.#tabKey());
      this.emit('activated', undefined, removed);
    }
    this.emit('forgotten', removed);
    this.emit('change');
  }

  /** Cancels refresh timers and stops listening to other tabs (call on shutdown). */
  close() {
    this.#tokens.close();
    this.#channel?.close();
  }

  // ---------------------------------------------------------------------------
  // Internals
  // ---------------------------------------------------------------------------

  #tabKey() { return `active:${this.#storeKey}`; }

  #mustGet(key) {
    const a = this.#accounts.get(key);
    if (!a) throw new OIDCClientError(`No account ${key}`, { code: 'no_account' });
    return a;
  }

  /** Runs onLeave, drops the tokens and records the new status. */
  async #leave(key, status, { revoke, reason }) {
    await this.#load();
    const a = this.#mustGet(key);
    const cached = await this.#tokens.get(key);
    if (cached && this.#opts.onLeave) {
      await Promise.resolve(this.#opts.onLeave(this.get(key), { getTokenSet: (opts) => this.#tokens.getTokenSet(key, opts) })).catch(() => {});
    }
    await this.#tokens.delete(key, { revoke }).catch(() => {});
    await this.#mutate(() => {
      this.#setStatus(key, status);
      const x = this.#accounts.get(key);
      if (x && reason !== undefined) x.endedReason = reason;
    });
    return { account: this.get(key) ?? { ...a, status }, idToken: cached?.id_token };
  }

  /** When the account that left was this tab's active one, the most recently added signed-in account takes over. */
  async #afterLeaving(key) {
    if (this.#active !== key) return;
    await this.#deactivate();
    const next = [...this.#accounts.values()].filter((a) => a.status === 'signed-in').sort((a, b) => b.addedAt - a.addedAt)[0];
    if (next) {
      await this.#activate(next.key).catch((err) => this.emit('activation_error', this.get(next.key), err));
    } else {
      this.emit('activated', undefined, this.get(key));
    }
  }

  #setStatus(key, status) {
    const a = this.#accounts.get(key);
    if (!a) return;
    a.status = status;
    a.changedAt = Date.now();
    if (status !== 'ended') delete a.endedReason;
  }

  /** Before adding an account at the limit: forget the account that left longest ago, or refuse. */
  #makeRoom(tokenSet) {
    if (this.#accounts.size < this.#opts.maxAccounts) return;
    const droppable = [...this.#accounts.values()].filter((a) => a.status !== 'signed-in')
      .sort((a, b) => (a.changedAt ?? a.lastUsedAt) - (b.changedAt ?? b.lastUsedAt));
    if (!droppable.length) {
      void Promise.allSettled([
        tokenSet.refresh_token && this.#client.revoke(tokenSet.refresh_token, 'refresh_token'),
        this.#client.revoke(tokenSet.access_token, 'access_token'),
      ]);
      throw new OIDCClientError(`${this.#opts.maxAccounts} accounts are signed in already; sign one out first`, { code: 'too_many_accounts' });
    }
    this.#accounts.delete(droppable[0].key);
    this.#tokens.delete(droppable[0].key).catch(() => {});
    this.emit('forgotten', droppable[0]);
  }

  async #onRefreshed(key, ts) {
    if (!ts.claims) return;
    await this.#mutate(() => {
      const a = this.#accounts.get(key);
      if (a) Object.assign(a, pick(ts.claims, PROFILE_CLAIMS));
    });
    if (key === this.#active) this.emit('refreshed', this.get(key));
    this.emit('change');
  }

  /** Another tab changed the list. When this tab's active account has left, its tokens stop being refreshed here. */
  async #onRemoteChange() {
    await this.#load();
    const key = this.#active;
    if (key && this.#accounts.get(key)?.status !== 'signed-in') this.#tokens.unschedule(key);
    if (key && !this.#accounts.has(key)) {
      this.#active = undefined;
      await this.#tabStore.delete(this.#tabKey());
      this.emit('activated', undefined, undefined);
    }
    this.emit('change');
  }

  /**
   * Re-reads the shared list. A change in progress in this tab (#mutate) is waited for first: replacing
   * the list under it would lose what it is adding, and its write would then persist the stale list,
   * orphaning the tokens it stored. Never call this from inside a #mutate callback.
   */
  async #load() {
    while (this.#mutating) await this.#mutating.catch(() => {});
    await this.#read();
  }

  async #read() {
    const saved = await this.#store.get(this.#storeKey);
    this.#accounts = new Map((saved?.accounts ?? []).map((a) => [a.key, a]));
  }

  /** Read-modify-write of the shared list, under a lock shared by the tabs; then tells the other tabs. */
  async #mutate(fn) {
    while (this.#mutating) await this.#mutating.catch(() => {});
    const run = withLock(`oidc-caep-client:${this.#storeKey}`, async () => {
      await this.#read();
      await fn();
      await this.#store.set(this.#storeKey, { accounts: [...this.#accounts.values()] });
    });
    this.#mutating = run;
    try {
      await run;
    } finally {
      if (this.#mutating === run) this.#mutating = undefined;
    }
    this.#channel?.postMessage({ type: 'change' });
  }
}

/**
 * The account key for an `iss` and `sub`: stable, opaque, and safe in a storage key or a URL.
 * @param {string} iss
 * @param {string} sub
 * @returns {Promise<string>}
 */
export async function accountKey(iss, sub) {
  return `acct-${base64url(await digest(`${iss}\n${sub}`)).slice(0, 22)}`;
}

function pick(obj, keys) {
  const out = {};
  for (const k of keys) if (obj[k] !== undefined) out[k] = obj[k];
  return out;
}