UNPKG

voluptasmollitia

Version:
326 lines (297 loc) 9.81 kB
/** * @license * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { getToken as getReCAPTCHAToken } from './recaptcha'; import { FirebaseApp } from '@firebase/app-types'; import { AppCheckTokenResult, AppCheckTokenListener } from '@firebase/app-check-interop-types'; import { AppCheckTokenInternal, getDebugState, getState, setState } from './state'; import { TOKEN_REFRESH_TIME } from './constants'; import { Refresher } from './proactive-refresh'; import { ensureActivated } from './util'; import { exchangeToken, getExchangeDebugTokenRequest, getExchangeRecaptchaTokenRequest } from './client'; import { writeTokenToStorage, readTokenFromStorage } from './storage'; import { getDebugToken, isDebugMode } from './debug'; import { base64, issuedAtTime } from '@firebase/util'; import { ERROR_FACTORY, AppCheckError } from './errors'; import { logger } from './logger'; import { Provider } from '@firebase/component'; // Initial hardcoded value agreed upon across platforms for initial launch. // Format left open for possible dynamic error values and other fields in the future. export const defaultTokenErrorData = { error: 'UNKNOWN_ERROR' }; /** * Stringify and base64 encode token error data. * * @param tokenError Error data, currently hardcoded. */ export function formatDummyToken( tokenErrorData: Record<string, string> ): string { return base64.encodeString( JSON.stringify(tokenErrorData), /* webSafe= */ false ); } /** * This function will always resolve. * The result will contain an error field if there is any error. * In case there is an error, the token field in the result will be populated with a dummy value */ export async function getToken( app: FirebaseApp, platformLoggerProvider: Provider<'platform-logger'>, forceRefresh = false ): Promise<AppCheckTokenResult> { ensureActivated(app); /** * DEBUG MODE * return the debug token directly */ if (isDebugMode()) { const tokenFromDebugExchange: AppCheckTokenInternal = await exchangeToken( getExchangeDebugTokenRequest(app, await getDebugToken()), platformLoggerProvider ); return { token: tokenFromDebugExchange.token }; } const state = getState(app); let token: AppCheckTokenInternal | undefined = state.token; let error: Error | undefined = undefined; /** * try to load token from indexedDB if it's the first time this function is called */ if (!token) { // readTokenFromStorage() always resolves. In case of an error, it resolves with `undefined`. const cachedToken = await readTokenFromStorage(app); if (cachedToken && isValid(cachedToken)) { token = cachedToken; setState(app, { ...state, token }); // notify all listeners with the cached token notifyTokenListeners(app, { token: token.token }); } } // return the cached token if it's valid if (!forceRefresh && token && isValid(token)) { return { token: token.token }; } /** * request a new token */ try { if (state.customProvider) { const customToken = await state.customProvider.getToken(); // Try to extract IAT from custom token, in case this token is not // being newly issued. JWT timestamps are in seconds since epoch. const issuedAtTimeSeconds = issuedAtTime(customToken.token); // Very basic validation, use current timestamp as IAT if JWT // has no `iat` field or value is out of bounds. const issuedAtTimeMillis = issuedAtTimeSeconds !== null && issuedAtTimeSeconds < Date.now() && issuedAtTimeSeconds > 0 ? issuedAtTimeSeconds * 1000 : Date.now(); token = { ...customToken, issuedAtTimeMillis }; } else { const attestedClaimsToken = await getReCAPTCHAToken(app).catch(_e => { // reCaptcha.execute() throws null which is not very descriptive. throw ERROR_FACTORY.create(AppCheckError.RECAPTCHA_ERROR); }); token = await exchangeToken( getExchangeRecaptchaTokenRequest(app, attestedClaimsToken), platformLoggerProvider ); } } catch (e) { // `getToken()` should never throw, but logging error text to console will aid debugging. logger.error(e); error = e; } let interopTokenResult: AppCheckTokenResult | undefined; if (!token) { // if token is undefined, there must be an error. // we return a dummy token along with the error interopTokenResult = makeDummyTokenResult(error!); } else { interopTokenResult = { token: token.token }; // write the new token to the memory state as well ashe persistent storage. // Only do it if we got a valid new token setState(app, { ...state, token }); await writeTokenToStorage(app, token); } notifyTokenListeners(app, interopTokenResult); return interopTokenResult; } export function addTokenListener( app: FirebaseApp, platformLoggerProvider: Provider<'platform-logger'>, listener: AppCheckTokenListener ): void { const state = getState(app); const newState = { ...state, tokenListeners: [...state.tokenListeners, listener] }; /** * DEBUG MODE * * invoke the listener once with the debug token. */ if (isDebugMode()) { const debugState = getDebugState(); if (debugState.enabled && debugState.token) { debugState.token.promise .then(token => listener({ token })) .catch(() => { /* we don't care about exceptions thrown in listeners */ }); } } else { /** * PROD MODE * * invoke the listener with the valid token, then start the token refresher */ if (!newState.tokenRefresher) { const tokenRefresher = createTokenRefresher(app, platformLoggerProvider); newState.tokenRefresher = tokenRefresher; } // Create the refresher but don't start it if `isTokenAutoRefreshEnabled` // is not true. if ( !newState.tokenRefresher.isRunning() && state.isTokenAutoRefreshEnabled === true ) { newState.tokenRefresher.start(); } // invoke the listener async immediately if there is a valid token if (state.token && isValid(state.token)) { const validToken = state.token; Promise.resolve() .then(() => listener({ token: validToken.token })) .catch(() => { /* we don't care about exceptions thrown in listeners */ }); } } setState(app, newState); } export function removeTokenListener( app: FirebaseApp, listener: AppCheckTokenListener ): void { const state = getState(app); const newListeners = state.tokenListeners.filter(l => l !== listener); if ( newListeners.length === 0 && state.tokenRefresher && state.tokenRefresher.isRunning() ) { state.tokenRefresher.stop(); } setState(app, { ...state, tokenListeners: newListeners }); } function createTokenRefresher( app: FirebaseApp, platformLoggerProvider: Provider<'platform-logger'> ): Refresher { return new Refresher( // Keep in mind when this fails for any reason other than the ones // for which we should retry, it will effectively stop the proactive refresh. async () => { const state = getState(app); // If there is no token, we will try to load it from storage and use it // If there is a token, we force refresh it because we know it's going to expire soon let result; if (!state.token) { result = await getToken(app, platformLoggerProvider); } else { result = await getToken(app, platformLoggerProvider, true); } // getToken() always resolves. In case the result has an error field defined, it means the operation failed, and we should retry. if (result.error) { throw result.error; } }, () => { // TODO: when should we retry? return true; }, () => { const state = getState(app); if (state.token) { // issuedAtTime + (50% * total TTL) + 5 minutes let nextRefreshTimeMillis = state.token.issuedAtTimeMillis + (state.token.expireTimeMillis - state.token.issuedAtTimeMillis) * 0.5 + 5 * 60 * 1000; // Do not allow refresh time to be past (expireTime - 5 minutes) const latestAllowableRefresh = state.token.expireTimeMillis - 5 * 60 * 1000; nextRefreshTimeMillis = Math.min( nextRefreshTimeMillis, latestAllowableRefresh ); return Math.max(0, nextRefreshTimeMillis - Date.now()); } else { return 0; } }, TOKEN_REFRESH_TIME.RETRIAL_MIN_WAIT, TOKEN_REFRESH_TIME.RETRIAL_MAX_WAIT ); } function notifyTokenListeners( app: FirebaseApp, token: AppCheckTokenResult ): void { const listeners = getState(app).tokenListeners; for (const listener of listeners) { try { listener(token); } catch (e) { // If any handler fails, ignore and run next handler. } } } function isValid(token: AppCheckTokenInternal): boolean { return token.expireTimeMillis - Date.now() > 0; } function makeDummyTokenResult(error: Error): AppCheckTokenResult { return { token: formatDummyToken(defaultTokenErrorData), error }; }