Skip to content

Change appcheck activate() to use provider pattern #4902

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 11 commits into from
Jul 30, 2021
7 changes: 7 additions & 0 deletions .changeset/great-tigers-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@firebase/app-check': minor
'@firebase/app-check-types': minor
'firebase': minor
---

Add `RecaptchaV3Provider` and `CustomProvider` classes that can be supplied to `firebase.appCheck().activate()`.
24 changes: 24 additions & 0 deletions packages/app-check-types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import { PartialObserver, Unsubscribe } from '@firebase/util';
import { FirebaseApp } from '@firebase/app-types';
import { Provider } from '@firebase/component';

export interface FirebaseAppCheck {
/** The `FirebaseApp` associated with this instance. */
Expand Down Expand Up @@ -90,6 +91,29 @@ interface AppCheckProvider {
getToken(): Promise<AppCheckToken>;
}

export class ReCAPTCHAV3Provider {
/**
* @param siteKey - ReCAPTCHA v3 site key (public key).
*/
constructor(siteKey: string);
}
/*
* Custom token provider.
*/
export class CustomProvider {
/**
* @param options - Options for creating the custom provider.
*/
constructor(options: CustomProviderOptions);
}
interface CustomProviderOptions {
/**
* Function to get an App Check token through a custom provider
* service.
*/
getToken: () => Promise<AppCheckToken>;
}

/**
* The token returned from an `AppCheckProvider`.
*/
Expand Down
95 changes: 78 additions & 17 deletions packages/app-check/src/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import * as client from './client';
import * as storage from './storage';
import * as logger from './logger';
import * as util from './util';
import { ReCaptchaV3Provider } from './providers';

describe('api', () => {
beforeEach(() => {
Expand All @@ -53,47 +54,92 @@ describe('api', () => {

it('sets activated to true', () => {
expect(getState(app).activated).to.equal(false);
activate(app, FAKE_SITE_KEY);
activate(
app,
new ReCaptchaV3Provider(FAKE_SITE_KEY),
getFakePlatformLoggingProvider()
);
expect(getState(app).activated).to.equal(true);
});

it('isTokenAutoRefreshEnabled value defaults to global setting', () => {
app = getFakeApp({ automaticDataCollectionEnabled: false });
activate(app, FAKE_SITE_KEY);
activate(
app,
new ReCaptchaV3Provider(FAKE_SITE_KEY),
getFakePlatformLoggingProvider()
);
expect(getState(app).isTokenAutoRefreshEnabled).to.equal(false);
});

it('sets isTokenAutoRefreshEnabled correctly, overriding global setting', () => {
app = getFakeApp({ automaticDataCollectionEnabled: false });
activate(app, FAKE_SITE_KEY, true);
activate(
app,
new ReCaptchaV3Provider(FAKE_SITE_KEY),
getFakePlatformLoggingProvider(),
true
);
expect(getState(app).isTokenAutoRefreshEnabled).to.equal(true);
});

it('can only be called once', () => {
activate(app, FAKE_SITE_KEY);
expect(() => activate(app, FAKE_SITE_KEY)).to.throw(
/AppCheck can only be activated once/
activate(
app,
new ReCaptchaV3Provider(FAKE_SITE_KEY),
getFakePlatformLoggingProvider()
);
expect(() =>
activate(
app,
new ReCaptchaV3Provider(FAKE_SITE_KEY),
getFakePlatformLoggingProvider()
)
).to.throw(/AppCheck can only be activated once/);
});

it('initialize reCAPTCHA when a sitekey is provided', () => {
it('initialize reCAPTCHA when a sitekey string is provided', () => {
const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize').returns(
Promise.resolve({} as any)
);
activate(app, FAKE_SITE_KEY);
activate(app, FAKE_SITE_KEY, getFakePlatformLoggingProvider());
expect(initReCAPTCHAStub).to.have.been.calledWithExactly(
app,
FAKE_SITE_KEY
);
});

it('does NOT initialize reCAPTCHA when a custom token provider is provided', () => {
const fakeCustomTokenProvider = getFakeCustomTokenProvider();
const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize');
activate(app, fakeCustomTokenProvider);
expect(getState(app).customProvider).to.equal(fakeCustomTokenProvider);
expect(initReCAPTCHAStub).to.have.not.been.called;
it('initialize reCAPTCHA when a ReCaptchaV3Provider instance is provided', () => {
const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize').returns(
Promise.resolve({} as any)
);
activate(
app,
new ReCaptchaV3Provider(FAKE_SITE_KEY),
getFakePlatformLoggingProvider()
);
expect(initReCAPTCHAStub).to.have.been.calledWithExactly(
app,
FAKE_SITE_KEY
);
});

it(
'creates CustomProvider instance if user provides an object containing' +
' a getToken() method',
async () => {
const fakeCustomTokenProvider = getFakeCustomTokenProvider();
const initReCAPTCHAStub = stub(reCAPTCHA, 'initialize');
activate(
app,
fakeCustomTokenProvider,
getFakePlatformLoggingProvider()
);
const result = await getState(app).provider?.getToken();
expect(result?.token).to.equal('fake-custom-app-check-token');
expect(initReCAPTCHAStub).to.have.not.been.called;
}
);
});
describe('setTokenAutoRefreshEnabled()', () => {
it('sets isTokenAutoRefreshEnabled correctly', () => {
Expand Down Expand Up @@ -149,7 +195,12 @@ describe('api', () => {
});
it('Listeners work when using top-level parameters pattern', async () => {
const app = getFakeApp();
activate(app, FAKE_SITE_KEY, false);
activate(
app,
new ReCaptchaV3Provider(FAKE_SITE_KEY),
fakePlatformLoggingProvider,
false
);
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stub(client, 'exchangeToken').returns(
Promise.resolve(fakeRecaptchaAppCheckToken)
Expand Down Expand Up @@ -193,7 +244,12 @@ describe('api', () => {

it('Listeners work when using Observer pattern', async () => {
const app = getFakeApp();
activate(app, FAKE_SITE_KEY, false);
activate(
app,
new ReCaptchaV3Provider(FAKE_SITE_KEY),
fakePlatformLoggingProvider,
false
);
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stub(client, 'exchangeToken').returns(
Promise.resolve(fakeRecaptchaAppCheckToken)
Expand Down Expand Up @@ -238,7 +294,12 @@ describe('api', () => {
it('onError() catches token errors', async () => {
stub(logger.logger, 'error');
const app = getFakeApp();
activate(app, FAKE_SITE_KEY, false);
activate(
app,
new ReCaptchaV3Provider(FAKE_SITE_KEY),
fakePlatformLoggingProvider,
false
);
stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken));
stub(client, 'exchangeToken').rejects('exchange error');

Expand Down
34 changes: 23 additions & 11 deletions packages/app-check/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
} from '@firebase/app-check-types';
import { FirebaseApp } from '@firebase/app-types';
import { ERROR_FACTORY, AppCheckError } from './errors';
import { initialize as initializeRecaptcha } from './recaptcha';
import { getState, setState, AppCheckState, ListenerType } from './state';
import {
getToken as getTokenInternal,
Expand All @@ -30,18 +29,25 @@ import {
} from './internal-api';
import { Provider } from '@firebase/component';
import { ErrorFn, NextFn, PartialObserver, Unsubscribe } from '@firebase/util';
import { CustomProvider, ReCaptchaV3Provider } from './providers';

/**
*
* @param app
* @param siteKeyOrProvider - optional custom attestation provider
* or reCAPTCHA siteKey
* or reCAPTCHA provider
* @param isTokenAutoRefreshEnabled - if true, enables auto refresh
* of appCheck token.
*/
export function activate(
app: FirebaseApp,
siteKeyOrProvider: string | AppCheckProvider,
siteKeyOrProvider:
| ReCaptchaV3Provider
| CustomProvider
// This is the old interface for users to supply a custom provider.
| AppCheckProvider
| string,
platformLoggerProvider: Provider<'platform-logger'>,
isTokenAutoRefreshEnabled?: boolean
): void {
const state = getState(app);
Expand All @@ -52,10 +58,21 @@ export function activate(
}

const newState: AppCheckState = { ...state, activated: true };

if (typeof siteKeyOrProvider === 'string') {
newState.siteKey = siteKeyOrProvider;
newState.provider = new ReCaptchaV3Provider(siteKeyOrProvider);
} else if (
siteKeyOrProvider instanceof ReCaptchaV3Provider ||
siteKeyOrProvider instanceof CustomProvider
) {
newState.provider = siteKeyOrProvider;
} else {
newState.customProvider = siteKeyOrProvider;
// Process "old" custom provider to avoid breaking previous users.
// This was defined at beta release as simply an object with a
// getToken() method.
newState.provider = new CustomProvider({
getToken: siteKeyOrProvider.getToken
});
}

// Use value of global `automaticDataCollectionEnabled` (which
Expand All @@ -68,12 +85,7 @@ export function activate(

setState(app, newState);

// initialize reCAPTCHA if siteKey is provided
if (newState.siteKey) {
initializeRecaptcha(app, newState.siteKey).catch(() => {
/* we don't care about the initialization result in activate() */
});
}
newState.provider.initialize(app, platformLoggerProvider);
}

export function setTokenAutoRefreshEnabled(
Expand Down
10 changes: 8 additions & 2 deletions packages/app-check/src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,15 @@ export function factory(
return {
app,
activate: (
siteKeyOrProvider: string | AppCheckProvider,
siteKeyOrProvider: AppCheckProvider | string,
isTokenAutoRefreshEnabled?: boolean
) => activate(app, siteKeyOrProvider, isTokenAutoRefreshEnabled),
) =>
activate(
app,
siteKeyOrProvider,
platformLoggerProvider,
isTokenAutoRefreshEnabled
),
setTokenAutoRefreshEnabled: (isTokenAutoRefreshEnabled: boolean) =>
setTokenAutoRefreshEnabled(app, isTokenAutoRefreshEnabled),
getToken: forceRefresh =>
Expand Down
7 changes: 7 additions & 0 deletions packages/app-check/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
AppCheckComponentName
} from '@firebase/app-check-types';
import { factory, internalFactory } from './factory';
import { ReCaptchaV3Provider, CustomProvider } from './providers';
import { initializeDebugMode } from './debug';
import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types';
import { name, version } from '../package.json';
Expand All @@ -46,6 +47,10 @@ function registerAppCheck(firebase: _FirebaseNamespace): void {
},
ComponentType.PUBLIC
)
.setServiceProps({
ReCaptchaV3Provider,
CustomProvider
})
/**
* AppCheck can only be initialized by explicitly calling firebase.appCheck()
* We don't want firebase products that consume AppCheck to gate on AppCheck
Expand Down Expand Up @@ -94,6 +99,8 @@ initializeDebugMode();
declare module '@firebase/app-types' {
interface FirebaseNamespace {
appCheck(app?: FirebaseApp): FirebaseAppCheck;
ReCaptchaV3Provider: typeof ReCaptchaV3Provider;
CustomProvider: typeof CustomProvider;
}
interface FirebaseApp {
appCheck(): FirebaseAppCheck;
Expand Down
Loading