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}`;
}