Source: token-manager.js

import { OAuthError, OIDCClientError } from './errors.js';
import { MemoryTokenStore } from './stores.js';
import { TokenSet } from './token-set.js';
import { Emitter, withLock } from './util.js';

const MAX_TIMEOUT_MS = 2 ** 31 - 1;

/**
 * Caches TokenSets per key (typically a user session id) and keeps them fresh.
 *
 * - `getAccessToken(key)` always returns a token valid for at least `refreshSkewSec`, refreshing on demand.
 * - With `autoRefresh` (default), a timer refreshes each token `refreshSkewSec` before it expires.
 * - Concurrent refreshes for the same key are coalesced into one token-endpoint call; across tabs sharing
 *   a store, a refresh holds a Web Lock and reuses the tokens another tab has just obtained, so a rotating
 *   refresh token is never presented twice.
 * - `set(key, ts, { schedule: false })` / `unschedule(key)` keep a key's tokens without refreshing them.
 *
 * Events:
 *   "refreshed"      (key, tokenSet)  — new tokens obtained via refresh_token
 *   "refresh_error"  (key, error)     — refresh failed (tokens are dropped if the grant is invalid)
 *   "expired"        (key)            — tokens for key could not be renewed and were removed
 *   "removed"        (key)            — tokens deleted via delete()
 */
export class TokenManager extends Emitter {
  #client;
  #store;
  #timers = new Map();
  #wanted = new Set();
  #inflight = new Map();
  #opts;

  /**
   * @param {import('./oidc-client.js').OIDCClient} client
   * @param {{ store?: import('./stores.js').TokenStore, refreshSkewSec?: number, autoRefresh?: boolean, minRefreshDelayMs?: number, retryDelayMs?: number }} [opts]
   */
  constructor(client, { store = new MemoryTokenStore(), refreshSkewSec = 60, autoRefresh = true, minRefreshDelayMs = 1000, retryDelayMs = 5000 } = {}) {
    super();
    this.#client = client;
    this.#store = store;
    this.#opts = { refreshSkewSec, autoRefresh, minRefreshDelayMs, retryDelayMs };
  }

  /**
   * Stores tokens for a key and (unless `schedule: false`) schedules their proactive refresh.
   * @param {string} key
   * @param {TokenSet|object} tokenSet
   * @param {{ schedule?: boolean }} [opts]
   */
  async set(key, tokenSet, { schedule = true } = {}) {
    const ts = TokenSet.from(tokenSet);
    await this.#store.set(key, ts.toJSON());
    if (schedule) this.#wanted.add(key);
    else this.unschedule(key);
    this.#schedule(key, ts);
    return ts;
  }

  /**
   * Schedules the proactive refresh of the tokens already stored for key.
   * @param {string} key
   * @returns {Promise<void>}
   */
  async schedule(key) {
    this.#wanted.add(key);
    const ts = await this.get(key);
    if (ts) this.#schedule(key, ts);
  }

  /**
   * Stops refreshing key's tokens in the background; they stay stored.
   * @param {string} key
   */
  unschedule(key) {
    this.#wanted.delete(key);
    clearTimeout(this.#timers.get(key));
    this.#timers.delete(key);
  }

  /**
   * @param {string} key
   * @returns {Promise<TokenSet|undefined>} Cached tokens (possibly expired) for key.
   */
  async get(key) {
    const raw = await this.#store.get(key);
    return raw ? TokenSet.from(raw) : undefined;
  }

  /**
   * Returns a usable access token for key, refreshing first if it is expired or about to expire.
   * @param {string} key
   * @param {{ forceRefresh?: boolean }} [opts]
   * @returns {Promise<string>}
   */
  async getAccessToken(key, opts) {
    return (await this.getTokenSet(key, opts)).access_token;
  }

  /**
   * Refreshes tokens for key now. Concurrent calls share a single request.
   * @param {string} key
   * @returns {Promise<TokenSet>}
   */
  refresh(key) {
    const existing = this.#inflight.get(key);
    if (existing) return existing;
    const p = (async () => {
      const before = await this.get(key);
      if (!before) throw new OIDCClientError(`No tokens cached for ${key}`, { code: 'no_tokens' });
      try {
        return await withLock(refreshLock(key), async () => {
          // Another tab sharing the store may have refreshed while this one waited for the lock.
          const current = await this.get(key);
          if (!current) throw new OIDCClientError(`Tokens for ${key} were removed`, { code: 'no_tokens' });
          if (current.access_token !== before.access_token && !current.isExpired(this.#opts.refreshSkewSec)) {
            this.#schedule(key, current);
            return current;
          }
          const next = await this.#client.refresh(current);
          // The key may have been deleted while the request was in flight, by a delete() in another tab
          // (this tab's waits for the lock). Its new tokens are revoked rather than left valid at the OP.
          if (!(await this.#store.get(key))) {
            await Promise.allSettled([
              next.refresh_token && this.#client.revoke(next.refresh_token, 'refresh_token'),
              this.#client.revoke(next.access_token, 'access_token'),
            ]);
            throw new OIDCClientError(`Tokens for ${key} were removed during refresh`, { code: 'no_tokens' });
          }
          await this.set(key, next, { schedule: this.#wanted.has(key) });
          this.emit('refreshed', key, next);
          return next;
        });
      } catch (err) {
        this.emit('refresh_error', key, err);
        if (err instanceof OAuthError && (err.error === 'invalid_grant' || err.error === 'invalid_client')) {
          await this.#drop(key, 'expired');
        }
        throw err;
      }
    })().finally(() => this.#inflight.delete(key));
    this.#inflight.set(key, p);
    return p;
  }

  /**
   * Removes tokens for key, optionally revoking them at the OP (RFC 7009).
   * @param {string} key
   * @param {{ revoke?: boolean }} [opts]
   */
  async delete(key, { revoke = false } = {}) {
    // Under the refresh lock: a refresh in flight finishes first, so it can't write its tokens back after
    // they were removed (e.g. on a CAEP session-revoked).
    const ts = await withLock(refreshLock(key), async () => {
      const held = await this.get(key);
      await this.#drop(key, 'removed');
      return held;
    });
    if (revoke && ts) {
      await Promise.allSettled([
        ts.refresh_token && this.#client.revoke(ts.refresh_token, 'refresh_token'),
        this.#client.revoke(ts.access_token, 'access_token'),
      ]);
    }
    return ts;
  }

  /**
   * Returns the cached TokenSet for key, refreshing first if the access token is (nearly) expired.
   * Use this rather than getAccessToken() when the token may be DPoP-bound: the caller needs its
   * token_type to build the right Authorization header (OIDCClient.resourceHeaders()).
   * @param {string} key
   * @param {{ forceRefresh?: boolean }} [opts]
   * @returns {Promise<TokenSet>}
   */
  async getTokenSet(key, { forceRefresh = false } = {}) {
    let ts = await this.get(key);
    if (!ts) throw new OIDCClientError(`No tokens cached for ${key}`, { code: 'no_tokens' });
    if (forceRefresh || ts.isExpired(this.#opts.refreshSkewSec)) {
      if (ts.refresh_token) {
        ts = await this.refresh(key);
      } else if (ts.isExpired()) {
        await this.#drop(key, 'expired');
        throw new OIDCClientError('Access token expired and no refresh_token is available', { code: 'token_expired' });
      }
    }
    return ts;
  }

  /** Cancels all refresh timers (call on shutdown). */
  close() {
    for (const t of this.#timers.values()) clearTimeout(t);
    this.#timers.clear();
    this.#wanted.clear();
  }

  async #drop(key, event) {
    this.unschedule(key);
    await this.#store.delete(key);
    this.emit(event, key);
  }

  #schedule(key, ts) {
    clearTimeout(this.#timers.get(key));
    this.#timers.delete(key);
    if (!this.#opts.autoRefresh || !this.#wanted.has(key) || ts.expires_at === undefined || !ts.refresh_token) return;
    const dueMs = (ts.expires_at - ts.effectiveSkew(this.#opts.refreshSkewSec)) * 1000 - Date.now();
    const delay = Math.min(MAX_TIMEOUT_MS, Math.max(this.#opts.minRefreshDelayMs, dueMs));
    const timer = setTimeout(() => {
      this.#timers.delete(key);
      this.refresh(key).catch(() => this.#retry(key));
    }, delay);
    timer.unref?.();
    this.#timers.set(key, timer);
  }

  /** After a transient background-refresh failure (already surfaced via "refresh_error"), try again later. */
  async #retry(key) {
    const ts = await this.get(key);
    if (!ts || this.#timers.has(key) || !this.#wanted.has(key)) return;
    if (ts.isExpired()) {
      await this.#drop(key, 'expired');
      return;
    }
    const timer = setTimeout(() => {
      this.#timers.delete(key);
      this.refresh(key).catch(() => this.#retry(key));
    }, this.#opts.retryDelayMs);
    timer.unref?.();
    this.#timers.set(key, timer);
  }
}

/** The lock one key's refreshes and deletes share, across this origin's tabs. */
function refreshLock(key) {
  return `oidc-caep-client:refresh:${key}`;
}