Skip to content

Implement ReCaptchaEnterprise for App Check #5595

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 16 commits into from
Nov 2, 2021
6 changes: 6 additions & 0 deletions .changeset/ten-impalas-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@firebase/app-check': minor
'@firebase/app-check-compat': minor
---

Add ReCAPTCHA Enterprise as an attestation option for App Check.
199 changes: 105 additions & 94 deletions common/api-review/app-check.api.md
Original file line number Diff line number Diff line change
@@ -1,94 +1,105 @@
## API Report File for "@firebase/app-check"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).

```ts

import { FirebaseApp } from '@firebase/app';
import { PartialObserver } from '@firebase/util';
import { Unsubscribe } from '@firebase/util';

// @public
export interface AppCheck {
app: FirebaseApp;
}

// @internal (undocumented)
export type _AppCheckComponentName = 'app-check';

// @internal (undocumented)
export type _AppCheckInternalComponentName = 'app-check-internal';

// @public
export interface AppCheckOptions {
isTokenAutoRefreshEnabled?: boolean;
provider: CustomProvider | ReCaptchaV3Provider;
}

// @public
export interface AppCheckToken {
readonly expireTimeMillis: number;
// (undocumented)
readonly token: string;
}

// @public
export type AppCheckTokenListener = (token: AppCheckTokenResult) => void;

// @public
export interface AppCheckTokenResult {
readonly token: string;
}

// Warning: (ae-forgotten-export) The symbol "AppCheckProvider" needs to be exported by the entry point index.d.ts
//
// @public
export class CustomProvider implements AppCheckProvider {
constructor(_customProviderOptions: CustomProviderOptions);
// Warning: (ae-forgotten-export) The symbol "AppCheckTokenInternal" needs to be exported by the entry point index.d.ts
//
// @internal (undocumented)
getToken(): Promise<AppCheckTokenInternal>;
// @internal (undocumented)
initialize(app: FirebaseApp): void;
// @internal (undocumented)
isEqual(otherProvider: unknown): boolean;
}

// @public
export interface CustomProviderOptions {
getToken: () => Promise<AppCheckToken>;
}

// @public
export function getToken(appCheckInstance: AppCheck, forceRefresh?: boolean): Promise<AppCheckTokenResult>;

// @public
export function initializeAppCheck(app: FirebaseApp | undefined, options: AppCheckOptions): AppCheck;

// @public
export function onTokenChanged(appCheckInstance: AppCheck, observer: PartialObserver<AppCheckTokenResult>): Unsubscribe;

// @public
export function onTokenChanged(appCheckInstance: AppCheck, onNext: (tokenResult: AppCheckTokenResult) => void, onError?: (error: Error) => void, onCompletion?: () => void): Unsubscribe;

export { PartialObserver }

// @public
export class ReCaptchaV3Provider implements AppCheckProvider {
constructor(_siteKey: string);
// @internal
getToken(): Promise<AppCheckTokenInternal>;
// @internal (undocumented)
initialize(app: FirebaseApp): void;
// @internal (undocumented)
isEqual(otherProvider: unknown): boolean;
}

// @public
export function setTokenAutoRefreshEnabled(appCheckInstance: AppCheck, isTokenAutoRefreshEnabled: boolean): void;

export { Unsubscribe }


```
## API Report File for "@firebase/app-check"

> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).

```ts

import { FirebaseApp } from '@firebase/app';
import { PartialObserver } from '@firebase/util';
import { Unsubscribe } from '@firebase/util';

// @public
export interface AppCheck {
app: FirebaseApp;
}

// @internal (undocumented)
export type _AppCheckComponentName = 'app-check';

// @internal (undocumented)
export type _AppCheckInternalComponentName = 'app-check-internal';

// @public
export interface AppCheckOptions {
isTokenAutoRefreshEnabled?: boolean;
provider: CustomProvider | ReCaptchaV3Provider | ReCaptchaEnterpriseProvider;
}

// @public
export interface AppCheckToken {
readonly expireTimeMillis: number;
// (undocumented)
readonly token: string;
}

// @public
export type AppCheckTokenListener = (token: AppCheckTokenResult) => void;

// @public
export interface AppCheckTokenResult {
readonly token: string;
}

// Warning: (ae-forgotten-export) The symbol "AppCheckProvider" needs to be exported by the entry point index.d.ts
//
// @public
export class CustomProvider implements AppCheckProvider {
constructor(_customProviderOptions: CustomProviderOptions);
// Warning: (ae-forgotten-export) The symbol "AppCheckTokenInternal" needs to be exported by the entry point index.d.ts
//
// @internal (undocumented)
getToken(): Promise<AppCheckTokenInternal>;
// @internal (undocumented)
initialize(app: FirebaseApp): void;
// @internal (undocumented)
isEqual(otherProvider: unknown): boolean;
}

// @public
export interface CustomProviderOptions {
getToken: () => Promise<AppCheckToken>;
}

// @public
export function getToken(appCheckInstance: AppCheck, forceRefresh?: boolean): Promise<AppCheckTokenResult>;

// @public
export function initializeAppCheck(app: FirebaseApp | undefined, options: AppCheckOptions): AppCheck;

// @public
export function onTokenChanged(appCheckInstance: AppCheck, observer: PartialObserver<AppCheckTokenResult>): Unsubscribe;

// @public
export function onTokenChanged(appCheckInstance: AppCheck, onNext: (tokenResult: AppCheckTokenResult) => void, onError?: (error: Error) => void, onCompletion?: () => void): Unsubscribe;

export { PartialObserver }

// @public
export class ReCaptchaEnterpriseProvider implements AppCheckProvider {
constructor(_siteKey: string);
// @internal
getToken(): Promise<AppCheckTokenInternal>;
// @internal (undocumented)
initialize(app: FirebaseApp): void;
// @internal (undocumented)
isEqual(otherProvider: unknown): boolean;
}

// @public
export class ReCaptchaV3Provider implements AppCheckProvider {
constructor(_siteKey: string);
// @internal
getToken(): Promise<AppCheckTokenInternal>;
// @internal (undocumented)
initialize(app: FirebaseApp): void;
// @internal (undocumented)
isEqual(otherProvider: unknown): boolean;
}

// @public
export function setTokenAutoRefreshEnabled(appCheckInstance: AppCheck, isTokenAutoRefreshEnabled: boolean): void;

export { Unsubscribe }


```
7 changes: 6 additions & 1 deletion packages/app-check-compat/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import {
} from '@firebase/component';
import { AppCheckService } from './service';
import { FirebaseAppCheck } from '@firebase/app-check-types';
import { ReCaptchaV3Provider, CustomProvider } from '@firebase/app-check';
import {
ReCaptchaV3Provider,
ReCaptchaEnterpriseProvider,
CustomProvider
} from '@firebase/app-check';

const factory: InstanceFactory<'appCheck-compat'> = (
container: ComponentContainer
Expand All @@ -46,6 +50,7 @@ export function registerAppCheck(): void {
factory,
ComponentType.PUBLIC
).setServiceProps({
ReCaptchaEnterpriseProvider,
ReCaptchaV3Provider,
CustomProvider
})
Expand Down
7 changes: 6 additions & 1 deletion packages/app-check-compat/src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
CustomProvider,
initializeAppCheck,
ReCaptchaV3Provider,
ReCaptchaEnterpriseProvider,
setTokenAutoRefreshEnabled as setTokenAutoRefreshEnabledExp,
getToken as getTokenExp,
onTokenChanged as onTokenChangedExp
Expand All @@ -43,10 +44,14 @@ export class AppCheckService
siteKeyOrProvider: string | AppCheckProvider,
isTokenAutoRefreshEnabled?: boolean
): void {
let provider: ReCaptchaV3Provider | CustomProvider;
let provider:
| ReCaptchaV3Provider
| CustomProvider
| ReCaptchaEnterpriseProvider;
if (typeof siteKeyOrProvider === 'string') {
provider = new ReCaptchaV3Provider(siteKeyOrProvider);
} else if (
siteKeyOrProvider instanceof ReCaptchaEnterpriseProvider ||
siteKeyOrProvider instanceof ReCaptchaV3Provider ||
siteKeyOrProvider instanceof CustomProvider
) {
Expand Down
36 changes: 35 additions & 1 deletion packages/app-check/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ import * as internalApi from './internal-api';
import * as indexeddb from './indexeddb';
import * as debug from './debug';
import { deleteApp, FirebaseApp } from '@firebase/app';
import { CustomProvider, ReCaptchaV3Provider } from './providers';
import { CustomProvider, ReCaptchaEnterpriseProvider, ReCaptchaV3Provider } from './providers';
import { AppCheckService } from './factory';
import { AppCheckToken } from './public-types';
import { getDebugToken } from './debug';
Expand Down Expand Up @@ -83,6 +83,16 @@ describe('api', () => {
})
).to.throw(/appCheck\/already-initialized/);
});
it('can only be called once (if given different ReCaptchaEnterpriseProviders)', () => {
initializeAppCheck(app, {
provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY)
});
expect(() =>
initializeAppCheck(app, {
provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY + 'X')
})
).to.throw(/appCheck\/already-initialized/);
});
it('can only be called once (if given different CustomProviders)', () => {
initializeAppCheck(app, {
provider: new CustomProvider({
Expand All @@ -107,6 +117,16 @@ describe('api', () => {
})
).to.equal(appCheckInstance);
});
it('can be called multiple times (if given equivalent ReCaptchaEnterpriseProviders)', () => {
const appCheckInstance = initializeAppCheck(app, {
provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY)
});
expect(
initializeAppCheck(app, {
provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY)
})
).to.equal(appCheckInstance);
});
it('can be called multiple times (if given equivalent CustomProviders)', () => {
const appCheckInstance = initializeAppCheck(app, {
provider: new CustomProvider({
Expand Down Expand Up @@ -178,6 +198,20 @@ describe('api', () => {
);
});

it('initialize reCAPTCHA when a ReCaptchaEnterpriseProvider is provided', () => {
const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize').returns(
Promise.resolve({} as any)
);
initializeAppCheck(app, {
provider: new ReCaptchaEnterpriseProvider(FAKE_SITE_KEY)
});
expect(initReCAPTCHAStub).to.have.been.calledWithExactly(
app,
FAKE_SITE_KEY,
true
);
});

it('sets activated to true', () => {
expect(getState(app).activated).to.equal(false);
initializeAppCheck(app, {
Expand Down
6 changes: 5 additions & 1 deletion packages/app-check/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ declare module '@firebase/component' {
}
}

export { ReCaptchaV3Provider, CustomProvider } from './providers';
export {
ReCaptchaV3Provider,
CustomProvider,
ReCaptchaEnterpriseProvider
} from './providers';

/**
* Activate App Check for the given app. Can be called only once per app.
Expand Down
17 changes: 17 additions & 0 deletions packages/app-check/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,23 @@ describe('client', () => {
});
});

it('creates exchange recaptcha enterprise token request correctly', () => {
const request = getExchangeRecaptchaTokenRequest(
app,
'fake-recaptcha-token',
true
);
const { projectId, appId, apiKey } = app.options;

expect(request).to.deep.equal({
url: `${BASE_ENDPOINT}/projects/${projectId}/apps/${appId}:exchangeRecaptchaEnterpriseToken?key=${apiKey}`,
body: {
// eslint-disable-next-line camelcase
recaptcha_enterprise_token: 'fake-recaptcha-token'
}
});
});

it('returns a AppCheck token', async () => {
// To get a consistent expireTime/issuedAtTime.
const clock = useFakeTimers();
Expand Down
15 changes: 11 additions & 4 deletions packages/app-check/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import {
BASE_ENDPOINT,
EXCHANGE_DEBUG_TOKEN_METHOD,
EXCHANGE_RECAPTCHA_ENTERPRISE_TOKEN_METHOD,
EXCHANGE_RECAPTCHA_TOKEN_METHOD
} from './constants';
import { FirebaseApp } from '@firebase/app';
Expand Down Expand Up @@ -105,15 +106,21 @@ export async function exchangeToken(

export function getExchangeRecaptchaTokenRequest(
app: FirebaseApp,
reCAPTCHAToken: string
reCAPTCHAToken: string,
isEnterprise: boolean = false
): AppCheckRequest {
const { projectId, appId, apiKey } = app.options;
const fieldName = isEnterprise
? 'recaptcha_enterprise_token'
: 'recaptcha_token';
const exchangeMethod = isEnterprise
? EXCHANGE_RECAPTCHA_ENTERPRISE_TOKEN_METHOD
: EXCHANGE_RECAPTCHA_TOKEN_METHOD;

return {
url: `${BASE_ENDPOINT}/projects/${projectId}/apps/${appId}:${EXCHANGE_RECAPTCHA_TOKEN_METHOD}?key=${apiKey}`,
url: `${BASE_ENDPOINT}/projects/${projectId}/apps/${appId}:${exchangeMethod}?key=${apiKey}`,
body: {
// eslint-disable-next-line
recaptcha_token: reCAPTCHAToken
[fieldName]: reCAPTCHAToken
}
};
}
Expand Down
2 changes: 2 additions & 0 deletions packages/app-check/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const BASE_ENDPOINT =
'https://content-firebaseappcheck.googleapis.com/v1beta';

export const EXCHANGE_RECAPTCHA_TOKEN_METHOD = 'exchangeRecaptchaToken';
export const EXCHANGE_RECAPTCHA_ENTERPRISE_TOKEN_METHOD =
'exchangeRecaptchaEnterpriseToken';
export const EXCHANGE_DEBUG_TOKEN_METHOD = 'exchangeDebugToken';

export const TOKEN_REFRESH_TIME = {
Expand Down
Loading