@firebase/app-check 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { _getProvider, getApp, _registerComponent, registerVersion } from '@firebase/app';
2
2
  import { Component } from '@firebase/component';
3
- import { Deferred, ErrorFactory, isIndexedDBAvailable, getGlobal, base64, issuedAtTime, getModularInstance } from '@firebase/util';
3
+ import { Deferred, ErrorFactory, isIndexedDBAvailable, getGlobal, base64, issuedAtTime, calculateBackoffMillis, getModularInstance } from '@firebase/util';
4
4
  import { Logger } from '@firebase/logger';
5
5
 
6
6
  /**
@@ -73,7 +73,11 @@ const TOKEN_REFRESH_TIME = {
73
73
  * This is the maximum retrial wait, currently 16 minutes.
74
74
  */
75
75
  RETRIAL_MAX_WAIT: 16 * 60 * 1000
76
- };
76
+ };
77
+ /**
78
+ * One day in millis, for certain error code backoffs.
79
+ */
80
+ const ONE_DAY = 24 * 60 * 60 * 1000;
77
81
 
78
82
  /**
79
83
  * @license
@@ -214,7 +218,8 @@ const ERRORS = {
214
218
  ["storage-open" /* STORAGE_OPEN */]: 'Error thrown when opening storage. Original error: {$originalErrorMessage}.',
215
219
  ["storage-get" /* STORAGE_GET */]: 'Error thrown when reading from storage. Original error: {$originalErrorMessage}.',
216
220
  ["storage-set" /* STORAGE_WRITE */]: 'Error thrown when writing to storage. Original error: {$originalErrorMessage}.',
217
- ["recaptcha-error" /* RECAPTCHA_ERROR */]: 'ReCAPTCHA error.'
221
+ ["recaptcha-error" /* RECAPTCHA_ERROR */]: 'ReCAPTCHA error.',
222
+ ["throttled" /* THROTTLED */]: `Requests throttled due to {$httpStatus} error. Attempts allowed again after {$time}`
218
223
  };
219
224
  const ERROR_FACTORY = new ErrorFactory('appCheck', 'AppCheck', ERRORS);
220
225
 
@@ -256,6 +261,28 @@ function uuidv4() {
256
261
  const r = (Math.random() * 16) | 0, v = c === 'x' ? r : (r & 0x3) | 0x8;
257
262
  return v.toString(16);
258
263
  });
264
+ }
265
+ function getDurationString(durationInMillis) {
266
+ const totalSeconds = Math.round(durationInMillis / 1000);
267
+ const days = Math.floor(totalSeconds / (3600 * 24));
268
+ const hours = Math.floor((totalSeconds - days * 3600 * 24) / 3600);
269
+ const minutes = Math.floor((totalSeconds - days * 3600 * 24 - hours * 3600) / 60);
270
+ const seconds = totalSeconds - days * 3600 * 24 - hours * 3600 - minutes * 60;
271
+ let result = '';
272
+ if (days) {
273
+ result += pad(days) + 'd:';
274
+ }
275
+ if (hours) {
276
+ result += pad(hours) + 'h:';
277
+ }
278
+ result += pad(minutes) + 'm:' + pad(seconds) + 's';
279
+ return result;
280
+ }
281
+ function pad(value) {
282
+ if (value === 0) {
283
+ return '00';
284
+ }
285
+ return value >= 10 ? value.toString() : '0' + value;
259
286
  }
260
287
 
261
288
  /**
@@ -673,9 +700,6 @@ async function getToken$2(appCheck, forceRefresh = false) {
673
700
  const cachedToken = await state.cachedTokenPromise;
674
701
  if (cachedToken && isValid(cachedToken)) {
675
702
  token = cachedToken;
676
- setState(app, Object.assign(Object.assign({}, state), { token }));
677
- // notify all listeners with the cached token
678
- notifyTokenListeners(app, { token: token.token });
679
703
  }
680
704
  }
681
705
  // Return the cached token (from either memory or indexedDB) if it's valid
@@ -684,13 +708,25 @@ async function getToken$2(appCheck, forceRefresh = false) {
684
708
  token: token.token
685
709
  };
686
710
  }
711
+ // Only set to true if this `getToken()` call is making the actual
712
+ // REST call to the exchange endpoint, versus waiting for an already
713
+ // in-flight call (see debug and regular exchange endpoint paths below)
714
+ let shouldCallListeners = false;
687
715
  /**
688
716
  * DEBUG MODE
689
717
  * If debug mode is set, and there is no cached token, fetch a new App
690
718
  * Check token using the debug token, and return it directly.
691
719
  */
692
720
  if (isDebugMode()) {
693
- const tokenFromDebugExchange = await exchangeToken(getExchangeDebugTokenRequest(app, await getDebugToken()), appCheck.platformLoggerProvider);
721
+ // Avoid making another call to the exchange endpoint if one is in flight.
722
+ if (!state.exchangeTokenPromise) {
723
+ state.exchangeTokenPromise = exchangeToken(getExchangeDebugTokenRequest(app, await getDebugToken()), appCheck.platformLoggerProvider).then(token => {
724
+ state.exchangeTokenPromise = undefined;
725
+ return token;
726
+ });
727
+ shouldCallListeners = true;
728
+ }
729
+ const tokenFromDebugExchange = await state.exchangeTokenPromise;
694
730
  // Write debug token to indexedDB.
695
731
  await writeTokenToStorage(app, tokenFromDebugExchange);
696
732
  // Write debug token to state.
@@ -701,14 +737,29 @@ async function getToken$2(appCheck, forceRefresh = false) {
701
737
  * request a new token
702
738
  */
703
739
  try {
704
- // state.provider is populated in initializeAppCheck()
705
- // ensureActivated() at the top of this function checks that
706
- // initializeAppCheck() has been called.
707
- token = await state.provider.getToken();
740
+ // Avoid making another call to the exchange endpoint if one is in flight.
741
+ if (!state.exchangeTokenPromise) {
742
+ // state.provider is populated in initializeAppCheck()
743
+ // ensureActivated() at the top of this function checks that
744
+ // initializeAppCheck() has been called.
745
+ state.exchangeTokenPromise = state.provider.getToken().then(token => {
746
+ state.exchangeTokenPromise = undefined;
747
+ return token;
748
+ });
749
+ shouldCallListeners = true;
750
+ }
751
+ token = await state.exchangeTokenPromise;
708
752
  }
709
753
  catch (e) {
710
- // `getToken()` should never throw, but logging error text to console will aid debugging.
711
- logger.error(e);
754
+ if (e.code === `appCheck/${"throttled" /* THROTTLED */}`) {
755
+ // Warn if throttled, but do not treat it as an error.
756
+ logger.warn(e.message);
757
+ }
758
+ else {
759
+ // `getToken()` should never throw, but logging error text to console will aid debugging.
760
+ logger.error(e);
761
+ }
762
+ // Always save error to be added to dummy token.
712
763
  error = e;
713
764
  }
714
765
  let interopTokenResult;
@@ -726,7 +777,9 @@ async function getToken$2(appCheck, forceRefresh = false) {
726
777
  setState(app, Object.assign(Object.assign({}, state), { token }));
727
778
  await writeTokenToStorage(app, token);
728
779
  }
729
- notifyTokenListeners(app, interopTokenResult);
780
+ if (shouldCallListeners) {
781
+ notifyTokenListeners(app, interopTokenResult);
782
+ }
730
783
  return interopTokenResult;
731
784
  }
732
785
  function addTokenListener(appCheck, type, listener, onError) {
@@ -737,44 +790,31 @@ function addTokenListener(appCheck, type, listener, onError) {
737
790
  error: onError,
738
791
  type
739
792
  };
740
- const newState = Object.assign(Object.assign({}, state), { tokenObservers: [...state.tokenObservers, tokenObserver] });
741
- /**
742
- * Invoke the listener with the valid token, then start the token refresher
743
- */
744
- if (!newState.tokenRefresher) {
745
- const tokenRefresher = createTokenRefresher(appCheck);
746
- newState.tokenRefresher = tokenRefresher;
747
- }
748
- // Create the refresher but don't start it if `isTokenAutoRefreshEnabled`
749
- // is not true.
750
- if (!newState.tokenRefresher.isRunning() && state.isTokenAutoRefreshEnabled) {
751
- newState.tokenRefresher.start();
752
- }
793
+ setState(app, Object.assign(Object.assign({}, state), { tokenObservers: [...state.tokenObservers, tokenObserver] }));
753
794
  // Invoke the listener async immediately if there is a valid token
754
795
  // in memory.
755
796
  if (state.token && isValid(state.token)) {
756
797
  const validToken = state.token;
757
798
  Promise.resolve()
758
- .then(() => listener({ token: validToken.token }))
759
- .catch(() => {
760
- /* we don't care about exceptions thrown in listeners */
761
- });
762
- }
763
- else if (state.token == null) {
764
- // Only check cache if there was no token. If the token was invalid,
765
- // skip this and rely on exchange endpoint.
766
- void state
767
- .cachedTokenPromise // Storage token promise. Always populated in `activate()`.
768
- .then(cachedToken => {
769
- if (cachedToken && isValid(cachedToken)) {
770
- listener({ token: cachedToken.token });
771
- }
799
+ .then(() => {
800
+ listener({ token: validToken.token });
801
+ initTokenRefresher(appCheck);
772
802
  })
773
803
  .catch(() => {
774
- /** Ignore errors in listeners. */
804
+ /* we don't care about exceptions thrown in listeners */
775
805
  });
776
806
  }
777
- setState(app, newState);
807
+ /**
808
+ * Wait for any cached token promise to resolve before starting the token
809
+ * refresher. The refresher checks to see if there is an existing token
810
+ * in state and calls the exchange endpoint if not. We should first let the
811
+ * IndexedDB check have a chance to populate state if it can.
812
+ *
813
+ * Listener call isn't needed here because cachedTokenPromise will call any
814
+ * listeners that exist when it resolves.
815
+ */
816
+ // state.cachedTokenPromise is always populated in `activate()`.
817
+ void state.cachedTokenPromise.then(() => initTokenRefresher(appCheck));
778
818
  }
779
819
  function removeTokenListener(app, listener) {
780
820
  const state = getState(app);
@@ -786,6 +826,23 @@ function removeTokenListener(app, listener) {
786
826
  }
787
827
  setState(app, Object.assign(Object.assign({}, state), { tokenObservers: newObservers }));
788
828
  }
829
+ /**
830
+ * Logic to create and start refresher as needed.
831
+ */
832
+ function initTokenRefresher(appCheck) {
833
+ const { app } = appCheck;
834
+ const state = getState(app);
835
+ // Create the refresher but don't start it if `isTokenAutoRefreshEnabled`
836
+ // is not true.
837
+ let refresher = state.tokenRefresher;
838
+ if (!refresher) {
839
+ refresher = createTokenRefresher(appCheck);
840
+ setState(app, Object.assign(Object.assign({}, state), { tokenRefresher: refresher }));
841
+ }
842
+ if (!refresher.isRunning() && state.isTokenAutoRefreshEnabled) {
843
+ refresher.start();
844
+ }
845
+ }
789
846
  function createTokenRefresher(appCheck) {
790
847
  const { app } = appCheck;
791
848
  return new Refresher(
@@ -807,7 +864,6 @@ function createTokenRefresher(appCheck) {
807
864
  throw result.error;
808
865
  }
809
866
  }, () => {
810
- // TODO: when should we retry?
811
867
  return true;
812
868
  }, () => {
813
869
  const state = getState(app);
@@ -903,7 +959,7 @@ function internalFactory(appCheck) {
903
959
  }
904
960
 
905
961
  const name = "@firebase/app-check";
906
- const version = "0.5.1";
962
+ const version = "0.5.2";
907
963
 
908
964
  /**
909
965
  * @license
@@ -1061,19 +1117,44 @@ class ReCaptchaV3Provider {
1061
1117
  */
1062
1118
  constructor(_siteKey) {
1063
1119
  this._siteKey = _siteKey;
1120
+ /**
1121
+ * Throttle requests on certain error codes to prevent too many retries
1122
+ * in a short time.
1123
+ */
1124
+ this._throttleData = null;
1064
1125
  }
1065
1126
  /**
1066
1127
  * Returns an App Check token.
1067
1128
  * @internal
1068
1129
  */
1069
1130
  async getToken() {
1131
+ var _a;
1132
+ throwIfThrottled(this._throttleData);
1070
1133
  // Top-level `getToken()` has already checked that App Check is initialized
1071
1134
  // and therefore this._app and this._platformLoggerProvider are available.
1072
1135
  const attestedClaimsToken = await getToken$1(this._app).catch(_e => {
1073
1136
  // reCaptcha.execute() throws null which is not very descriptive.
1074
1137
  throw ERROR_FACTORY.create("recaptcha-error" /* RECAPTCHA_ERROR */);
1075
1138
  });
1076
- return exchangeToken(getExchangeRecaptchaV3TokenRequest(this._app, attestedClaimsToken), this._platformLoggerProvider);
1139
+ let result;
1140
+ try {
1141
+ result = await exchangeToken(getExchangeRecaptchaV3TokenRequest(this._app, attestedClaimsToken), this._platformLoggerProvider);
1142
+ }
1143
+ catch (e) {
1144
+ if (e.code === "fetch-status-error" /* FETCH_STATUS_ERROR */) {
1145
+ this._throttleData = setBackoff(Number((_a = e.customData) === null || _a === void 0 ? void 0 : _a.httpStatus), this._throttleData);
1146
+ throw ERROR_FACTORY.create("throttled" /* THROTTLED */, {
1147
+ time: getDurationString(this._throttleData.allowRequestsAfter - Date.now()),
1148
+ httpStatus: this._throttleData.httpStatus
1149
+ });
1150
+ }
1151
+ else {
1152
+ throw e;
1153
+ }
1154
+ }
1155
+ // If successful, clear throttle data.
1156
+ this._throttleData = null;
1157
+ return result;
1077
1158
  }
1078
1159
  /**
1079
1160
  * @internal
@@ -1110,19 +1191,44 @@ class ReCaptchaEnterpriseProvider {
1110
1191
  */
1111
1192
  constructor(_siteKey) {
1112
1193
  this._siteKey = _siteKey;
1194
+ /**
1195
+ * Throttle requests on certain error codes to prevent too many retries
1196
+ * in a short time.
1197
+ */
1198
+ this._throttleData = null;
1113
1199
  }
1114
1200
  /**
1115
1201
  * Returns an App Check token.
1116
1202
  * @internal
1117
1203
  */
1118
1204
  async getToken() {
1205
+ var _a;
1206
+ throwIfThrottled(this._throttleData);
1119
1207
  // Top-level `getToken()` has already checked that App Check is initialized
1120
1208
  // and therefore this._app and this._platformLoggerProvider are available.
1121
1209
  const attestedClaimsToken = await getToken$1(this._app).catch(_e => {
1122
1210
  // reCaptcha.execute() throws null which is not very descriptive.
1123
1211
  throw ERROR_FACTORY.create("recaptcha-error" /* RECAPTCHA_ERROR */);
1124
1212
  });
1125
- return exchangeToken(getExchangeRecaptchaEnterpriseTokenRequest(this._app, attestedClaimsToken), this._platformLoggerProvider);
1213
+ let result;
1214
+ try {
1215
+ result = await exchangeToken(getExchangeRecaptchaEnterpriseTokenRequest(this._app, attestedClaimsToken), this._platformLoggerProvider);
1216
+ }
1217
+ catch (e) {
1218
+ if (e.code === "fetch-status-error" /* FETCH_STATUS_ERROR */) {
1219
+ this._throttleData = setBackoff(Number((_a = e.customData) === null || _a === void 0 ? void 0 : _a.httpStatus), this._throttleData);
1220
+ throw ERROR_FACTORY.create("throttled" /* THROTTLED */, {
1221
+ time: getDurationString(this._throttleData.allowRequestsAfter - Date.now()),
1222
+ httpStatus: this._throttleData.httpStatus
1223
+ });
1224
+ }
1225
+ else {
1226
+ throw e;
1227
+ }
1228
+ }
1229
+ // If successful, clear throttle data.
1230
+ this._throttleData = null;
1231
+ return result;
1126
1232
  }
1127
1233
  /**
1128
1234
  * @internal
@@ -1190,6 +1296,57 @@ class CustomProvider {
1190
1296
  return false;
1191
1297
  }
1192
1298
  }
1299
+ }
1300
+ /**
1301
+ * Set throttle data to block requests until after a certain time
1302
+ * depending on the failed request's status code.
1303
+ * @param httpStatus - Status code of failed request.
1304
+ * @param throttleData - `ThrottleData` object containing previous throttle
1305
+ * data state.
1306
+ * @returns Data about current throttle state and expiration time.
1307
+ */
1308
+ function setBackoff(httpStatus, throttleData) {
1309
+ /**
1310
+ * Block retries for 1 day for the following error codes:
1311
+ *
1312
+ * 404: Likely malformed URL.
1313
+ *
1314
+ * 403:
1315
+ * - Attestation failed
1316
+ * - Wrong API key
1317
+ * - Project deleted
1318
+ */
1319
+ if (httpStatus === 404 || httpStatus === 403) {
1320
+ return {
1321
+ backoffCount: 1,
1322
+ allowRequestsAfter: Date.now() + ONE_DAY,
1323
+ httpStatus
1324
+ };
1325
+ }
1326
+ else {
1327
+ /**
1328
+ * For all other error codes, the time when it is ok to retry again
1329
+ * is based on exponential backoff.
1330
+ */
1331
+ const backoffCount = throttleData ? throttleData.backoffCount : 0;
1332
+ const backoffMillis = calculateBackoffMillis(backoffCount, 1000, 2);
1333
+ return {
1334
+ backoffCount: backoffCount + 1,
1335
+ allowRequestsAfter: Date.now() + backoffMillis,
1336
+ httpStatus
1337
+ };
1338
+ }
1339
+ }
1340
+ function throwIfThrottled(throttleData) {
1341
+ if (throttleData) {
1342
+ if (Date.now() - throttleData.allowRequestsAfter <= 0) {
1343
+ // If before, throw.
1344
+ throw ERROR_FACTORY.create("throttled" /* THROTTLED */, {
1345
+ time: getDurationString(throttleData.allowRequestsAfter - Date.now()),
1346
+ httpStatus: throttleData.httpStatus
1347
+ });
1348
+ }
1349
+ }
1193
1350
  }
1194
1351
 
1195
1352
  /**
@@ -1245,6 +1402,17 @@ function initializeAppCheck(app = getApp(), options) {
1245
1402
  }
1246
1403
  const appCheck = provider.initialize({ options });
1247
1404
  _activate(app, options.provider, options.isTokenAutoRefreshEnabled);
1405
+ // If isTokenAutoRefreshEnabled is false, do not send any requests to the
1406
+ // exchange endpoint without an explicit call from the user either directly
1407
+ // or through another Firebase library (storage, functions, etc.)
1408
+ if (getState(app).isTokenAutoRefreshEnabled) {
1409
+ // Adding a listener will start the refresher and fetch a token if needed.
1410
+ // This gets a token ready and prevents a delay when an internal library
1411
+ // requests the token.
1412
+ // Listener function does not need to do anything, its base functionality
1413
+ // of calling getToken() already fetches token and writes it to memory/storage.
1414
+ addTokenListener(appCheck, "INTERNAL" /* INTERNAL */, () => { });
1415
+ }
1248
1416
  return appCheck;
1249
1417
  }
1250
1418
  /**
@@ -1264,6 +1432,8 @@ function _activate(app, provider, isTokenAutoRefreshEnabled) {
1264
1432
  newState.cachedTokenPromise = readTokenFromStorage(app).then(cachedToken => {
1265
1433
  if (cachedToken && isValid(cachedToken)) {
1266
1434
  setState(app, Object.assign(Object.assign({}, getState(app)), { token: cachedToken }));
1435
+ // notify all listeners with the cached token
1436
+ notifyTokenListeners(app, { token: cachedToken.token });
1267
1437
  }
1268
1438
  return cachedToken;
1269
1439
  });