Skip to content

Commit 2e48e6c

Browse files
committed
Add recaptcha enterprise provider
1 parent 65dce18 commit 2e48e6c

File tree

9 files changed

+203
-22
lines changed

9 files changed

+203
-22
lines changed

packages/app-check/src/api.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,11 @@ declare module '@firebase/component' {
4343
}
4444
}
4545

46-
export { ReCaptchaV3Provider, CustomProvider } from './providers';
46+
export {
47+
ReCaptchaV3Provider,
48+
CustomProvider,
49+
ReCaptchaEnterpriseProvider
50+
} from './providers';
4751

4852
/**
4953
* Activate App Check for the given app. Can be called only once per app.

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,23 @@ describe('client', () => {
5151
});
5252
});
5353

54+
it('creates exchange recaptcha enterprise token request correctly', () => {
55+
const request = getExchangeRecaptchaTokenRequest(
56+
app,
57+
'fake-recaptcha-token',
58+
true
59+
);
60+
const { projectId, appId, apiKey } = app.options;
61+
62+
expect(request).to.deep.equal({
63+
url: `${BASE_ENDPOINT}/projects/${projectId}/apps/${appId}:exchangeRecaptchaToken?key=${apiKey}`,
64+
body: {
65+
// eslint-disable-next-line camelcase
66+
recaptcha_enterprise_token: 'fake-recaptcha-token'
67+
}
68+
});
69+
});
70+
5471
it('returns a AppCheck token', async () => {
5572
// To get a consistent expireTime/issuedAtTime.
5673
const clock = useFakeTimers();

packages/app-check/src/client.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,16 @@ export async function exchangeToken(
105105

106106
export function getExchangeRecaptchaTokenRequest(
107107
app: FirebaseApp,
108-
reCAPTCHAToken: string
108+
reCAPTCHAToken: string,
109+
isEnterprise: boolean = false
109110
): AppCheckRequest {
110111
const { projectId, appId, apiKey } = app.options;
112+
const fieldName = isEnterprise ? 'recaptcha_enterprise_token' : 'recaptcha_token';
111113

112114
return {
113115
url: `${BASE_ENDPOINT}/projects/${projectId}/apps/${appId}:${EXCHANGE_RECAPTCHA_TOKEN_METHOD}?key=${apiKey}`,
114116
body: {
115-
// eslint-disable-next-line
116-
recaptcha_token: reCAPTCHAToken
117+
[fieldName]: reCAPTCHAToken
117118
}
118119
};
119120
}

packages/app-check/src/providers.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,67 @@ export class ReCaptchaV3Provider implements AppCheckProvider {
8888
}
8989
}
9090

91+
/**
92+
* App Check provider that can obtain a reCAPTCHA Enterprise token and exchange it
93+
* for an App Check token.
94+
*
95+
* @public
96+
*/
97+
export class ReCaptchaEnterpriseProvider implements AppCheckProvider {
98+
private _app?: FirebaseApp;
99+
private _platformLoggerProvider?: Provider<'platform-logger'>;
100+
/**
101+
* Create a ReCaptchaV3Provider instance.
102+
* @param siteKey - ReCAPTCHA V3 siteKey.
103+
*/
104+
constructor(private _siteKey: string) {}
105+
106+
/**
107+
* Returns an App Check token.
108+
* @internal
109+
*/
110+
async getToken(): Promise<AppCheckTokenInternal> {
111+
if (!this._app || !this._platformLoggerProvider) {
112+
// This should only occur if user has not called initializeAppCheck().
113+
// We don't have an appName to provide if so.
114+
// This should already be caught in the top level `getToken()` function.
115+
throw ERROR_FACTORY.create(AppCheckError.USE_BEFORE_ACTIVATION, {
116+
appName: ''
117+
});
118+
}
119+
const attestedClaimsToken = await getReCAPTCHAToken(this._app).catch(_e => {
120+
// reCaptcha.execute() throws null which is not very descriptive.
121+
throw ERROR_FACTORY.create(AppCheckError.RECAPTCHA_ERROR);
122+
});
123+
return exchangeToken(
124+
getExchangeRecaptchaTokenRequest(this._app, attestedClaimsToken, true),
125+
this._platformLoggerProvider
126+
);
127+
}
128+
129+
/**
130+
* @internal
131+
*/
132+
initialize(app: FirebaseApp): void {
133+
this._app = app;
134+
this._platformLoggerProvider = _getProvider(app, 'platform-logger');
135+
initializeRecaptcha(app, this._siteKey, true).catch(() => {
136+
/* we don't care about the initialization result */
137+
});
138+
}
139+
140+
/**
141+
* @internal
142+
*/
143+
isEqual(otherProvider: unknown): boolean {
144+
if (otherProvider instanceof ReCaptchaEnterpriseProvider) {
145+
return this._siteKey === otherProvider._siteKey;
146+
} else {
147+
return false;
148+
}
149+
}
150+
}
151+
91152
/**
92153
* Custom provider class.
93154
* @public

packages/app-check/src/public-types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
*/
1717

1818
import { FirebaseApp } from '@firebase/app';
19-
import { CustomProvider, ReCaptchaV3Provider } from './providers';
19+
import {
20+
CustomProvider,
21+
ReCaptchaEnterpriseProvider,
22+
ReCaptchaV3Provider
23+
} from './providers';
2024
export { Unsubscribe, PartialObserver } from '@firebase/util';
2125

2226
/**
@@ -56,7 +60,7 @@ export interface AppCheckOptions {
5660
/**
5761
* reCAPTCHA provider or custom provider.
5862
*/
59-
provider: CustomProvider | ReCaptchaV3Provider;
63+
provider: CustomProvider | ReCaptchaV3Provider | ReCaptchaEnterpriseProvider;
6064
/**
6165
* If set to true, enables automatic background refresh of App Check token.
6266
*/

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

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,12 @@ import {
2626
findgreCAPTCHAScriptsOnPage,
2727
FAKE_SITE_KEY
2828
} from '../test/util';
29-
import { initialize, getToken } from './recaptcha';
29+
import { initialize, getToken, GreCAPTCHATopLevel } from './recaptcha';
3030
import * as utils from './util';
3131
import { getState } from './state';
3232
import { Deferred } from '@firebase/util';
3333
import { initializeAppCheck } from './api';
34-
import { ReCaptchaV3Provider } from './providers';
34+
import { ReCaptchaEnterpriseProvider, ReCaptchaV3Provider } from './providers';
3535

3636
describe('recaptcha', () => {
3737
let app: FirebaseApp;
@@ -45,9 +45,9 @@ describe('recaptcha', () => {
4545
return deleteApp(app);
4646
});
4747

48-
describe('initialize()', () => {
48+
describe('initialize() - V3', () => {
4949
it('sets reCAPTCHAState', async () => {
50-
self.grecaptcha = getFakeGreCAPTCHA();
50+
self.grecaptcha = getFakeGreCAPTCHA() as GreCAPTCHATopLevel;
5151
expect(getState(app).reCAPTCHAState).to.equal(undefined);
5252
await initialize(app, FAKE_SITE_KEY);
5353
expect(getState(app).reCAPTCHAState?.initialized).to.be.instanceof(
@@ -75,7 +75,7 @@ describe('recaptcha', () => {
7575
it('creates invisible widget', async () => {
7676
const grecaptchaFake = getFakeGreCAPTCHA();
7777
const renderStub = stub(grecaptchaFake, 'render').callThrough();
78-
self.grecaptcha = grecaptchaFake;
78+
self.grecaptcha = grecaptchaFake as GreCAPTCHATopLevel;
7979

8080
await initialize(app, FAKE_SITE_KEY);
8181

@@ -88,7 +88,50 @@ describe('recaptcha', () => {
8888
});
8989
});
9090

91-
describe('getToken()', () => {
91+
describe('initialize() - Enterprise', () => {
92+
it('sets reCAPTCHAState', async () => {
93+
self.grecaptcha = getFakeGreCAPTCHA() as GreCAPTCHATopLevel;
94+
expect(getState(app).reCAPTCHAState).to.equal(undefined);
95+
await initialize(app, FAKE_SITE_KEY, true);
96+
expect(getState(app).reCAPTCHAState?.initialized).to.be.instanceof(
97+
Deferred
98+
);
99+
});
100+
101+
it('loads reCAPTCHA script if it was not loaded already', async () => {
102+
const fakeRecaptcha = getFakeGreCAPTCHA();
103+
let count = 0;
104+
stub(utils, 'getRecaptcha').callsFake(() => {
105+
count++;
106+
if (count === 1) {
107+
return undefined;
108+
}
109+
110+
return fakeRecaptcha;
111+
});
112+
113+
expect(findgreCAPTCHAScriptsOnPage().length).to.equal(0);
114+
await initialize(app, FAKE_SITE_KEY, true);
115+
expect(findgreCAPTCHAScriptsOnPage().length).to.equal(1);
116+
});
117+
118+
it('creates invisible widget', async () => {
119+
const grecaptchaFake = getFakeGreCAPTCHA() as GreCAPTCHATopLevel;
120+
const renderStub = stub(grecaptchaFake.enterprise, 'render').callThrough();
121+
self.grecaptcha = grecaptchaFake;
122+
123+
await initialize(app, FAKE_SITE_KEY, true);
124+
125+
expect(renderStub).to.be.calledWith(`fire_app_check_${app.name}`, {
126+
sitekey: FAKE_SITE_KEY,
127+
size: 'invisible'
128+
});
129+
130+
expect(getState(app).reCAPTCHAState?.widgetId).to.equal('fake_widget_1');
131+
});
132+
});
133+
134+
describe('getToken() - V3', () => {
92135
it('throws if AppCheck has not been activated yet', () => {
93136
return expect(getToken(app)).to.eventually.rejectedWith(
94137
/appCheck\/use-before-activation/
@@ -100,7 +143,7 @@ describe('recaptcha', () => {
100143
const executeStub = stub(grecaptchaFake, 'execute').returns(
101144
Promise.resolve('fake-recaptcha-token')
102145
);
103-
self.grecaptcha = grecaptchaFake;
146+
self.grecaptcha = grecaptchaFake as GreCAPTCHATopLevel;
104147
initializeAppCheck(app, {
105148
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
106149
});
@@ -116,7 +159,7 @@ describe('recaptcha', () => {
116159
stub(grecaptchaFake, 'execute').returns(
117160
Promise.resolve('fake-recaptcha-token')
118161
);
119-
self.grecaptcha = grecaptchaFake;
162+
self.grecaptcha = grecaptchaFake as GreCAPTCHATopLevel;
120163
initializeAppCheck(app, {
121164
provider: new ReCaptchaV3Provider(FAKE_SITE_KEY)
122165
});
@@ -125,4 +168,36 @@ describe('recaptcha', () => {
125168
expect(token).to.equal('fake-recaptcha-token');
126169
});
127170
});
171+
172+
describe('getToken() - Enterprise', () => {
173+
it('calls recaptcha.execute with correct widgetId', async () => {
174+
const grecaptchaFake = getFakeGreCAPTCHA() as GreCAPTCHATopLevel;
175+
const executeStub = stub(grecaptchaFake.enterprise, 'execute').returns(
176+
Promise.resolve('fake-recaptcha-token')
177+
);
178+
self.grecaptcha = grecaptchaFake;
179+
initializeAppCheck(app, {
180+
provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY)
181+
});
182+
await getToken(app);
183+
184+
expect(executeStub).to.have.been.calledWith('fake_widget_1', {
185+
action: 'fire_app_check'
186+
});
187+
});
188+
189+
it('resolves with token returned by recaptcha.execute', async () => {
190+
const grecaptchaFake = getFakeGreCAPTCHA() as GreCAPTCHATopLevel;
191+
stub(grecaptchaFake.enterprise, 'execute').returns(
192+
Promise.resolve('fake-recaptcha-token')
193+
);
194+
self.grecaptcha = grecaptchaFake;
195+
initializeAppCheck(app, {
196+
provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY)
197+
});
198+
const token = await getToken(app);
199+
200+
expect(token).to.equal('fake-recaptcha-token');
201+
});
202+
});
128203
});

packages/app-check/src/recaptcha.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ export const RECAPTCHA_URL = 'https://www.google.com/recaptcha/api.js';
2424

2525
export function initialize(
2626
app: FirebaseApp,
27-
siteKey: string
27+
siteKey: string,
28+
isEnterprise: boolean = false
2829
): Promise<GreCAPTCHA> {
2930
const state = getState(app);
3031
const initialized = new Deferred<GreCAPTCHA>();
@@ -38,10 +39,10 @@ export function initialize(
3839

3940
document.body.appendChild(invisibleDiv);
4041

41-
const grecaptcha = getRecaptcha();
42+
const grecaptcha = getRecaptcha(isEnterprise);
4243
if (!grecaptcha) {
4344
loadReCAPTCHAScript(() => {
44-
const grecaptcha = getRecaptcha();
45+
const grecaptcha = getRecaptcha(isEnterprise);
4546

4647
if (!grecaptcha) {
4748
// it shouldn't happen.
@@ -120,10 +121,14 @@ function loadReCAPTCHAScript(onload: () => void): void {
120121

121122
declare global {
122123
interface Window {
123-
grecaptcha: GreCAPTCHA | undefined;
124+
grecaptcha: GreCAPTCHATopLevel | undefined;
124125
}
125126
}
126127

128+
export interface GreCAPTCHATopLevel extends GreCAPTCHA {
129+
enterprise: GreCAPTCHA;
130+
}
131+
127132
export interface GreCAPTCHA {
128133
ready: (callback: () => void) => void;
129134
execute: (siteKey: string, options: { action: string }) => Promise<string>;

packages/app-check/src/util.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import { getState } from './state';
2020
import { ERROR_FACTORY, AppCheckError } from './errors';
2121
import { FirebaseApp } from '@firebase/app';
2222

23-
export function getRecaptcha(): GreCAPTCHA | undefined {
23+
export function getRecaptcha(isEnterprise: boolean = false): GreCAPTCHA | undefined {
24+
if (isEnterprise) {
25+
return self.grecaptcha?.enterprise;
26+
}
2427
return self.grecaptcha;
2528
}
2629

packages/app-check/test/util.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
*/
1717

1818
import { FirebaseApp, initializeApp, _registerComponent } from '@firebase/app';
19-
import { GreCAPTCHA, RECAPTCHA_URL } from '../src/recaptcha';
19+
import {
20+
GreCAPTCHA,
21+
GreCAPTCHATopLevel,
22+
RECAPTCHA_URL
23+
} from '../src/recaptcha';
2024
import {
2125
Provider,
2226
ComponentContainer,
@@ -102,12 +106,19 @@ export function getFakePlatformLoggingProvider(
102106
return container.getProvider('platform-logger');
103107
}
104108

105-
export function getFakeGreCAPTCHA(): GreCAPTCHA {
106-
return {
109+
export function getFakeGreCAPTCHA(
110+
isTopLevel: boolean = true
111+
): GreCAPTCHATopLevel | GreCAPTCHA {
112+
const greCaptchaTopLevel: GreCAPTCHA = {
107113
ready: callback => callback(),
108114
render: (_container, _parameters) => 'fake_widget_1',
109115
execute: (_siteKey, _options) => Promise.resolve('fake_recaptcha_token')
110116
};
117+
if (isTopLevel) {
118+
(greCaptchaTopLevel as GreCAPTCHATopLevel).enterprise =
119+
getFakeGreCAPTCHA(false);
120+
}
121+
return greCaptchaTopLevel;
111122
}
112123

113124
/**

0 commit comments

Comments
 (0)