Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/selfish-worms-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@firebase/app-check': patch
---

Fix timer issues in App Check that caused the token to fail to refresh after the token expired, or caused rapid repeated requests attempting to do so.
2 changes: 1 addition & 1 deletion packages/app-check/src/indexeddb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function readTokenFromIndexedDB(

export function writeTokenToIndexedDB(
app: FirebaseApp,
token: AppCheckTokenInternal
token?: AppCheckTokenInternal
): Promise<void> {
return write(computeKey(app), token);
}
Expand Down
288 changes: 288 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,95 @@ describe('internal api', () => {
});
});

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)
});
setState(app, {
...getState(app),
token: {
token: 'something',
expireTimeMillis: Date.now() - 1000,
issuedAtTimeMillis: 0
}
});

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

expect(await getToken(appCheck as AppCheckService)).to.deep.equal({
token: 'new-recaptcha-app-check-token'
});
});

it('returns the valid token in storage without making a network request', async () => {
const clock = useFakeTimers();

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

const clientStub = stub(client, 'exchangeToken');
expect(await getToken(appCheck as AppCheckService)).to.deep.equal({
token: fakeCachedAppCheckToken.token
});
expect(clientStub).to.not.have.been.called;

clock.restore();
});

it('deletes cached token if it is invalid and continues to exchange request', async () => {
storageReadStub.resolves({
token: 'something',
expireTimeMillis: Date.now() - 1000,
issuedAtTimeMillis: 0
});
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
});

const freshToken = {
token: 'new-recaptcha-app-check-token',
expireTimeMillis: Date.now() + 60000,
issuedAtTimeMillis: 0
};

stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stub(client, 'exchangeToken').returns(Promise.resolve(freshToken));

expect(await getToken(appCheck as AppCheckService)).to.deep.equal({
token: 'new-recaptcha-app-check-token'
});

// When it wiped the invalid token.
expect(storageWriteStub).has.been.calledWith(app, undefined);

// When it wrote the new token fetched from the exchange endpoint.
expect(storageWriteStub).has.been.calledWith(app, freshToken);
});

it('returns the actual token and an internalError if a token is valid but the request fails', async () => {
stub(logger, 'error');
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
});
setState(app, { ...getState(app), token: fakeRecaptchaAppCheckToken });

stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stub(client, 'exchangeToken').returns(Promise.reject(new Error('blah')));

const tokenResult = await getToken(appCheck as AppCheckService, true);
expect(tokenResult.internalError?.message).to.equal('blah');
expect(tokenResult.token).to.equal('fake-recaptcha-app-check-token');
});

it('exchanges debug token if in debug mode and there is no cached token', async () => {
const exchangeTokenStub: SinonStub = stub(
client,
Expand Down Expand Up @@ -534,6 +623,205 @@ describe('internal api', () => {
fakeListener
);
});

it('does not make rapid requests within proactive refresh window', async () => {
const clock = useFakeTimers();
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY),
isTokenAutoRefreshEnabled: true
});
setState(app, {
...getState(app),
token: {
token: `fake-cached-app-check-token`,
// within refresh window
expireTimeMillis: 10000,
issuedAtTimeMillis: 0
}
});

const fakeListener: AppCheckTokenListener = stub();

const fakeExchange = stub(client, 'exchangeToken').returns(
Promise.resolve({
token: 'new-recaptcha-app-check-token',
expireTimeMillis: 10 * 60 * 1000,
issuedAtTimeMillis: 0
})
);

addTokenListener(
appCheck as AppCheckService,
ListenerType.INTERNAL,
fakeListener
);
// Tick 10s, make sure nothing is called repeatedly in that time.
await clock.tickAsync(10000);
expect(fakeListener).to.be.calledWith({
token: 'fake-cached-app-check-token'
});
expect(fakeListener).to.be.calledWith({
token: 'new-recaptcha-app-check-token'
});
expect(fakeExchange).to.be.calledOnce;
clock.restore();
});

it('proactive refresh window test - exchange request fails - wait 10s', async () => {
stub(logger, 'error');
const clock = useFakeTimers();
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY),
isTokenAutoRefreshEnabled: true
});
setState(app, {
...getState(app),
token: {
token: `fake-cached-app-check-token`,
// not expired but within refresh window
expireTimeMillis: 10000,
issuedAtTimeMillis: 0
}
});

const fakeListener: AppCheckTokenListener = stub();

const fakeExchange = stub(client, 'exchangeToken').returns(
Promise.reject(new Error('fetch failed or something'))
);

addTokenListener(
appCheck as AppCheckService,
ListenerType.EXTERNAL,
fakeListener
);
// Tick 10s, make sure nothing is called repeatedly in that time.
await clock.tickAsync(10000);
expect(fakeListener).to.be.calledWith({
token: 'fake-cached-app-check-token'
});
// once on init and once invoked directly in this test
expect(fakeListener).to.be.calledTwice;
expect(fakeExchange).to.be.calledOnce;
clock.restore();
});

it('proactive refresh window test - exchange request fails - wait 40s', async () => {
stub(logger, 'error');
const clock = useFakeTimers();
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY),
isTokenAutoRefreshEnabled: true
});
setState(app, {
...getState(app),
token: {
token: `fake-cached-app-check-token`,
// not expired but within refresh window
expireTimeMillis: 10000,
issuedAtTimeMillis: 0
}
});

const fakeListener: AppCheckTokenListener = stub();

const fakeExchange = stub(client, 'exchangeToken').returns(
Promise.reject(new Error('fetch failed or something'))
);

addTokenListener(
appCheck as AppCheckService,
ListenerType.EXTERNAL,
fakeListener
);
// Tick 40s, expect one initial exchange request and one retry.
// (First backoff is 30s).
await clock.tickAsync(40000);
expect(fakeListener).to.be.calledTwice;
expect(fakeExchange).to.be.calledTwice;
clock.restore();
});

it('expired token - exchange request fails - wait 10s', async () => {
stub(logger, 'error');
const clock = useFakeTimers();
clock.tick(1);
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY),
isTokenAutoRefreshEnabled: true
});
setState(app, {
...getState(app),
token: {
token: `fake-cached-app-check-token`,
// expired
expireTimeMillis: 0,
issuedAtTimeMillis: 0
}
});

const fakeListener = stub();
const errorHandler = stub();
const fakeNetworkError = new Error('fetch failed or something');

const fakeExchange = stub(client, 'exchangeToken').returns(
Promise.reject(fakeNetworkError)
);

addTokenListener(
appCheck as AppCheckService,
ListenerType.EXTERNAL,
fakeListener,
errorHandler
);
// Tick 10s, make sure nothing is called repeatedly in that time.
await clock.tickAsync(10000);
expect(fakeListener).not.to.be.called;
expect(fakeExchange).to.be.calledOnce;
expect(errorHandler).to.be.calledWith(fakeNetworkError);
clock.restore();
});

it('expired token - exchange request fails - wait 40s', async () => {
stub(logger, 'error');
const clock = useFakeTimers();
clock.tick(1);
const appCheck = initializeAppCheck(app, {
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY),
isTokenAutoRefreshEnabled: true
});
setState(app, {
...getState(app),
token: {
token: `fake-cached-app-check-token`,
// expired
expireTimeMillis: 0,
issuedAtTimeMillis: 0
}
});

const fakeListener = stub();
const errorHandler = stub();
const fakeNetworkError = new Error('fetch failed or something');

const fakeExchange = stub(client, 'exchangeToken').returns(
Promise.reject(fakeNetworkError)
);

addTokenListener(
appCheck as AppCheckService,
ListenerType.EXTERNAL,
fakeListener,
errorHandler
);
// Tick 40s, expect one initial exchange request and one retry.
// (First backoff is 30s).
await clock.tickAsync(40000);
expect(fakeListener).not.to.be.called;
expect(fakeExchange).to.be.calledTwice;
expect(errorHandler).to.be.calledTwice;
clock.restore();
});
});

describe('removeTokenListener', () => {
Expand Down
Loading