From 6f0049e66064809ae990a2d9461e28b2d6d08d19 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Tue, 16 Nov 2021 15:00:33 -0800 Subject: [PATCH] Add retry logic to app check (#5676) --- .changeset/late-bottles-whisper.md | 5 + packages/app-check/src/api.test.ts | 17 +- packages/app-check/src/api.ts | 16 +- packages/app-check/src/constants.ts | 5 + packages/app-check/src/errors.ts | 7 +- packages/app-check/src/internal-api.test.ts | 76 +++++++- packages/app-check/src/internal-api.ts | 130 ++++++++----- packages/app-check/src/providers.test.ts | 201 ++++++++++++++++++++ packages/app-check/src/providers.ts | 142 ++++++++++++-- packages/app-check/src/state.ts | 1 + packages/app-check/src/types.ts | 6 + packages/app-check/src/util.ts | 27 +++ 12 files changed, 561 insertions(+), 72 deletions(-) create mode 100644 .changeset/late-bottles-whisper.md create mode 100644 packages/app-check/src/providers.test.ts diff --git a/.changeset/late-bottles-whisper.md b/.changeset/late-bottles-whisper.md new file mode 100644 index 00000000000..5a0aa106720 --- /dev/null +++ b/.changeset/late-bottles-whisper.md @@ -0,0 +1,5 @@ +--- +'@firebase/app-check': patch +--- + +Block exchange requests for certain periods of time after certain error codes to prevent overwhelming the endpoint. Start token listener when App Check is initialized to avoid extra wait time on first getToken() call. diff --git a/packages/app-check/src/api.test.ts b/packages/app-check/src/api.test.ts index 2d869875a28..724839226af 100644 --- a/packages/app-check/src/api.test.ts +++ b/packages/app-check/src/api.test.ts @@ -278,6 +278,9 @@ describe('api', () => { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: true }); + + expect(getState(app).tokenObservers.length).to.equal(1); + const fakeRecaptchaToken = 'fake-recaptcha-token'; const fakeRecaptchaAppCheckToken = { token: 'fake-recaptcha-app-check-token', @@ -299,7 +302,7 @@ describe('api', () => { const unsubscribe1 = onTokenChanged(appCheck, listener1, errorFn1); const unsubscribe2 = onTokenChanged(appCheck, listener2, errorFn2); - expect(getState(app).tokenObservers.length).to.equal(2); + expect(getState(app).tokenObservers.length).to.equal(3); await internalApi.getToken(appCheck as AppCheckService); @@ -312,7 +315,7 @@ describe('api', () => { expect(errorFn2).to.not.be.called; unsubscribe1(); unsubscribe2(); - expect(getState(app).tokenObservers.length).to.equal(0); + expect(getState(app).tokenObservers.length).to.equal(1); }); it('Listeners work when using Observer pattern', async () => { @@ -320,6 +323,9 @@ describe('api', () => { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: true }); + + expect(getState(app).tokenObservers.length).to.equal(1); + const fakeRecaptchaToken = 'fake-recaptcha-token'; const fakeRecaptchaAppCheckToken = { token: 'fake-recaptcha-app-check-token', @@ -351,7 +357,7 @@ describe('api', () => { error: errorFn1 }); - expect(getState(app).tokenObservers.length).to.equal(2); + expect(getState(app).tokenObservers.length).to.equal(3); await internalApi.getToken(appCheck as AppCheckService); @@ -364,7 +370,7 @@ describe('api', () => { expect(errorFn2).to.not.be.called; unsubscribe1(); unsubscribe2(); - expect(getState(app).tokenObservers.length).to.equal(0); + expect(getState(app).tokenObservers.length).to.equal(1); }); it('onError() catches token errors', async () => { @@ -373,6 +379,9 @@ describe('api', () => { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: false }); + + expect(getState(app).tokenObservers.length).to.equal(0); + const fakeRecaptchaToken = 'fake-recaptcha-token'; stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); stub(client, 'exchangeToken').rejects('exchange error'); diff --git a/packages/app-check/src/api.ts b/packages/app-check/src/api.ts index 596b1a402a3..b885cec1201 100644 --- a/packages/app-check/src/api.ts +++ b/packages/app-check/src/api.ts @@ -32,7 +32,8 @@ import { getToken as getTokenInternal, addTokenListener, removeTokenListener, - isValid + isValid, + notifyTokenListeners } from './internal-api'; import { readTokenFromStorage } from './storage'; import { getDebugToken, initializeDebugMode, isDebugMode } from './debug'; @@ -97,6 +98,17 @@ export function initializeAppCheck( const appCheck = provider.initialize({ options }); _activate(app, options.provider, options.isTokenAutoRefreshEnabled); + // If isTokenAutoRefreshEnabled is false, do not send any requests to the + // exchange endpoint without an explicit call from the user either directly + // or through another Firebase library (storage, functions, etc.) + if (getState(app).isTokenAutoRefreshEnabled) { + // Adding a listener will start the refresher and fetch a token if needed. + // This gets a token ready and prevents a delay when an internal library + // requests the token. + // Listener function does not need to do anything, its base functionality + // of calling getToken() already fetches token and writes it to memory/storage. + addTokenListener(appCheck, ListenerType.INTERNAL, () => {}); + } return appCheck; } @@ -123,6 +135,8 @@ function _activate( newState.cachedTokenPromise = readTokenFromStorage(app).then(cachedToken => { if (cachedToken && isValid(cachedToken)) { setState(app, { ...getState(app), token: cachedToken }); + // notify all listeners with the cached token + notifyTokenListeners(app, { token: cachedToken.token }); } return cachedToken; }); diff --git a/packages/app-check/src/constants.ts b/packages/app-check/src/constants.ts index ecb7fb49a87..8e61b3a8745 100644 --- a/packages/app-check/src/constants.ts +++ b/packages/app-check/src/constants.ts @@ -38,3 +38,8 @@ export const TOKEN_REFRESH_TIME = { */ RETRIAL_MAX_WAIT: 16 * 60 * 1000 }; + +/** + * One day in millis, for certain error code backoffs. + */ +export const ONE_DAY = 24 * 60 * 60 * 1000; diff --git a/packages/app-check/src/errors.ts b/packages/app-check/src/errors.ts index 05324b639ee..c6f088b371b 100644 --- a/packages/app-check/src/errors.ts +++ b/packages/app-check/src/errors.ts @@ -26,7 +26,8 @@ export const enum AppCheckError { STORAGE_OPEN = 'storage-open', STORAGE_GET = 'storage-get', STORAGE_WRITE = 'storage-set', - RECAPTCHA_ERROR = 'recaptcha-error' + RECAPTCHA_ERROR = 'recaptcha-error', + THROTTLED = 'throttled' } const ERRORS: ErrorMap = { @@ -52,7 +53,8 @@ const ERRORS: ErrorMap = { 'Error thrown when reading from storage. Original error: {$originalErrorMessage}.', [AppCheckError.STORAGE_WRITE]: 'Error thrown when writing to storage. Original error: {$originalErrorMessage}.', - [AppCheckError.RECAPTCHA_ERROR]: 'ReCAPTCHA error.' + [AppCheckError.RECAPTCHA_ERROR]: 'ReCAPTCHA error.', + [AppCheckError.THROTTLED]: `Requests throttled due to {$httpStatus} error. Attempts allowed again after {$time}` }; interface ErrorParams { @@ -64,6 +66,7 @@ interface ErrorParams { [AppCheckError.STORAGE_OPEN]: { originalErrorMessage?: string }; [AppCheckError.STORAGE_GET]: { originalErrorMessage?: string }; [AppCheckError.STORAGE_WRITE]: { originalErrorMessage?: string }; + [AppCheckError.THROTTLED]: { time: string; httpStatus: number }; } export const ERROR_FACTORY = new ErrorFactory( diff --git a/packages/app-check/src/internal-api.test.ts b/packages/app-check/src/internal-api.test.ts index 779b84fb9fc..f5d4954224f 100644 --- a/packages/app-check/src/internal-api.test.ts +++ b/packages/app-check/src/internal-api.test.ts @@ -38,12 +38,14 @@ import * as reCAPTCHA from './recaptcha'; import * as client from './client'; import * as storage from './storage'; import * as util from './util'; +import { logger } from './logger'; import { getState, clearState, setState, getDebugState } from './state'; import { AppCheckTokenListener } from './public-types'; -import { Deferred } from '@firebase/util'; +import { Deferred, FirebaseError } from '@firebase/util'; import { ReCaptchaEnterpriseProvider, ReCaptchaV3Provider } from './providers'; import { AppCheckService } from './factory'; import { ListenerType } from './types'; +import { AppCheckError } from './errors'; const fakeRecaptchaToken = 'fake-recaptcha-token'; const fakeRecaptchaAppCheckToken = { @@ -385,6 +387,62 @@ describe('internal api', () => { ); expect(token).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token }); }); + + it('throttles for a period less than 1d on 503', async () => { + // More detailed check of exponential backoff in providers.test.ts + const appCheck = initializeAppCheck(app, { + provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) + }); + const warnStub = stub(logger, 'warn'); + stub(client, 'exchangeToken').returns( + Promise.reject( + new FirebaseError( + AppCheckError.FETCH_STATUS_ERROR, + 'test error msg', + { httpStatus: 503 } + ) + ) + ); + + const token = await getToken(appCheck as AppCheckService); + + // ReCaptchaV3Provider's _throttleData is private so checking + // the resulting error message to be sure it has roughly the + // correct throttle time. This also tests the time formatter. + // Check both the error itself and that it makes it through to + // console.warn + expect(token.error?.message).to.include('503'); + expect(token.error?.message).to.include('00m'); + expect(token.error?.message).to.not.include('1d'); + expect(warnStub.args[0][0]).to.include('503'); + }); + + it('throttles 1d on 403', async () => { + const appCheck = initializeAppCheck(app, { + provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) + }); + const warnStub = stub(logger, 'warn'); + stub(client, 'exchangeToken').returns( + Promise.reject( + new FirebaseError( + AppCheckError.FETCH_STATUS_ERROR, + 'test error msg', + { httpStatus: 403 } + ) + ) + ); + + const token = await getToken(appCheck as AppCheckService); + + // ReCaptchaV3Provider's _throttleData is private so checking + // the resulting error message to be sure it has roughly the + // correct throttle time. This also tests the time formatter. + // Check both the error itself and that it makes it through to + // console.warn + expect(token.error?.message).to.include('403'); + expect(token.error?.message).to.include('1d'); + expect(warnStub.args[0][0]).to.include('403'); + }); }); describe('addTokenListener', () => { @@ -404,7 +462,7 @@ describe('internal api', () => { expect(getState(app).tokenObservers[0].next).to.equal(listener); }); - it('starts proactively refreshing token after adding the first listener', () => { + it('starts proactively refreshing token after adding the first listener', async () => { const listener = (): void => {}; setState(app, { ...getState(app), @@ -420,6 +478,12 @@ describe('internal api', () => { listener ); + expect(getState(app).tokenRefresher?.isRunning()).to.be.undefined; + + // addTokenListener() waits for the result of cachedTokenPromise + // before starting the refresher + await getState(app).cachedTokenPromise; + expect(getState(app).tokenRefresher?.isRunning()).to.be.true; }); @@ -430,6 +494,7 @@ describe('internal api', () => { setState(app, { ...getState(app), + cachedTokenPromise: Promise.resolve(undefined), token: { token: `fake-memory-app-check-token`, expireTimeMillis: Date.now() + 60000, @@ -493,7 +558,7 @@ describe('internal api', () => { expect(getState(app).tokenObservers.length).to.equal(0); }); - it('should stop proactively refreshing token after deleting the last listener', () => { + it('should stop proactively refreshing token after deleting the last listener', async () => { const listener = (): void => {}; setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true }); setState(app, { @@ -506,6 +571,11 @@ describe('internal api', () => { ListenerType.INTERNAL, listener ); + + // addTokenListener() waits for the result of cachedTokenPromise + // before starting the refresher + await getState(app).cachedTokenPromise; + expect(getState(app).tokenObservers.length).to.equal(1); expect(getState(app).tokenRefresher?.isRunning()).to.be.true; diff --git a/packages/app-check/src/internal-api.ts b/packages/app-check/src/internal-api.ts index fee45d72b12..b7885b1b524 100644 --- a/packages/app-check/src/internal-api.ts +++ b/packages/app-check/src/internal-api.ts @@ -30,9 +30,10 @@ import { ensureActivated } from './util'; import { exchangeToken, getExchangeDebugTokenRequest } from './client'; import { writeTokenToStorage } from './storage'; import { getDebugToken, isDebugMode } from './debug'; -import { base64 } from '@firebase/util'; +import { base64, FirebaseError } from '@firebase/util'; import { logger } from './logger'; import { AppCheckService } from './factory'; +import { AppCheckError } from './errors'; // Initial hardcoded value agreed upon across platforms for initial launch. // Format left open for possible dynamic error values and other fields in the future. @@ -80,10 +81,6 @@ export async function getToken( const cachedToken = await state.cachedTokenPromise; if (cachedToken && isValid(cachedToken)) { token = cachedToken; - - setState(app, { ...state, token }); - // notify all listeners with the cached token - notifyTokenListeners(app, { token: token.token }); } } @@ -94,16 +91,30 @@ export async function getToken( }; } + // Only set to true if this `getToken()` call is making the actual + // REST call to the exchange endpoint, versus waiting for an already + // in-flight call (see debug and regular exchange endpoint paths below) + let shouldCallListeners = false; + /** * DEBUG MODE * If debug mode is set, and there is no cached token, fetch a new App * Check token using the debug token, and return it directly. */ if (isDebugMode()) { - const tokenFromDebugExchange: AppCheckTokenInternal = await exchangeToken( - getExchangeDebugTokenRequest(app, await getDebugToken()), - appCheck.platformLoggerProvider - ); + // Avoid making another call to the exchange endpoint if one is in flight. + if (!state.exchangeTokenPromise) { + state.exchangeTokenPromise = exchangeToken( + getExchangeDebugTokenRequest(app, await getDebugToken()), + appCheck.platformLoggerProvider + ).then(token => { + state.exchangeTokenPromise = undefined; + return token; + }); + shouldCallListeners = true; + } + const tokenFromDebugExchange: AppCheckTokenInternal = + await state.exchangeTokenPromise; // Write debug token to indexedDB. await writeTokenToStorage(app, tokenFromDebugExchange); // Write debug token to state. @@ -115,14 +126,28 @@ export async function getToken( * request a new token */ try { - // state.provider is populated in initializeAppCheck() - // ensureActivated() at the top of this function checks that - // initializeAppCheck() has been called. - token = await state.provider!.getToken(); + // Avoid making another call to the exchange endpoint if one is in flight. + if (!state.exchangeTokenPromise) { + // state.provider is populated in initializeAppCheck() + // ensureActivated() at the top of this function checks that + // initializeAppCheck() has been called. + state.exchangeTokenPromise = state.provider!.getToken().then(token => { + state.exchangeTokenPromise = undefined; + return token; + }); + shouldCallListeners = true; + } + token = await state.exchangeTokenPromise; } catch (e) { - // `getToken()` should never throw, but logging error text to console will aid debugging. - logger.error(e); - error = e; + if ((e as FirebaseError).code === `appCheck/${AppCheckError.THROTTLED}`) { + // Warn if throttled, but do not treat it as an error. + logger.warn((e as FirebaseError).message); + } else { + // `getToken()` should never throw, but logging error text to console will aid debugging. + logger.error(e); + } + // Always save error to be added to dummy token. + error = e as FirebaseError; } let interopTokenResult: AppCheckTokenResult | undefined; @@ -140,7 +165,9 @@ export async function getToken( await writeTokenToStorage(app, token); } - notifyTokenListeners(app, interopTokenResult); + if (shouldCallListeners) { + notifyTokenListeners(app, interopTokenResult); + } return interopTokenResult; } @@ -157,49 +184,37 @@ export function addTokenListener( error: onError, type }; - const newState = { + setState(app, { ...state, tokenObservers: [...state.tokenObservers, tokenObserver] - }; - /** - * Invoke the listener with the valid token, then start the token refresher - */ - if (!newState.tokenRefresher) { - const tokenRefresher = createTokenRefresher(appCheck); - newState.tokenRefresher = tokenRefresher; - } - - // Create the refresher but don't start it if `isTokenAutoRefreshEnabled` - // is not true. - if (!newState.tokenRefresher.isRunning() && state.isTokenAutoRefreshEnabled) { - newState.tokenRefresher.start(); - } + }); // Invoke the listener async immediately if there is a valid token // in memory. 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 */ - }); - } else if (state.token == null) { - // Only check cache if there was no token. If the token was invalid, - // skip this and rely on exchange endpoint. - void state - .cachedTokenPromise! // Storage token promise. Always populated in `activate()`. - .then(cachedToken => { - if (cachedToken && isValid(cachedToken)) { - listener({ token: cachedToken.token }); - } + .then(() => { + listener({ token: validToken.token }); + initTokenRefresher(appCheck); }) .catch(() => { - /** Ignore errors in listeners. */ + /* we don't care about exceptions thrown in listeners */ }); } - setState(app, newState); + /** + * Wait for any cached token promise to resolve before starting the token + * refresher. The refresher checks to see if there is an existing token + * in state and calls the exchange endpoint if not. We should first let the + * IndexedDB check have a chance to populate state if it can. + * + * Listener call isn't needed here because cachedTokenPromise will call any + * listeners that exist when it resolves. + */ + + // state.cachedTokenPromise is always populated in `activate()`. + void state.cachedTokenPromise!.then(() => initTokenRefresher(appCheck)); } export function removeTokenListener( @@ -225,6 +240,24 @@ export function removeTokenListener( }); } +/** + * Logic to create and start refresher as needed. + */ +function initTokenRefresher(appCheck: AppCheckService): void { + const { app } = appCheck; + const state = getState(app); + // Create the refresher but don't start it if `isTokenAutoRefreshEnabled` + // is not true. + let refresher: Refresher | undefined = state.tokenRefresher; + if (!refresher) { + refresher = createTokenRefresher(appCheck); + setState(app, { ...state, tokenRefresher: refresher }); + } + if (!refresher.isRunning() && state.isTokenAutoRefreshEnabled) { + refresher.start(); + } +} + function createTokenRefresher(appCheck: AppCheckService): Refresher { const { app } = appCheck; return new Refresher( @@ -247,7 +280,6 @@ function createTokenRefresher(appCheck: AppCheckService): Refresher { } }, () => { - // TODO: when should we retry? return true; }, () => { @@ -277,7 +309,7 @@ function createTokenRefresher(appCheck: AppCheckService): Refresher { ); } -function notifyTokenListeners( +export function notifyTokenListeners( app: FirebaseApp, token: AppCheckTokenResult ): void { diff --git a/packages/app-check/src/providers.test.ts b/packages/app-check/src/providers.test.ts new file mode 100644 index 00000000000..c9734d1ffc3 --- /dev/null +++ b/packages/app-check/src/providers.test.ts @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2021 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 '../test/setup'; +import { getFakeGreCAPTCHA, getFullApp } from '../test/util'; +import { ReCaptchaEnterpriseProvider, ReCaptchaV3Provider } from './providers'; +import * as client from './client'; +import * as reCAPTCHA from './recaptcha'; +import * as util from './util'; +import { stub, useFakeTimers } from 'sinon'; +import { expect } from 'chai'; +import { FirebaseError } from '@firebase/util'; +import { AppCheckError } from './errors'; +import { clearState } from './state'; +import { deleteApp, FirebaseApp } from '@firebase/app'; + +describe('ReCaptchaV3Provider', () => { + let app: FirebaseApp; + let clock = useFakeTimers(); + beforeEach(() => { + clock = useFakeTimers(); + app = getFullApp(); + stub(util, 'getRecaptcha').returns(getFakeGreCAPTCHA()); + stub(reCAPTCHA, 'getToken').returns( + Promise.resolve('fake-recaptcha-token') + ); + }); + + afterEach(() => { + clock.restore(); + clearState(); + return deleteApp(app); + }); + it('getToken() gets a token from the exchange endpoint', async () => { + const app = getFullApp(); + const provider = new ReCaptchaV3Provider('fake-site-key'); + stub(client, 'exchangeToken').resolves({ + token: 'fake-exchange-token', + issuedAtTimeMillis: 0, + expireTimeMillis: 10 + }); + provider.initialize(app); + const token = await provider.getToken(); + expect(token.token).to.equal('fake-exchange-token'); + }); + it('getToken() throttles 1d on 403', async () => { + const app = getFullApp(); + const provider = new ReCaptchaV3Provider('fake-site-key'); + stub(client, 'exchangeToken').rejects( + new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', { + httpStatus: 403 + }) + ); + provider.initialize(app); + await expect(provider.getToken()).to.be.rejectedWith('1d'); + // Wait 10s and try again to see if wait time string decreases. + clock.tick(10000); + await expect(provider.getToken()).to.be.rejectedWith('23h'); + }); + it('getToken() throttles exponentially on 503', async () => { + const app = getFullApp(); + const provider = new ReCaptchaV3Provider('fake-site-key'); + let exchangeTokenStub = stub(client, 'exchangeToken').rejects( + new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', { + httpStatus: 503 + }) + ); + provider.initialize(app); + await expect(provider.getToken()).to.be.rejectedWith('503'); + expect(exchangeTokenStub).to.be.called; + exchangeTokenStub.resetHistory(); + // Try again immediately, should be rejected. + await expect(provider.getToken()).to.be.rejectedWith('503'); + expect(exchangeTokenStub).not.to.be.called; + exchangeTokenStub.resetHistory(); + // Times below are max range of each random exponential wait, + // the possible range is 2^(backoff_count) plus or minus 50% + // Wait for 1.5 seconds to pass, should call exchange endpoint again + // (and be rejected again) + clock.tick(1500); + await expect(provider.getToken()).to.be.rejectedWith('503'); + expect(exchangeTokenStub).to.be.called; + exchangeTokenStub.resetHistory(); + // Wait for 3 seconds to pass, should call exchange endpoint again + // (and be rejected again) + clock.tick(3000); + await expect(provider.getToken()).to.be.rejectedWith('503'); + expect(exchangeTokenStub).to.be.called; + // Wait for 6 seconds to pass, should call exchange endpoint again + // (and succeed) + clock.tick(6000); + exchangeTokenStub.restore(); + exchangeTokenStub = stub(client, 'exchangeToken').resolves({ + token: 'fake-exchange-token', + issuedAtTimeMillis: 0, + expireTimeMillis: 10 + }); + const token = await provider.getToken(); + expect(token.token).to.equal('fake-exchange-token'); + }); +}); + +describe('ReCaptchaEnterpriseProvider', () => { + let app: FirebaseApp; + let clock = useFakeTimers(); + beforeEach(() => { + clock = useFakeTimers(); + app = getFullApp(); + stub(util, 'getRecaptcha').returns(getFakeGreCAPTCHA()); + stub(reCAPTCHA, 'getToken').returns( + Promise.resolve('fake-recaptcha-token') + ); + }); + + afterEach(() => { + clock.restore(); + clearState(); + return deleteApp(app); + }); + it('getToken() gets a token from the exchange endpoint', async () => { + const app = getFullApp(); + const provider = new ReCaptchaEnterpriseProvider('fake-site-key'); + stub(client, 'exchangeToken').resolves({ + token: 'fake-exchange-token', + issuedAtTimeMillis: 0, + expireTimeMillis: 10 + }); + provider.initialize(app); + const token = await provider.getToken(); + expect(token.token).to.equal('fake-exchange-token'); + }); + it('getToken() throttles 1d on 403', async () => { + const app = getFullApp(); + const provider = new ReCaptchaEnterpriseProvider('fake-site-key'); + stub(client, 'exchangeToken').rejects( + new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', { + httpStatus: 403 + }) + ); + provider.initialize(app); + await expect(provider.getToken()).to.be.rejectedWith('1d'); + // Wait 10s and try again to see if wait time string decreases. + clock.tick(10000); + await expect(provider.getToken()).to.be.rejectedWith('23h'); + }); + it('getToken() throttles exponentially on 503', async () => { + const app = getFullApp(); + const provider = new ReCaptchaEnterpriseProvider('fake-site-key'); + let exchangeTokenStub = stub(client, 'exchangeToken').rejects( + new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', { + httpStatus: 503 + }) + ); + provider.initialize(app); + await expect(provider.getToken()).to.be.rejectedWith('503'); + expect(exchangeTokenStub).to.be.called; + exchangeTokenStub.resetHistory(); + // Try again immediately, should be rejected. + await expect(provider.getToken()).to.be.rejectedWith('503'); + expect(exchangeTokenStub).not.to.be.called; + exchangeTokenStub.resetHistory(); + // Times below are max range of each random exponential wait, + // the possible range is 2^(backoff_count) plus or minus 50% + // Wait for 1.5 seconds to pass, should call exchange endpoint again + // (and be rejected again) + clock.tick(1500); + await expect(provider.getToken()).to.be.rejectedWith('503'); + expect(exchangeTokenStub).to.be.called; + exchangeTokenStub.resetHistory(); + // Wait for 3 seconds to pass, should call exchange endpoint again + // (and be rejected again) + clock.tick(3000); + await expect(provider.getToken()).to.be.rejectedWith('503'); + expect(exchangeTokenStub).to.be.called; + // Wait for 6 seconds to pass, should call exchange endpoint again + // (and succeed) + clock.tick(6000); + exchangeTokenStub.restore(); + exchangeTokenStub = stub(client, 'exchangeToken').resolves({ + token: 'fake-exchange-token', + issuedAtTimeMillis: 0, + expireTimeMillis: 10 + }); + const token = await provider.getToken(); + expect(token.token).to.equal('fake-exchange-token'); + }); +}); diff --git a/packages/app-check/src/providers.ts b/packages/app-check/src/providers.ts index ab1009c5148..b47b00a02e3 100644 --- a/packages/app-check/src/providers.ts +++ b/packages/app-check/src/providers.ts @@ -17,12 +17,17 @@ import { FirebaseApp, _getProvider } from '@firebase/app'; import { Provider } from '@firebase/component'; -import { issuedAtTime } from '@firebase/util'; +import { + FirebaseError, + issuedAtTime, + calculateBackoffMillis +} from '@firebase/util'; import { exchangeToken, getExchangeRecaptchaEnterpriseTokenRequest, getExchangeRecaptchaV3TokenRequest } from './client'; +import { ONE_DAY } from './constants'; import { AppCheckError, ERROR_FACTORY } from './errors'; import { CustomProviderOptions } from './public-types'; import { @@ -30,7 +35,8 @@ import { initializeV3 as initializeRecaptchaV3, initializeEnterprise as initializeRecaptchaEnterprise } from './recaptcha'; -import { AppCheckProvider, AppCheckTokenInternal } from './types'; +import { AppCheckProvider, AppCheckTokenInternal, ThrottleData } from './types'; +import { getDurationString } from './util'; /** * App Check provider that can obtain a reCAPTCHA V3 token and exchange it @@ -41,6 +47,11 @@ import { AppCheckProvider, AppCheckTokenInternal } from './types'; export class ReCaptchaV3Provider implements AppCheckProvider { private _app?: FirebaseApp; private _platformLoggerProvider?: Provider<'platform-logger'>; + /** + * Throttle requests on certain error codes to prevent too many retries + * in a short time. + */ + private _throttleData: ThrottleData | null = null; /** * Create a ReCaptchaV3Provider instance. * @param siteKey - ReCAPTCHA V3 siteKey. @@ -52,6 +63,8 @@ export class ReCaptchaV3Provider implements AppCheckProvider { * @internal */ async getToken(): Promise { + throwIfThrottled(this._throttleData); + // Top-level `getToken()` has already checked that App Check is initialized // and therefore this._app and this._platformLoggerProvider are available. const attestedClaimsToken = await getReCAPTCHAToken(this._app!).catch( @@ -60,10 +73,31 @@ export class ReCaptchaV3Provider implements AppCheckProvider { throw ERROR_FACTORY.create(AppCheckError.RECAPTCHA_ERROR); } ); - return exchangeToken( - getExchangeRecaptchaV3TokenRequest(this._app!, attestedClaimsToken), - this._platformLoggerProvider! - ); + let result; + try { + result = await exchangeToken( + getExchangeRecaptchaV3TokenRequest(this._app!, attestedClaimsToken), + this._platformLoggerProvider! + ); + } catch (e) { + if ((e as FirebaseError).code === AppCheckError.FETCH_STATUS_ERROR) { + this._throttleData = setBackoff( + Number((e as FirebaseError).customData?.httpStatus), + this._throttleData + ); + throw ERROR_FACTORY.create(AppCheckError.THROTTLED, { + time: getDurationString( + this._throttleData.allowRequestsAfter - Date.now() + ), + httpStatus: this._throttleData.httpStatus + }); + } else { + throw e; + } + } + // If successful, clear throttle data. + this._throttleData = null; + return result; } /** @@ -98,6 +132,11 @@ export class ReCaptchaV3Provider implements AppCheckProvider { export class ReCaptchaEnterpriseProvider implements AppCheckProvider { private _app?: FirebaseApp; private _platformLoggerProvider?: Provider<'platform-logger'>; + /** + * Throttle requests on certain error codes to prevent too many retries + * in a short time. + */ + private _throttleData: ThrottleData | null = null; /** * Create a ReCaptchaEnterpriseProvider instance. * @param siteKey - reCAPTCHA Enterprise score-based site key. @@ -109,6 +148,7 @@ export class ReCaptchaEnterpriseProvider implements AppCheckProvider { * @internal */ async getToken(): Promise { + throwIfThrottled(this._throttleData); // Top-level `getToken()` has already checked that App Check is initialized // and therefore this._app and this._platformLoggerProvider are available. const attestedClaimsToken = await getReCAPTCHAToken(this._app!).catch( @@ -117,13 +157,34 @@ export class ReCaptchaEnterpriseProvider implements AppCheckProvider { throw ERROR_FACTORY.create(AppCheckError.RECAPTCHA_ERROR); } ); - return exchangeToken( - getExchangeRecaptchaEnterpriseTokenRequest( - this._app!, - attestedClaimsToken - ), - this._platformLoggerProvider! - ); + let result; + try { + result = await exchangeToken( + getExchangeRecaptchaEnterpriseTokenRequest( + this._app!, + attestedClaimsToken + ), + this._platformLoggerProvider! + ); + } catch (e) { + if ((e as FirebaseError).code === AppCheckError.FETCH_STATUS_ERROR) { + this._throttleData = setBackoff( + Number((e as FirebaseError).customData?.httpStatus), + this._throttleData + ); + throw ERROR_FACTORY.create(AppCheckError.THROTTLED, { + time: getDurationString( + this._throttleData.allowRequestsAfter - Date.now() + ), + httpStatus: this._throttleData.httpStatus + }); + } else { + throw e; + } + } + // If successful, clear throttle data. + this._throttleData = null; + return result; } /** @@ -200,3 +261,58 @@ export class CustomProvider implements AppCheckProvider { } } } + +/** + * Set throttle data to block requests until after a certain time + * depending on the failed request's status code. + * @param httpStatus - Status code of failed request. + * @param throttleData - `ThrottleData` object containing previous throttle + * data state. + * @returns Data about current throttle state and expiration time. + */ +function setBackoff( + httpStatus: number, + throttleData: ThrottleData | null +): ThrottleData { + /** + * Block retries for 1 day for the following error codes: + * + * 404: Likely malformed URL. + * + * 403: + * - Attestation failed + * - Wrong API key + * - Project deleted + */ + if (httpStatus === 404 || httpStatus === 403) { + return { + backoffCount: 1, + allowRequestsAfter: Date.now() + ONE_DAY, + httpStatus + }; + } else { + /** + * For all other error codes, the time when it is ok to retry again + * is based on exponential backoff. + */ + const backoffCount = throttleData ? throttleData.backoffCount : 0; + const backoffMillis = calculateBackoffMillis(backoffCount, 1000, 2); + return { + backoffCount: backoffCount + 1, + allowRequestsAfter: Date.now() + backoffMillis, + httpStatus + }; + } +} + +function throwIfThrottled(throttleData: ThrottleData | null): void { + if (throttleData) { + if (Date.now() - throttleData.allowRequestsAfter <= 0) { + // If before, throw. + throw ERROR_FACTORY.create(AppCheckError.THROTTLED, { + time: getDurationString(throttleData.allowRequestsAfter - Date.now()), + httpStatus: throttleData.httpStatus + }); + } + } +} diff --git a/packages/app-check/src/state.ts b/packages/app-check/src/state.ts index 27d9de08816..0a81ba81636 100644 --- a/packages/app-check/src/state.ts +++ b/packages/app-check/src/state.ts @@ -30,6 +30,7 @@ export interface AppCheckState { provider?: AppCheckProvider; token?: AppCheckTokenInternal; cachedTokenPromise?: Promise; + exchangeTokenPromise?: Promise; tokenRefresher?: Refresher; reCAPTCHAState?: ReCAPTCHAState; isTokenAutoRefreshEnabled?: boolean; diff --git a/packages/app-check/src/types.ts b/packages/app-check/src/types.ts index 21b2aa92622..534509d04d4 100644 --- a/packages/app-check/src/types.ts +++ b/packages/app-check/src/types.ts @@ -75,6 +75,12 @@ export interface AppCheckProvider { */ export type _AppCheckInternalComponentName = 'app-check-internal'; +export interface ThrottleData { + allowRequestsAfter: number; + backoffCount: number; + httpStatus: number; +} + declare module '@firebase/component' { interface NameServiceMapping { 'app-check-internal': FirebaseAppCheckInternal; diff --git a/packages/app-check/src/util.ts b/packages/app-check/src/util.ts index dd699141da8..2d077961370 100644 --- a/packages/app-check/src/util.ts +++ b/packages/app-check/src/util.ts @@ -47,3 +47,30 @@ export function uuidv4(): string { return v.toString(16); }); } + +export function getDurationString(durationInMillis: number): string { + const totalSeconds = Math.round(durationInMillis / 1000); + const days = Math.floor(totalSeconds / (3600 * 24)); + const hours = Math.floor((totalSeconds - days * 3600 * 24) / 3600); + const minutes = Math.floor( + (totalSeconds - days * 3600 * 24 - hours * 3600) / 60 + ); + const seconds = totalSeconds - days * 3600 * 24 - hours * 3600 - minutes * 60; + + let result = ''; + if (days) { + result += pad(days) + 'd:'; + } + if (hours) { + result += pad(hours) + 'h:'; + } + result += pad(minutes) + 'm:' + pad(seconds) + 's'; + return result; +} + +function pad(value: number): string { + if (value === 0) { + return '00'; + } + return value >= 10 ? value.toString() : '0' + value; +}