src/auth/storage.ts (174 lines of code) (raw):

import Storage, {type StorageClass, type StorageInterface} from '../storage/storage'; import {type AuthUser} from './auth-core'; import {type AuthResponse} from './response-parser'; /** * @typedef {Object} StoredToken * @property {string} accessToken * @property {string[]} scopes * @property {number} expires */ /** * @typedef {Object} StoredState * @property {Date} created * @property {string} restoreLocation * @property {string[]} scopes */ const DEFAULT_STATE_QUOTA = 102400; // 100 kb ~~ 200 tabs with a large list of scopes // eslint-disable-next-line no-magic-numbers const DEFAULT_STATE_TTL = 1000 * 60 * 60 * 24; // nobody will need auth state after a day const UPDATE_USER_TIMEOUT = 1000; export interface StoredToken { accessToken: string; scopes?: string[]; expires?: number; lifeTime?: number; waitForRedirectTimeout?: number; } export interface AuthState extends AuthResponse { restoreLocation?: string; scopes?: string[]; nonRedirect?: boolean | null | undefined; created?: number; } export interface AuthStorageConfig { stateKeyPrefix?: string | null | undefined; tokenKey?: string | null | undefined; messagePrefix?: string | null | undefined; userKey?: string | null | undefined; stateTTL?: number | null | undefined; storage?: StorageClass | null | undefined; stateQuota?: number | null | undefined; } interface StateRemovalResult { key: string; created: number; size: number; } export default class AuthStorage<M = unknown> { messagePrefix: string; stateKeyPrefix: string; tokenKey: string; userKey: string; stateTTL: number; stateQuota: number; private _lastMessage: unknown; _stateStorage: StorageInterface; _tokenStorage: StorageInterface; _messagesStorage: StorageInterface; private _currentUserStorage: StorageInterface; /** * Custom storage for Auth * @param {{stateKeyPrefix: string, tokenKey: string, onTokenRemove: Function}} config */ constructor(config: AuthStorageConfig) { this.messagePrefix = config.messagePrefix || ''; this.stateKeyPrefix = config.stateKeyPrefix || ''; this.tokenKey = config.tokenKey || ''; this.userKey = config.userKey || 'user-key'; this.stateTTL = config.stateTTL || DEFAULT_STATE_TTL; this._lastMessage = null; const StorageConstructor = config.storage || Storage; this.stateQuota = config.stateQuota || DEFAULT_STATE_QUOTA; this._stateStorage = new StorageConstructor({ cookieName: 'ring-state', }); this._tokenStorage = new StorageConstructor({ cookieName: 'ring-token', }); this._messagesStorage = new StorageConstructor({ cookieName: 'ring-message', }); this._currentUserStorage = new StorageConstructor({ cookieName: 'ring-user', }); } /** * Add token change listener * @param {function(string)} fn Token change listener * @return {function()} remove listener function */ onTokenChange(fn: (token: StoredToken | null) => void) { return this._tokenStorage.on(this.tokenKey, fn); } /** * Add state change listener * @param {string} stateKey State key * @param {function(string)} fn State change listener * @return {function()} remove listener function */ onStateChange(stateKey: string, fn: (state: AuthState | null) => void) { return this._stateStorage.on(this.stateKeyPrefix + stateKey, fn); } /** * Add state change listener * @param {string} key State key * @param {function(string)} fn State change listener * @return {function()} remove listener function */ onMessage(key: string, fn: (message: M | null) => void) { return this._messagesStorage.on<M>(this.messagePrefix + key, message => fn(message)); } sendMessage(key: string, message: M | null = null) { this._lastMessage = message; this._messagesStorage.set(this.messagePrefix + key, message); } /** * Save authentication request state. * * @param {string} id Unique state identifier * @param {StoredState} state State to store * @param {boolean=} dontCleanAndRetryOnFail If falsy then remove all stored states and try again to save state */ async saveState(id: string, state: AuthState, dontCleanAndRetryOnFail?: boolean): Promise<void> { state.created = Date.now(); try { await this._stateStorage.set(this.stateKeyPrefix + id, state); } catch (e) { if (!dontCleanAndRetryOnFail) { await this.cleanStates(); return this.saveState(id, state, true); } throw e; } return undefined; } /** * Remove all stored states * * @return {Promise} promise that is resolved when OLD states [and some selected] are removed */ async cleanStates(removeStateId?: string) { const now = Date.now(); const removalResult = await this._stateStorage.each<StateRemovalResult | void>((key, value) => { if (value === null || value === undefined) { return undefined; } // Remove requested state if (key === this.stateKeyPrefix + removeStateId) { return this._stateStorage.remove(key); } if (key.indexOf(this.stateKeyPrefix) === 0) { // Clean old states const state = value as AuthState; const created = state.created ?? Date.now(); if (created + this.stateTTL < now) { return this._stateStorage.remove(key); } // Data to clean up due quota return { key, created, size: JSON.stringify(state).length, }; } return undefined; }); const currentStates = removalResult.filter( (state): state is StateRemovalResult => state !== null && state !== undefined, ); let stateStorageSize = currentStates.reduce((overallSize, state) => state.size + overallSize, 0); if (stateStorageSize > this.stateQuota) { currentStates.sort((a, b) => a.created - b.created); const removalPromises = currentStates .filter(state => { if (stateStorageSize > this.stateQuota) { stateStorageSize -= state.size; return true; } return false; }) .map(state => this._stateStorage.remove(state.key)); return removalPromises.length && Promise.all(removalPromises); } return undefined; } /** * Get state by id and remove stored states from the storage. * * @param {string} id unique state identifier * @return {Promise.<StoredState>} */ async getState(id: string): Promise<AuthState | null> { try { const result = await this._stateStorage.get<AuthState>(this.stateKeyPrefix + id); await this.cleanStates(id); return result; } catch (e) { await this.cleanStates(id); throw e; } } /** * @param {StoredToken} token * @return {Promise} promise that is resolved when the token is saved */ saveToken(token: StoredToken) { return this._tokenStorage.set(this.tokenKey, token); } /** * @return {Promise.<StoredToken>} promise that is resolved to the stored token */ getToken(): Promise<StoredToken | null> { return this._tokenStorage.get(this.tokenKey); } /** * Remove stored token if any. * @return {Promise} promise that is resolved when the token is wiped. */ wipeToken() { return this._tokenStorage.remove(this.tokenKey); } /** * @param {function} loadUser user loader * @return {Promise.<object>>} promise that is resolved to stored current user */ async getCachedUser(loadUser: () => Promise<AuthUser | null>): Promise<AuthUser | null> { const user = await this._currentUserStorage.get<AuthUser>(this.userKey); const loadAndCache = () => loadUser().then(response => { this._currentUserStorage.set(this.userKey, response); return response; }); if (user && user.id) { setTimeout(loadAndCache, UPDATE_USER_TIMEOUT); return user; } return loadAndCache(); } /** * Remove cached user if any */ wipeCachedCurrentUser() { return this._currentUserStorage.remove(this.userKey); } /** * Wipes cache if user has changed */ onUserChanged() { this.wipeCachedCurrentUser(); } }