Skip to content

Commit

Permalink
fix(AppCheck): Fixed getToken promise not being cleared
Browse files Browse the repository at this point in the history
  • Loading branch information
ishowta committed Oct 28, 2022
1 parent d7acc96 commit 1496461
Show file tree
Hide file tree
Showing 3 changed files with 115 additions and 13 deletions.
97 changes: 97 additions & 0 deletions packages/app-check/src/internal-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,103 @@ describe('internal api', () => {
});
});

it('no dangling exchangeToken promise internal', async () => {
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
});

setState(app, {
...getState(app),
token: fakeRecaptchaAppCheckToken,
cachedTokenPromise: undefined
});

stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stub(client, 'exchangeToken').returns(
Promise.resolve({
token: 'new-recaptcha-app-check-token',
expireTimeMillis: Date.now() + 60000,
issuedAtTimeMillis: 0
})
);

const getTokenPromise = getToken(appCheck as AppCheckService, true);

expect(getState(app).exchangeTokenFetcher.promise).to.be.instanceOf(
Promise
);

const state = {
...getState(app)
};

await getTokenPromise;

setState(app, state);

expect(getState(app).exchangeTokenFetcher.promise).to.be.equal(undefined);
});

it('no dangling exchangeToken promise', async () => {
const clock = useFakeTimers();

const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
});

const soonExpiredToken = {
token: `recaptcha-app-check-token-old`,
expireTimeMillis: Date.now() + 1000,
issuedAtTimeMillis: 0
};

setState(app, {
...getState(app),
token: soonExpiredToken,
cachedTokenPromise: undefined
});

stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
let count = 0;
stub(client, 'exchangeToken').callsFake(
() =>
new Promise(res =>
setTimeout(
() =>
res({
token: `recaptcha-app-check-token-new-${count++}`,
expireTimeMillis: Date.now() + 60000,
issuedAtTimeMillis: 0
}),
3000
)
)
);

// start fetch token
void getToken(appCheck as AppCheckService, true);

clock.tick(2000);

// save expired `token-old` with copied state and wait fetch token
void getToken(appCheck as AppCheckService);

// wait fetch token with copied state
void getToken(appCheck as AppCheckService);

// stored copied state with `token-new-0`
await clock.runAllAsync();

// fetch token with copied state
const newToken = getToken(appCheck as AppCheckService, true);

await clock.runAllAsync();

expect(await newToken).to.deep.equal({
token: 'recaptcha-app-check-token-new-1'
});
});

it('ignores in-memory token if it is invalid and continues to exchange request', async () => {
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
Expand Down
24 changes: 13 additions & 11 deletions packages/app-check/src/internal-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,18 +117,18 @@ export async function getToken(
*/
if (isDebugMode()) {
// Avoid making another call to the exchange endpoint if one is in flight.
if (!state.exchangeTokenPromise) {
state.exchangeTokenPromise = exchangeToken(
if (!state.exchangeTokenFetcher.promise) {
state.exchangeTokenFetcher.promise = exchangeToken(
getExchangeDebugTokenRequest(app, await getDebugToken()),
appCheck.heartbeatServiceProvider
).finally(() => {
// Clear promise when settled - either resolved or rejected.
state.exchangeTokenPromise = undefined;
state.exchangeTokenFetcher.promise = undefined;
});
shouldCallListeners = true;
}
const tokenFromDebugExchange: AppCheckTokenInternal =
await state.exchangeTokenPromise;
const tokenFromDebugExchange: AppCheckTokenInternal = await state
.exchangeTokenFetcher.promise;
// Write debug token to indexedDB.
await writeTokenToStorage(app, tokenFromDebugExchange);
// Write debug token to state.
Expand All @@ -143,17 +143,19 @@ export async function getToken(
*/
try {
// Avoid making another call to the exchange endpoint if one is in flight.
if (!state.exchangeTokenPromise) {
if (!state.exchangeTokenFetcher.promise) {
// state.provider is populated in initializeAppCheck()
// ensureActivated() at the top of this function checks that
// initializeAppCheck() has been called.
state.exchangeTokenPromise = state.provider!.getToken().finally(() => {
// Clear promise when settled - either resolved or rejected.
state.exchangeTokenPromise = undefined;
});
state.exchangeTokenFetcher.promise = state
.provider!.getToken()
.finally(() => {
// Clear promise when settled - either resolved or rejected.
state.exchangeTokenFetcher.promise = undefined;
});
shouldCallListeners = true;
}
token = await state.exchangeTokenPromise;
token = await state.exchangeTokenFetcher.promise;
} catch (e) {
if ((e as FirebaseError).code === `appCheck/${AppCheckError.THROTTLED}`) {
// Warn if throttled, but do not treat it as an error.
Expand Down
7 changes: 5 additions & 2 deletions packages/app-check/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ export interface AppCheckState {
provider?: AppCheckProvider;
token?: AppCheckTokenInternal;
cachedTokenPromise?: Promise<AppCheckTokenInternal | undefined>;
exchangeTokenPromise?: Promise<AppCheckTokenInternal>;
exchangeTokenFetcher: {
promise?: Promise<AppCheckTokenInternal>;
};
tokenRefresher?: Refresher;
reCAPTCHAState?: ReCAPTCHAState;
isTokenAutoRefreshEnabled?: boolean;
Expand All @@ -50,7 +52,8 @@ export interface DebugState {
const APP_CHECK_STATES = new Map<FirebaseApp, AppCheckState>();
export const DEFAULT_STATE: AppCheckState = {
activated: false,
tokenObservers: []
tokenObservers: [],
exchangeTokenFetcher: {}
};

const DEBUG_STATE: DebugState = {
Expand Down

0 comments on commit 1496461

Please sign in to comment.