/**
* Storage for tokens and for in-flight authorization transactions.
*
* Token store interface (async or sync methods):
* @typedef {object} TokenStore
* @property {(key: string) => Promise<object|undefined>|object|undefined} get
* @property {(key: string, value: object) => Promise<void>|void} set
* @property {(key: string) => Promise<void>|void} delete
*/
/** @typedef {Record<string, any>} StoredTokens A token set as a store keeps it (`TokenSet#toJSON()`). */
/**
* Tokens in memory only (the default, and what the browser-based apps BCP recommends): nothing
* persists, so a page reload means signing in again, and nothing is left behind in storage for
* injected script to read later.
*/
export class MemoryTokenStore {
#map = new Map();
/** @param {string} key @returns {StoredTokens|undefined} */
get(key) { return this.#map.get(key); }
/** @param {string} key @param {StoredTokens} value */
set(key, value) { this.#map.set(key, value); }
/** @param {string} key */
delete(key) { this.#map.delete(key); }
/** @returns {string[]} */
keys() { return [...this.#map.keys()]; }
}
/**
* Tokens in Web Storage (sessionStorage by default), so a reload keeps the session. Anything script in
* the page can read, it can read: pair this with DPoP so the stored tokens are useless without the
* non-extractable key.
*/
export class WebStorageTokenStore {
#storage;
#prefix;
/** @param {{ storage?: Storage, prefix?: string }} [opts] */
constructor({ storage = globalThis.sessionStorage, prefix = 'oidc-caep-client:tokens:' } = {}) {
if (!storage) throw new TypeError('Web Storage is not available here');
this.#storage = storage;
this.#prefix = prefix;
}
/** @param {string} key @returns {StoredTokens|undefined} */
get(key) {
const raw = this.#storage.getItem(this.#prefix + key);
return raw ? JSON.parse(raw) : undefined;
}
/** @param {string} key @param {StoredTokens} value */
set(key, value) { this.#storage.setItem(this.#prefix + key, JSON.stringify(value)); }
/** @param {string} key */
delete(key) { this.#storage.removeItem(this.#prefix + key); }
}
/**
* Tokens in IndexedDB, shared by every tab of this origin and kept until deleted. This is the cache an
* AccountManager uses for all the accounts signed in in this browser. As with Web Storage, script in the
* page can read it: pair it with DPoP so the stored tokens are useless without the non-extractable key.
*/
export class IndexedDBTokenStore {
#name;
/** @param {{ database?: string }} [opts] */
constructor({ database = 'oidc-caep-client-tokens' } = {}) {
if (typeof indexedDB === 'undefined') throw new TypeError('IndexedDB is not available here');
this.#name = database;
}
#open() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(this.#name, 1);
req.onupgradeneeded = () => req.result.createObjectStore('tokens');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async #tx(mode, fn) {
const db = await this.#open();
try {
return await new Promise((resolve, reject) => {
const tx = db.transaction('tokens', mode);
const req = fn(tx.objectStore('tokens'));
tx.oncomplete = () => resolve(req.result);
tx.onerror = () => reject(tx.error);
});
} finally {
db.close();
}
}
/** @param {string} key @returns {Promise<StoredTokens|undefined>} */
get(key) { return this.#tx('readonly', (s) => s.get(key)); }
/** @param {string} key @param {StoredTokens} value */
async set(key, value) { await this.#tx('readwrite', (s) => s.put(value, key)); }
/** @param {string} key */
async delete(key) { await this.#tx('readwrite', (s) => s.delete(key)); }
}
/**
* Authorization transactions (state, nonce, PKCE verifier) between the redirect to the OP and the
* callback. Keyed by `state`; `take()` removes the entry as it reads it, so each transaction — and
* therefore each authorization response — can be used exactly once.
*
* Uses sessionStorage when present (per tab, cleared when the tab closes, survives the round trip to
* the OP) and memory otherwise.
*/
export class TransactionStore {
#storage;
#memory = new Map();
#prefix;
/** @param {{ storage?: Storage|null, prefix?: string }} [opts] */
constructor({ storage = globalThis.sessionStorage ?? null, prefix = 'oidc-caep-client:txn:' } = {}) {
this.#storage = storage;
this.#prefix = prefix;
}
/** @param {string} id @param {Record<string, any>} transaction */
set(id, transaction) {
const value = JSON.stringify(transaction);
if (this.#storage) this.#storage.setItem(this.#prefix + id, value);
else this.#memory.set(id, value);
}
/**
* Reads and removes a transaction.
* @param {string} id
* @returns {Record<string, any>|undefined}
*/
take(id) {
if (typeof id !== 'string' || !id) return undefined;
let raw;
if (this.#storage) {
raw = this.#storage.getItem(this.#prefix + id);
this.#storage.removeItem(this.#prefix + id);
} else {
raw = this.#memory.get(id);
this.#memory.delete(id);
}
return raw ? JSON.parse(raw) : undefined;
}
/**
* Removes transactions older than `maxAgeMs` (abandoned sign-ins).
* @param {number} maxAgeMs
*/
sweep(maxAgeMs) {
const cutoff = Date.now() - maxAgeMs;
const expired = (raw) => {
try {
return (JSON.parse(raw).createdAt ?? 0) < cutoff;
} catch {
return true;
}
};
if (this.#storage) {
for (let i = this.#storage.length - 1; i >= 0; i--) {
const k = this.#storage.key(i);
if (k?.startsWith(this.#prefix) && expired(this.#storage.getItem(k))) this.#storage.removeItem(k);
}
} else {
for (const [k, raw] of this.#memory) if (expired(raw)) this.#memory.delete(k);
}
}
}