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
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
61 changes: 61 additions & 0 deletions packages/app-check/src/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,67 @@ export class ReCaptchaV3Provider implements AppCheckProvider {
}
}

/**
* App Check provider that can obtain a reCAPTCHA Enterprise token and exchange it
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kevinthecheung This comment will go into documentation.

* for an App Check token.
*
* @public
*/
export class ReCaptchaEnterpriseProvider implements AppCheckProvider {
private _app?: FirebaseApp;
private _platformLoggerProvider?: Provider<'platform-logger'>;
/**
* Create a ReCaptchaV3Provider instance.
* @param siteKey - ReCAPTCHA V3 siteKey.
*/
constructor(private _siteKey: string) {}

/**
* Returns an App Check token.
* @internal
*/
async getToken(): Promise<AppCheckTokenInternal> {
if (!this._app || !this._platformLoggerProvider) {
// This should only occur if user has not called initializeAppCheck().
// We don't have an appName to provide if so.
// This should already be caught in the top level `getToken()` function.
throw ERROR_FACTORY.create(AppCheckError.USE_BEFORE_ACTIVATION, {
appName: ''
});
}
const attestedClaimsToken = await getReCAPTCHAToken(this._app).catch(_e => {
// reCaptcha.execute() throws null which is not very descriptive.
throw ERROR_FACTORY.create(AppCheckError.RECAPTCHA_ERROR);
});
return exchangeToken(
getExchangeRecaptchaTokenRequest(this._app, attestedClaimsToken, true),
this._platformLoggerProvider
);
}

/**
* @internal
*/
initialize(app: FirebaseApp): void {
this._app = app;
this._platformLoggerProvider = _getProvider(app, 'platform-logger');
initializeRecaptcha(app, this._siteKey, true).catch(() => {
/* we don't care about the initialization result */
});
}

/**
* @internal
*/
isEqual(otherProvider: unknown): boolean {
if (otherProvider instanceof ReCaptchaEnterpriseProvider) {
return this._siteKey === otherProvider._siteKey;
} else {
return false;
}
}
}

/**
* Custom provider class.
* @public
Expand Down
Loading