Skip to content

Commit 8c44d58

Browse files
authored
Catch all recaptcha errors (#7203)
1 parent 833ca90 commit 8c44d58

File tree

9 files changed

+126
-33
lines changed

9 files changed

+126
-33
lines changed

.changeset/shaggy-zebras-leave.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@firebase/app-check': patch
3+
---
4+
5+
Catch all ReCAPTCHA errors and, if caught, prevent App Check from making a request to the exchange endpoint.

packages/app-check/src/api.test.ts

+10
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ describe('api', () => {
6060
let storageReadStub: SinonStub;
6161
let storageWriteStub: SinonStub;
6262

63+
function setRecaptchaSuccess(isSuccess: boolean = true): void {
64+
getStateReference(app).reCAPTCHAState!.succeeded = isSuccess;
65+
}
66+
6367
beforeEach(() => {
6468
app = getFullApp();
6569
storageReadStub = stub(storage, 'readTokenFromStorage').resolves(undefined);
@@ -291,6 +295,8 @@ describe('api', () => {
291295
isTokenAutoRefreshEnabled: true
292296
});
293297

298+
setRecaptchaSuccess(true);
299+
294300
expect(getStateReference(app).tokenObservers.length).to.equal(1);
295301

296302
const fakeRecaptchaToken = 'fake-recaptcha-token';
@@ -335,6 +341,8 @@ describe('api', () => {
335341
isTokenAutoRefreshEnabled: true
336342
});
337343

344+
setRecaptchaSuccess(true);
345+
338346
expect(getStateReference(app).tokenObservers.length).to.equal(1);
339347

340348
const fakeRecaptchaToken = 'fake-recaptcha-token';
@@ -391,6 +399,8 @@ describe('api', () => {
391399
isTokenAutoRefreshEnabled: false
392400
});
393401

402+
setRecaptchaSuccess(true);
403+
394404
expect(getStateReference(app).tokenObservers.length).to.equal(0);
395405

396406
const fakeRecaptchaToken = 'fake-recaptcha-token';

packages/app-check/src/internal-api.test.ts

+55-19
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ describe('internal api', () => {
7070
let storageReadStub: SinonStub;
7171
let storageWriteStub: SinonStub;
7272

73+
function stubGetRecaptchaToken(
74+
token: string = fakeRecaptchaToken,
75+
isSuccess: boolean = true
76+
): SinonStub {
77+
getStateReference(app).reCAPTCHAState!.succeeded = isSuccess;
78+
79+
return stub(reCAPTCHA, 'getToken').returns(Promise.resolve(token));
80+
}
81+
7382
beforeEach(() => {
7483
app = getFullApp();
7584
storageReadStub = stub(storage, 'readTokenFromStorage').resolves(undefined);
@@ -104,9 +113,7 @@ describe('internal api', () => {
104113
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
105114
});
106115

107-
const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns(
108-
Promise.resolve(fakeRecaptchaToken)
109-
);
116+
const reCAPTCHASpy = stubGetRecaptchaToken();
110117
const exchangeTokenStub: SinonStub = stub(
111118
client,
112119
'exchangeToken'
@@ -127,9 +134,8 @@ describe('internal api', () => {
127134
provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY)
128135
});
129136

130-
const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns(
131-
Promise.resolve(fakeRecaptchaToken)
132-
);
137+
const reCAPTCHASpy = stubGetRecaptchaToken();
138+
133139
const exchangeTokenStub: SinonStub = stub(
134140
client,
135141
'exchangeToken'
@@ -151,9 +157,7 @@ describe('internal api', () => {
151157
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
152158
});
153159

154-
const reCAPTCHASpy = stub(reCAPTCHA, 'getToken').returns(
155-
Promise.resolve(fakeRecaptchaToken)
156-
);
160+
const reCAPTCHASpy = stubGetRecaptchaToken();
157161

158162
const error = new Error('oops, something went wrong');
159163
stub(client, 'exchangeToken').returns(Promise.reject(error));
@@ -171,6 +175,26 @@ describe('internal api', () => {
171175
errorStub.restore();
172176
});
173177

178+
it('resolves with a dummy token and an error if recaptcha failed', async () => {
179+
const errorStub = stub(console, 'error');
180+
const appCheck = initializeAppCheck(app, {
181+
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
182+
});
183+
184+
const reCAPTCHASpy = stubGetRecaptchaToken('', false);
185+
const exchangeTokenStub = stub(client, 'exchangeToken');
186+
187+
const token = await getToken(appCheck as AppCheckService);
188+
189+
expect(reCAPTCHASpy).to.be.called;
190+
expect(exchangeTokenStub).to.not.be.called;
191+
expect(token.token).to.equal(formatDummyToken(defaultTokenErrorData));
192+
expect(errorStub.args[0][1].message).to.include(
193+
AppCheckError.RECAPTCHA_ERROR
194+
);
195+
errorStub.restore();
196+
});
197+
174198
it('notifies listeners using cached token', async () => {
175199
storageReadStub.resolves(fakeCachedAppCheckToken);
176200
const appCheck = initializeAppCheck(app, {
@@ -213,7 +237,7 @@ describe('internal api', () => {
213237
isTokenAutoRefreshEnabled: true
214238
});
215239

216-
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
240+
stubGetRecaptchaToken();
217241
stub(client, 'exchangeToken').returns(
218242
Promise.resolve(fakeRecaptchaAppCheckToken)
219243
);
@@ -247,7 +271,7 @@ describe('internal api', () => {
247271
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY),
248272
isTokenAutoRefreshEnabled: true
249273
});
250-
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
274+
stubGetRecaptchaToken();
251275
stub(client, 'exchangeToken').rejects('exchange error');
252276
const listener1 = spy();
253277
const errorFn1 = spy();
@@ -271,7 +295,7 @@ describe('internal api', () => {
271295
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY),
272296
isTokenAutoRefreshEnabled: true
273297
});
274-
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
298+
stubGetRecaptchaToken();
275299
stub(client, 'exchangeToken').returns(
276300
Promise.resolve(fakeRecaptchaAppCheckToken)
277301
);
@@ -324,7 +348,7 @@ describe('internal api', () => {
324348
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
325349
});
326350

327-
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
351+
stubGetRecaptchaToken();
328352
stub(client, 'exchangeToken').returns(
329353
Promise.resolve(fakeRecaptchaAppCheckToken)
330354
);
@@ -365,7 +389,7 @@ describe('internal api', () => {
365389
token: fakeRecaptchaAppCheckToken
366390
});
367391

368-
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
392+
stubGetRecaptchaToken();
369393
stub(client, 'exchangeToken').returns(
370394
Promise.resolve({
371395
token: 'new-recaptcha-app-check-token',
@@ -390,7 +414,7 @@ describe('internal api', () => {
390414
cachedTokenPromise: undefined
391415
});
392416

393-
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
417+
stubGetRecaptchaToken();
394418
stub(client, 'exchangeToken').returns(
395419
Promise.resolve({
396420
token: 'new-recaptcha-app-check-token',
@@ -431,7 +455,7 @@ describe('internal api', () => {
431455
cachedTokenPromise: undefined
432456
});
433457

434-
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
458+
stubGetRecaptchaToken();
435459
let count = 0;
436460
stub(client, 'exchangeToken').callsFake(
437461
() =>
@@ -485,7 +509,7 @@ describe('internal api', () => {
485509
}
486510
});
487511

488-
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
512+
stubGetRecaptchaToken();
489513
stub(client, 'exchangeToken').returns(
490514
Promise.resolve({
491515
token: 'new-recaptcha-app-check-token',
@@ -532,7 +556,7 @@ describe('internal api', () => {
532556
issuedAtTimeMillis: 0
533557
};
534558

535-
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
559+
stubGetRecaptchaToken();
536560
stub(client, 'exchangeToken').returns(Promise.resolve(freshToken));
537561

538562
expect(await getToken(appCheck as AppCheckService)).to.deep.equal({
@@ -556,7 +580,7 @@ describe('internal api', () => {
556580
token: fakeRecaptchaAppCheckToken
557581
});
558582

559-
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
583+
stubGetRecaptchaToken();
560584
stub(client, 'exchangeToken').returns(Promise.reject(new Error('blah')));
561585

562586
const tokenResult = await getToken(appCheck as AppCheckService, true);
@@ -589,6 +613,7 @@ describe('internal api', () => {
589613
const appCheck = initializeAppCheck(app, {
590614
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
591615
});
616+
stubGetRecaptchaToken();
592617
const warnStub = stub(logger, 'warn');
593618
stub(client, 'exchangeToken').returns(
594619
Promise.reject(
@@ -615,6 +640,7 @@ describe('internal api', () => {
615640
const appCheck = initializeAppCheck(app, {
616641
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
617642
});
643+
stubGetRecaptchaToken();
618644
const warnStub = stub(logger, 'warn');
619645
stub(client, 'exchangeToken').returns(
620646
Promise.reject(
@@ -765,6 +791,8 @@ describe('internal api', () => {
765791
})
766792
);
767793

794+
stubGetRecaptchaToken();
795+
768796
addTokenListener(
769797
appCheck as AppCheckService,
770798
ListenerType.INTERNAL,
@@ -799,6 +827,8 @@ describe('internal api', () => {
799827
}
800828
});
801829

830+
stubGetRecaptchaToken();
831+
802832
const fakeListener: AppCheckTokenListener = stub();
803833

804834
const fakeExchange = stub(client, 'exchangeToken').returns(
@@ -838,6 +868,8 @@ describe('internal api', () => {
838868
}
839869
});
840870

871+
stubGetRecaptchaToken();
872+
841873
const fakeListener: AppCheckTokenListener = stub();
842874

843875
const fakeExchange = stub(client, 'exchangeToken').returns(
@@ -865,6 +897,8 @@ describe('internal api', () => {
865897
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY),
866898
isTokenAutoRefreshEnabled: true
867899
});
900+
901+
stubGetRecaptchaToken();
868902
setInitialState(app, {
869903
...getStateReference(app),
870904
token: {
@@ -905,6 +939,8 @@ describe('internal api', () => {
905939
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY),
906940
isTokenAutoRefreshEnabled: true
907941
});
942+
943+
stubGetRecaptchaToken();
908944
setInitialState(app, {
909945
...getStateReference(app),
910946
token: {

packages/app-check/src/providers.test.ts

+14-7
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,12 @@ import { stub, useFakeTimers } from 'sinon';
2525
import { expect } from 'chai';
2626
import { FirebaseError } from '@firebase/util';
2727
import { AppCheckError } from './errors';
28-
import { clearState } from './state';
28+
import {
29+
clearState,
30+
DEFAULT_STATE,
31+
getStateReference,
32+
setInitialState
33+
} from './state';
2934
import { deleteApp, FirebaseApp } from '@firebase/app';
3035

3136
describe('ReCaptchaV3Provider', () => {
@@ -34,6 +39,7 @@ describe('ReCaptchaV3Provider', () => {
3439
beforeEach(() => {
3540
clock = useFakeTimers();
3641
app = getFullApp();
42+
setInitialState(app, DEFAULT_STATE);
3743
stub(util, 'getRecaptcha').returns(getFakeGreCAPTCHA());
3844
stub(reCAPTCHA, 'getToken').returns(
3945
Promise.resolve('fake-recaptcha-token')
@@ -46,40 +52,40 @@ describe('ReCaptchaV3Provider', () => {
4652
return deleteApp(app);
4753
});
4854
it('getToken() gets a token from the exchange endpoint', async () => {
49-
const app = getFullApp();
5055
const provider = new ReCaptchaV3Provider('fake-site-key');
5156
stub(client, 'exchangeToken').resolves({
5257
token: 'fake-exchange-token',
5358
issuedAtTimeMillis: 0,
5459
expireTimeMillis: 10
5560
});
5661
provider.initialize(app);
62+
getStateReference(app).reCAPTCHAState!.succeeded = true;
5763
const token = await provider.getToken();
5864
expect(token.token).to.equal('fake-exchange-token');
5965
});
6066
it('getToken() throttles 1d on 403', async () => {
61-
const app = getFullApp();
6267
const provider = new ReCaptchaV3Provider('fake-site-key');
6368
stub(client, 'exchangeToken').rejects(
6469
new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', {
6570
httpStatus: 403
6671
})
6772
);
6873
provider.initialize(app);
74+
getStateReference(app).reCAPTCHAState!.succeeded = true;
6975
await expect(provider.getToken()).to.be.rejectedWith('1d');
7076
// Wait 10s and try again to see if wait time string decreases.
7177
clock.tick(10000);
7278
await expect(provider.getToken()).to.be.rejectedWith('23h');
7379
});
7480
it('getToken() throttles exponentially on 503', async () => {
75-
const app = getFullApp();
7681
const provider = new ReCaptchaV3Provider('fake-site-key');
7782
let exchangeTokenStub = stub(client, 'exchangeToken').rejects(
7883
new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', {
7984
httpStatus: 503
8085
})
8186
);
8287
provider.initialize(app);
88+
getStateReference(app).reCAPTCHAState!.succeeded = true;
8389
await expect(provider.getToken()).to.be.rejectedWith('503');
8490
expect(exchangeTokenStub).to.be.called;
8591
exchangeTokenStub.resetHistory();
@@ -120,6 +126,7 @@ describe('ReCaptchaEnterpriseProvider', () => {
120126
beforeEach(() => {
121127
clock = useFakeTimers();
122128
app = getFullApp();
129+
setInitialState(app, DEFAULT_STATE);
123130
stub(util, 'getRecaptcha').returns(getFakeGreCAPTCHA());
124131
stub(reCAPTCHA, 'getToken').returns(
125132
Promise.resolve('fake-recaptcha-token')
@@ -132,40 +139,40 @@ describe('ReCaptchaEnterpriseProvider', () => {
132139
return deleteApp(app);
133140
});
134141
it('getToken() gets a token from the exchange endpoint', async () => {
135-
const app = getFullApp();
136142
const provider = new ReCaptchaEnterpriseProvider('fake-site-key');
137143
stub(client, 'exchangeToken').resolves({
138144
token: 'fake-exchange-token',
139145
issuedAtTimeMillis: 0,
140146
expireTimeMillis: 10
141147
});
142148
provider.initialize(app);
149+
getStateReference(app).reCAPTCHAState!.succeeded = true;
143150
const token = await provider.getToken();
144151
expect(token.token).to.equal('fake-exchange-token');
145152
});
146153
it('getToken() throttles 1d on 403', async () => {
147-
const app = getFullApp();
148154
const provider = new ReCaptchaEnterpriseProvider('fake-site-key');
149155
stub(client, 'exchangeToken').rejects(
150156
new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', {
151157
httpStatus: 403
152158
})
153159
);
154160
provider.initialize(app);
161+
getStateReference(app).reCAPTCHAState!.succeeded = true;
155162
await expect(provider.getToken()).to.be.rejectedWith('1d');
156163
// Wait 10s and try again to see if wait time string decreases.
157164
clock.tick(10000);
158165
await expect(provider.getToken()).to.be.rejectedWith('23h');
159166
});
160167
it('getToken() throttles exponentially on 503', async () => {
161-
const app = getFullApp();
162168
const provider = new ReCaptchaEnterpriseProvider('fake-site-key');
163169
let exchangeTokenStub = stub(client, 'exchangeToken').rejects(
164170
new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', {
165171
httpStatus: 503
166172
})
167173
);
168174
provider.initialize(app);
175+
getStateReference(app).reCAPTCHAState!.succeeded = true;
169176
await expect(provider.getToken()).to.be.rejectedWith('503');
170177
expect(exchangeTokenStub).to.be.called;
171178
exchangeTokenStub.resetHistory();

0 commit comments

Comments
 (0)