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();
}
}