Skip to content

Commit 7ca267e

Browse files
committed
Rebase and add recaptcha enterprise
1 parent f9f5e2a commit 7ca267e

File tree

2 files changed

+122
-20
lines changed

2 files changed

+122
-20
lines changed

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

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import '../test/setup';
1919
import { getFakeGreCAPTCHA, getFullApp } from '../test/util';
20-
import { ReCaptchaV3Provider } from './providers';
20+
import { ReCaptchaEnterpriseProvider, ReCaptchaV3Provider } from './providers';
2121
import * as client from './client';
2222
import * as reCAPTCHA from './recaptcha';
2323
import * as util from './util';
@@ -113,3 +113,90 @@ describe('ReCaptchaV3Provider', () => {
113113
expect(token.token).to.equal('fake-exchange-token');
114114
});
115115
});
116+
117+
describe('ReCaptchaEnterpriseProvider', () => {
118+
let app: FirebaseApp;
119+
let clock = useFakeTimers();
120+
beforeEach(() => {
121+
clock = useFakeTimers();
122+
app = getFullApp();
123+
stub(util, 'getRecaptcha').returns(getFakeGreCAPTCHA());
124+
stub(reCAPTCHA, 'getToken').returns(
125+
Promise.resolve('fake-recaptcha-token')
126+
);
127+
});
128+
129+
afterEach(() => {
130+
clock.restore();
131+
clearState();
132+
return deleteApp(app);
133+
});
134+
it('getToken() gets a token from the exchange endpoint', async () => {
135+
const app = getFullApp();
136+
const provider = new ReCaptchaEnterpriseProvider('fake-site-key');
137+
stub(client, 'exchangeToken').resolves({
138+
token: 'fake-exchange-token',
139+
issuedAtTimeMillis: 0,
140+
expireTimeMillis: 10
141+
});
142+
provider.initialize(app);
143+
const token = await provider.getToken();
144+
expect(token.token).to.equal('fake-exchange-token');
145+
});
146+
it('getToken() throttles 1d on 403', async () => {
147+
const app = getFullApp();
148+
const provider = new ReCaptchaEnterpriseProvider('fake-site-key');
149+
stub(client, 'exchangeToken').rejects(
150+
new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', {
151+
httpStatus: 403
152+
})
153+
);
154+
provider.initialize(app);
155+
await expect(provider.getToken()).to.be.rejectedWith('1d');
156+
// Wait 10s and try again to see if wait time string decreases.
157+
clock.tick(10000);
158+
await expect(provider.getToken()).to.be.rejectedWith('23h');
159+
});
160+
it('getToken() throttles exponentially on 503', async () => {
161+
const app = getFullApp();
162+
const provider = new ReCaptchaEnterpriseProvider('fake-site-key');
163+
let exchangeTokenStub = stub(client, 'exchangeToken').rejects(
164+
new FirebaseError(AppCheckError.FETCH_STATUS_ERROR, 'some-message', {
165+
httpStatus: 503
166+
})
167+
);
168+
provider.initialize(app);
169+
await expect(provider.getToken()).to.be.rejectedWith('503');
170+
expect(exchangeTokenStub).to.be.called;
171+
exchangeTokenStub.resetHistory();
172+
// Try again immediately, should be rejected.
173+
await expect(provider.getToken()).to.be.rejectedWith('503');
174+
expect(exchangeTokenStub).not.to.be.called;
175+
exchangeTokenStub.resetHistory();
176+
// Times below are max range of each random exponential wait,
177+
// the possible range is 2^(backoff_count) plus or minus 50%
178+
// Wait for 1.5 seconds to pass, should call exchange endpoint again
179+
// (and be rejected again)
180+
clock.tick(1500);
181+
await expect(provider.getToken()).to.be.rejectedWith('503');
182+
expect(exchangeTokenStub).to.be.called;
183+
exchangeTokenStub.resetHistory();
184+
// Wait for 3 seconds to pass, should call exchange endpoint again
185+
// (and be rejected again)
186+
clock.tick(3000);
187+
await expect(provider.getToken()).to.be.rejectedWith('503');
188+
expect(exchangeTokenStub).to.be.called;
189+
// Wait for 6 seconds to pass, should call exchange endpoint again
190+
// (and succeed)
191+
clock.tick(6000);
192+
exchangeTokenStub.restore();
193+
exchangeTokenStub = stub(client, 'exchangeToken').resolves({
194+
token: 'fake-exchange-token',
195+
issuedAtTimeMillis: 0,
196+
expireTimeMillis: 10
197+
});
198+
const token = await provider.getToken();
199+
expect(token.token).to.equal('fake-exchange-token');
200+
});
201+
});
202+

packages/app-check/src/providers.ts

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,7 @@ export class ReCaptchaV3Provider implements AppCheckProvider {
6464
*/
6565
async getToken(): Promise<AppCheckTokenInternal> {
6666
throwIfThrottled(this._throttleData);
67-
68-
if (!this._app || !this._platformLoggerProvider) {
69-
// This should only occur if user has not called initializeAppCheck().
70-
// We don't have an appName to provide if so.
71-
// This should already be caught in the top level `getToken()` function.
72-
throw ERROR_FACTORY.create(AppCheckError.USE_BEFORE_ACTIVATION, {
73-
appName: ''
74-
});
75-
}
67+
7668
// Top-level `getToken()` has already checked that App Check is initialized
7769
// and therefore this._app and this._platformLoggerProvider are available.
7870
const attestedClaimsToken = await getReCAPTCHAToken(this._app!).catch(
@@ -84,16 +76,15 @@ export class ReCaptchaV3Provider implements AppCheckProvider {
8476
let result;
8577
try {
8678
result = await exchangeToken(
87-
getExchangeRecaptchaV3TokenRequest(this._app, attestedClaimsToken),
88-
this._platformLoggerProvider
79+
getExchangeRecaptchaV3TokenRequest(this._app!, attestedClaimsToken),
80+
this._platformLoggerProvider!
8981
);
9082
} catch (e) {
9183
if ((e as FirebaseError).code === AppCheckError.FETCH_STATUS_ERROR) {
9284
this._throttleData = setBackoff(
9385
Number((e as FirebaseError).customData?.httpStatus),
9486
this._throttleData
9587
);
96-
console.log('next interval', this._throttleData.allowRequestsAfter - Date.now());
9788
throw ERROR_FACTORY.create(AppCheckError.THROTTLED, {
9889
time: getDurationString(
9990
this._throttleData.allowRequestsAfter - Date.now()
@@ -141,6 +132,11 @@ export class ReCaptchaV3Provider implements AppCheckProvider {
141132
export class ReCaptchaEnterpriseProvider implements AppCheckProvider {
142133
private _app?: FirebaseApp;
143134
private _platformLoggerProvider?: Provider<'platform-logger'>;
135+
/**
136+
* Throttle requests on certain error codes to prevent too many retries
137+
* in a short time.
138+
*/
139+
private _throttleData: ThrottleData | null = null;
144140
/**
145141
* Create a ReCaptchaEnterpriseProvider instance.
146142
* @param siteKey - reCAPTCHA Enterprise score-based site key.
@@ -152,6 +148,7 @@ export class ReCaptchaEnterpriseProvider implements AppCheckProvider {
152148
* @internal
153149
*/
154150
async getToken(): Promise<AppCheckTokenInternal> {
151+
throwIfThrottled(this._throttleData);
155152
// Top-level `getToken()` has already checked that App Check is initialized
156153
// and therefore this._app and this._platformLoggerProvider are available.
157154
const attestedClaimsToken = await getReCAPTCHAToken(this._app!).catch(
@@ -160,13 +157,31 @@ export class ReCaptchaEnterpriseProvider implements AppCheckProvider {
160157
throw ERROR_FACTORY.create(AppCheckError.RECAPTCHA_ERROR);
161158
}
162159
);
163-
return exchangeToken(
164-
getExchangeRecaptchaEnterpriseTokenRequest(
165-
this._app!,
166-
attestedClaimsToken
167-
),
168-
this._platformLoggerProvider!
169-
);
160+
let result;
161+
try {
162+
result = await exchangeToken(
163+
getExchangeRecaptchaEnterpriseTokenRequest(this._app!, attestedClaimsToken),
164+
this._platformLoggerProvider!
165+
);
166+
} catch (e) {
167+
if ((e as FirebaseError).code === AppCheckError.FETCH_STATUS_ERROR) {
168+
this._throttleData = setBackoff(
169+
Number((e as FirebaseError).customData?.httpStatus),
170+
this._throttleData
171+
);
172+
throw ERROR_FACTORY.create(AppCheckError.THROTTLED, {
173+
time: getDurationString(
174+
this._throttleData.allowRequestsAfter - Date.now()
175+
),
176+
httpStatus: this._throttleData.httpStatus
177+
});
178+
} else {
179+
throw e;
180+
}
181+
}
182+
// If successful, clear throttle data.
183+
this._throttleData = null;
184+
return result;
170185
}
171186

172187
/**

0 commit comments

Comments
 (0)