From dfbe67c3ee6cc3a1099e34e444412d45ea1b0246 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 24 Jun 2021 13:59:20 -0700 Subject: [PATCH 1/5] Port 3P token API to exp --- common/api-review/app-check-exp.api.md | 21 +- packages-exp/app-check-exp/src/api.test.ts | 190 +++++++++++++++++- packages-exp/app-check-exp/src/api.ts | 131 +++++++++++- packages-exp/app-check-exp/src/factory.ts | 8 +- .../app-check-exp/src/internal-api.test.ts | 86 ++++---- .../app-check-exp/src/internal-api.ts | 39 +++- .../app-check-exp/src/public-types.ts | 17 ++ packages-exp/app-check-exp/src/state.ts | 6 +- packages-exp/app-check-exp/src/types.ts | 9 +- packages-exp/app-check-exp/test/util.ts | 9 +- 10 files changed, 455 insertions(+), 61 deletions(-) diff --git a/common/api-review/app-check-exp.api.md b/common/api-review/app-check-exp.api.md index 34012ab0938..e2c070a6e30 100644 --- a/common/api-review/app-check-exp.api.md +++ b/common/api-review/app-check-exp.api.md @@ -5,6 +5,8 @@ ```ts import { FirebaseApp } from '@firebase/app-exp'; +import { PartialObserver } from '@firebase/util'; +import { Unsubscribe } from '@firebase/util'; // @public export interface AppCheck { @@ -30,6 +32,14 @@ export interface AppCheckToken { 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 @@ -48,9 +58,18 @@ export interface CustomProviderOptions { getToken: () => Promise; } +// @public +export function getToken(appCheckInstance: AppCheck, forceRefresh?: boolean): Promise; + // @public export function initializeAppCheck(app: FirebaseApp | undefined, options: AppCheckOptions): AppCheck; +// @public +export function onTokenChanged(appCheckInstance: AppCheck, observer: PartialObserver): Unsubscribe; + +// @public +export function onTokenChanged(appCheckInstance: AppCheck, onNext: (tokenResult: AppCheckTokenResult) => void, onError?: (error: Error) => void, onCompletion?: () => void): Unsubscribe; + // @public export class ReCaptchaV3Provider implements AppCheckProvider { constructor(_siteKey: string); @@ -61,7 +80,7 @@ export class ReCaptchaV3Provider implements AppCheckProvider { } // @public -export function setTokenAutoRefreshEnabled(app: FirebaseApp, isTokenAutoRefreshEnabled: boolean): void; +export function setTokenAutoRefreshEnabled(appCheckInstance: AppCheck, isTokenAutoRefreshEnabled: boolean): void; // (No @packageDocumentation comment for this package) diff --git a/packages-exp/app-check-exp/src/api.test.ts b/packages-exp/app-check-exp/src/api.test.ts index 98954b02944..73ca61ac852 100644 --- a/packages-exp/app-check-exp/src/api.test.ts +++ b/packages-exp/app-check-exp/src/api.test.ts @@ -16,11 +16,29 @@ */ import '../test/setup'; import { expect } from 'chai'; -import { stub } from 'sinon'; -import { setTokenAutoRefreshEnabled, initializeAppCheck } from './api'; -import { FAKE_SITE_KEY, getFullApp, getFakeApp } from '../test/util'; -import { getState } from './state'; +import { match, spy, stub } from 'sinon'; +import { + setTokenAutoRefreshEnabled, + initializeAppCheck, + getToken, + onTokenChanged +} from './api'; +import { + FAKE_SITE_KEY, + getFullApp, + getFakeApp, + getFakeGreCAPTCHA, + getFakeAppCheck, + getFakePlatformLoggingProvider, + removegreCAPTCHAScriptsOnPage +} from '../test/util'; +import { clearState, getState } from './state'; import * as reCAPTCHA from './recaptcha'; +import * as util from './util'; +import * as logger from './logger'; +import * as client from './client'; +import * as storage from './storage'; +import * as internalApi from './internal-api'; import { deleteApp, FirebaseApp } from '@firebase/app-exp'; import { ReCaptchaV3Provider } from './providers'; @@ -29,9 +47,12 @@ describe('api', () => { beforeEach(() => { app = getFullApp(); + stub(util, 'getRecaptcha').returns(getFakeGreCAPTCHA()); }); afterEach(() => { + clearState(); + removegreCAPTCHAScriptsOnPage(); return deleteApp(app); }); @@ -88,8 +109,167 @@ describe('api', () => { describe('setTokenAutoRefreshEnabled()', () => { it('sets isTokenAutoRefreshEnabled correctly', () => { const app = getFakeApp({ automaticDataCollectionEnabled: false }); - setTokenAutoRefreshEnabled(app, true); + const appCheck = getFakeAppCheck(app); + setTokenAutoRefreshEnabled(appCheck, true); expect(getState(app).isTokenAutoRefreshEnabled).to.equal(true); }); }); + describe('getToken()', () => { + it('getToken() calls the internal getToken() function', async () => { + const app = getFakeApp({ automaticDataCollectionEnabled: true }); + const appCheck = getFakeAppCheck(app); + const internalGetToken = stub(internalApi, 'getToken').resolves({ + token: 'a-token-string' + }); + await getToken(appCheck, true); + expect(internalGetToken).to.be.calledWith( + appCheck.app, + match.any, // platformLoggerProvider + true + ); + }); + it('getToken() throws errors returned with token', async () => { + const app = getFakeApp({ automaticDataCollectionEnabled: true }); + const appCheck = getFakeAppCheck(app); + // If getToken() errors, it returns a dummy token with an error field + // instead of throwing. + stub(internalApi, 'getToken').resolves({ + token: 'a-dummy-token', + error: Error('there was an error') + }); + await expect(getToken(appCheck, true)).to.be.rejectedWith( + 'there was an error' + ); + }); + }); + describe('onTokenChanged()', () => { + it('Listeners work when using top-level parameters pattern', async () => { + const appCheck = initializeAppCheck(app, { + provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), + isTokenAutoRefreshEnabled: true + }); + const fakeRecaptchaToken = 'fake-recaptcha-token'; + const fakeRecaptchaAppCheckToken = { + token: 'fake-recaptcha-app-check-token', + expireTimeMillis: 123, + issuedAtTimeMillis: 0 + }; + stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); + stub(client, 'exchangeToken').returns( + Promise.resolve(fakeRecaptchaAppCheckToken) + ); + stub(storage, 'writeTokenToStorage').returns(Promise.resolve(undefined)); + + const listener1 = (): void => { + throw new Error(); + }; + const listener2 = spy(); + + const errorFn1 = spy(); + const errorFn2 = spy(); + + const unsubscribe1 = onTokenChanged(appCheck, listener1, errorFn1); + const unsubscribe2 = onTokenChanged(appCheck, listener2, errorFn2); + + expect(getState(app).tokenObservers.length).to.equal(2); + + await internalApi.getToken( + appCheck.app, + getFakePlatformLoggingProvider() + ); + + expect(listener2).to.be.calledWith({ + token: fakeRecaptchaAppCheckToken.token + }); + // onError should not be called on listener errors. + expect(errorFn1).to.not.be.called; + expect(errorFn2).to.not.be.called; + unsubscribe1(); + unsubscribe2(); + expect(getState(app).tokenObservers.length).to.equal(0); + }); + + it('Listeners work when using Observer pattern', async () => { + const appCheck = initializeAppCheck(app, { + provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), + isTokenAutoRefreshEnabled: true + }); + const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); + const fakeRecaptchaToken = 'fake-recaptcha-token'; + const fakeRecaptchaAppCheckToken = { + token: 'fake-recaptcha-app-check-token', + expireTimeMillis: 123, + issuedAtTimeMillis: 0 + }; + stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); + stub(client, 'exchangeToken').returns( + Promise.resolve(fakeRecaptchaAppCheckToken) + ); + stub(storage, 'writeTokenToStorage').returns(Promise.resolve(undefined)); + + const listener1 = (): void => { + throw new Error(); + }; + const listener2 = spy(); + + const errorFn1 = spy(); + const errorFn2 = spy(); + + /** + * Reverse the order of adding the failed and successful handler, for extra + * testing. + */ + const unsubscribe2 = onTokenChanged(appCheck, { + next: listener2, + error: errorFn2 + }); + const unsubscribe1 = onTokenChanged(appCheck, { + next: listener1, + error: errorFn1 + }); + + expect(getState(app).tokenObservers.length).to.equal(2); + + await internalApi.getToken(appCheck.app, fakePlatformLoggingProvider); + + expect(listener2).to.be.calledWith({ + token: fakeRecaptchaAppCheckToken.token + }); + // onError should not be called on listener errors. + expect(errorFn1).to.not.be.called; + expect(errorFn2).to.not.be.called; + unsubscribe1(); + unsubscribe2(); + expect(getState(app).tokenObservers.length).to.equal(0); + }); + + it('onError() catches token errors', async () => { + stub(logger.logger, 'error'); + const appCheck = initializeAppCheck(app, { + provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), + isTokenAutoRefreshEnabled: false + }); + const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); + const fakeRecaptchaToken = 'fake-recaptcha-token'; + stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); + stub(client, 'exchangeToken').rejects('exchange error'); + stub(storage, 'writeTokenToStorage').returns(Promise.resolve(undefined)); + + const listener1 = spy(); + + const errorFn1 = spy(); + + const unsubscribe1 = onTokenChanged(appCheck, listener1, errorFn1); + + await internalApi.getToken(app, fakePlatformLoggingProvider); + + expect(getState(app).tokenObservers.length).to.equal(1); + + expect(errorFn1).to.be.calledOnce; + expect(errorFn1.args[0][0].name).to.include('exchange error'); + + unsubscribe1(); + expect(getState(app).tokenObservers.length).to.equal(0); + }); + }); }); diff --git a/packages-exp/app-check-exp/src/api.ts b/packages-exp/app-check-exp/src/api.ts index 79e87968ade..09d6d325445 100644 --- a/packages-exp/app-check-exp/src/api.ts +++ b/packages-exp/app-check-exp/src/api.ts @@ -15,13 +15,24 @@ * limitations under the License. */ -import { AppCheck, AppCheckOptions } from './public-types'; +import { AppCheck, AppCheckOptions, AppCheckTokenResult } from './public-types'; import { ERROR_FACTORY, AppCheckError } from './errors'; import { getState, setState, AppCheckState } from './state'; import { FirebaseApp, getApp, _getProvider } from '@firebase/app-exp'; -import { getModularInstance } from '@firebase/util'; +import { + getModularInstance, + ErrorFn, + NextFn, + PartialObserver, + Unsubscribe +} from '@firebase/util'; import { AppCheckService } from './factory'; import { AppCheckProvider } from './types'; +import { + getToken as getTokenInternal, + addTokenListener, + removeTokenListener +} from './internal-api'; declare module '@firebase/component' { interface NameServiceMapping { @@ -92,15 +103,17 @@ function _activate( /** * Set whether App Check will automatically refresh tokens as needed. * + * @param appCheckInstance - The App Check service instance. * @param isTokenAutoRefreshEnabled - If true, the SDK automatically * refreshes App Check tokens as needed. This overrides any value set * during `initializeAppCheck()`. * @public */ export function setTokenAutoRefreshEnabled( - app: FirebaseApp, + appCheckInstance: AppCheck, isTokenAutoRefreshEnabled: boolean ): void { + const app = appCheckInstance.app; const state = getState(app); // This will exist if any product libraries have called // `addTokenListener()` @@ -113,3 +126,115 @@ export function setTokenAutoRefreshEnabled( } setState(app, { ...state, isTokenAutoRefreshEnabled }); } +/** + * Get the current App Check token. Attaches to the most recent + * in-flight request if one is present. Returns null if no token + * is present and no token requests are in-flight. + * + * @param appCheckInstance - The App Check service instance. + * @param forceRefresh - If true, will always try to fetch a fresh token. + * If false, will use a cached token if found in storage. + * @public + */ +export async function getToken( + appCheckInstance: AppCheck, + forceRefresh?: boolean +): Promise { + const result = await getTokenInternal( + appCheckInstance.app, + (appCheckInstance as AppCheckService).platformLoggerProvider, + forceRefresh + ); + if (result.error) { + throw result.error; + } + return { token: result.token }; +} + +/** + * Registers a listener to changes in the token state. There can be more + * than one listener registered at the same time for one or more + * App Check instances. The listeners call back on the UI thread whenever + * the current token associated with this App Check instance changes. + * + * @param appCheckInstance - The App Check service instance. + * @param observer - An object with `next`, `error`, and `complete` + * properties. `next` is called with an + * {@link AppCheckTokenResult} + * whenever the token changes. `error` is optional and is called if an + * error is thrown by the listener (the `next` function). `complete` + * is unused, as the token stream is unending. + * + * @returns A function that unsubscribes this listener. + * @public + */ +export function onTokenChanged( + appCheckInstance: AppCheck, + observer: PartialObserver +): Unsubscribe; +/** + * Registers a listener to changes in the token state. There can be more + * than one listener registered at the same time for one or more + * App Check instances. The listeners call back on the UI thread whenever + * the current token associated with this App Check instance changes. + * + * @param appCheckInstance - The App Check service instance. + * @param onNext - When the token changes, this function is called with aa + * {@link AppCheckTokenResult}. + * @param onError - Optional. Called if there is an error thrown by the + * listener (the `onNext` function). + * @param onCompletion - Currently unused, as the token stream is unending. + * @returns A function that unsubscribes this listener. + * @public + */ +export function onTokenChanged( + appCheckInstance: AppCheck, + onNext: (tokenResult: AppCheckTokenResult) => void, + onError?: (error: Error) => void, + onCompletion?: () => void +): Unsubscribe; +/** + * Wraps addTokenListener/removeTokenListener methods in an Observer + * pattern for public use. + */ +export function onTokenChanged( + appCheckInstance: AppCheck, + onNextOrObserver: + | ((tokenResult: AppCheckTokenResult) => void) + | PartialObserver, + onError?: (error: Error) => void, + /** + * NOTE: Although an `onCompletion` callback can be provided, it will + * never be called because the token stream is never-ending. + * It is added only for API consistency with the observer pattern, which + * we follow in JS APIs. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onCompletion?: () => void +): Unsubscribe { + let nextFn: NextFn = () => {}; + let errorFn: ErrorFn = () => {}; + if ((onNextOrObserver as PartialObserver).next != null) { + nextFn = (onNextOrObserver as PartialObserver).next!.bind( + onNextOrObserver + ); + } else { + nextFn = onNextOrObserver as NextFn; + } + if ( + (onNextOrObserver as PartialObserver).error != null + ) { + errorFn = (onNextOrObserver as PartialObserver).error!.bind( + onNextOrObserver + ); + } else if (onError) { + errorFn = onError; + } + addTokenListener( + appCheckInstance.app, + (appCheckInstance as AppCheckService).platformLoggerProvider, + nextFn, + errorFn + ); + return () => removeTokenListener(appCheckInstance.app, nextFn); +} diff --git a/packages-exp/app-check-exp/src/factory.ts b/packages-exp/app-check-exp/src/factory.ts index bc67bfeb10c..d71aa8b5af4 100644 --- a/packages-exp/app-check-exp/src/factory.ts +++ b/packages-exp/app-check-exp/src/factory.ts @@ -16,7 +16,7 @@ */ import { AppCheck } from './public-types'; -import { FirebaseApp, _FirebaseService } from '@firebase/app-exp'; +import { FirebaseApp, _FirebaseService, _getProvider } from '@firebase/app-exp'; import { FirebaseAppCheckInternal } from './types'; import { getToken, @@ -29,7 +29,11 @@ import { Provider } from '@firebase/component'; * AppCheck Service class. */ export class AppCheckService implements AppCheck, _FirebaseService { - constructor(public app: FirebaseApp) {} + platformLoggerProvider: Provider<'platform-logger'>; + + constructor(public app: FirebaseApp) { + this.platformLoggerProvider = _getProvider(app, 'platform-logger'); + } _delete(): Promise { return Promise.resolve(); } diff --git a/packages-exp/app-check-exp/src/internal-api.test.ts b/packages-exp/app-check-exp/src/internal-api.test.ts index 0f18a9dee68..46763cfb8c2 100644 --- a/packages-exp/app-check-exp/src/internal-api.test.ts +++ b/packages-exp/app-check-exp/src/internal-api.test.ts @@ -24,7 +24,8 @@ import { getFullApp, getFakeCustomTokenProvider, getFakePlatformLoggingProvider, - removegreCAPTCHAScriptsOnPage + removegreCAPTCHAScriptsOnPage, + getFakeGreCAPTCHA } from '../test/util'; import { initializeAppCheck } from './api'; import { @@ -37,18 +38,36 @@ import { import * as reCAPTCHA from './recaptcha'; import * as client from './client'; import * as storage from './storage'; +import * as util from './util'; import { getState, clearState, setState, getDebugState } from './state'; -import { AppCheckTokenListener } from '../src/types'; +import { AppCheckTokenListener } from './public-types'; import { Deferred } from '@firebase/util'; import { ReCaptchaV3Provider } from './providers'; const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); +const fakeRecaptchaToken = 'fake-recaptcha-token'; +const fakeRecaptchaAppCheckToken = { + token: 'fake-recaptcha-app-check-token', + expireTimeMillis: Date.now() + 60000, + issuedAtTimeMillis: 0 +}; + +const fakeCachedAppCheckToken = { + token: 'fake-cached-app-check-token', + expireTimeMillis: Date.now() + 60000, + issuedAtTimeMillis: 0 +}; describe('internal api', () => { let app: FirebaseApp; + let storageReadStub: SinonStub; + let storageWriteStub: SinonStub; beforeEach(() => { app = getFullApp(); + storageReadStub = stub(storage, 'readTokenFromStorage').resolves(undefined); + storageWriteStub = stub(storage, 'writeTokenToStorage'); + stub(util, 'getRecaptcha').returns(getFakeGreCAPTCHA()); }); afterEach(() => { @@ -58,19 +77,6 @@ describe('internal api', () => { }); // TODO: test error conditions describe('getToken()', () => { - const fakeRecaptchaToken = 'fake-recaptcha-token'; - const fakeRecaptchaAppCheckToken = { - token: 'fake-recaptcha-app-check-token', - expireTimeMillis: 123, - issuedAtTimeMillis: 0 - }; - - const fakeCachedAppCheckToken = { - token: 'fake-cached-app-check-token', - expireTimeMillis: 123, - issuedAtTimeMillis: 0 - }; - it('uses customTokenProvider to get an AppCheck token', async () => { const customTokenProvider = getFakeCustomTokenProvider(); const customProviderSpy = spy(customTokenProvider, 'getToken'); @@ -144,9 +150,7 @@ describe('internal api', () => { }); const clock = useFakeTimers(); - stub(storage, 'readTokenFromStorage').returns( - Promise.resolve(fakeCachedAppCheckToken) - ); + storageReadStub.returns(Promise.resolve(fakeCachedAppCheckToken)); const listener1 = spy(); const listener2 = spy(); @@ -173,7 +177,6 @@ describe('internal api', () => { isTokenAutoRefreshEnabled: true }); - stub(storage, 'readTokenFromStorage').returns(Promise.resolve(undefined)); stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); stub(client, 'exchangeToken').returns( Promise.resolve(fakeRecaptchaAppCheckToken) @@ -194,6 +197,24 @@ describe('internal api', () => { }); }); + it('calls optional error handler if there is an error getting a token', async () => { + initializeAppCheck(app, { + provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), + isTokenAutoRefreshEnabled: true + }); + stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); + stub(client, 'exchangeToken').rejects('exchange error'); + const listener1 = spy(); + const errorFn1 = spy(); + + addTokenListener(app, fakePlatformLoggingProvider, listener1, errorFn1); + + await getToken(app, fakePlatformLoggingProvider); + + expect(errorFn1).to.be.calledOnce; + expect(errorFn1.args[0][0].name).to.include('exchange error'); + }); + it('ignores listeners that throw', async () => { initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), @@ -224,9 +245,7 @@ describe('internal api', () => { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); - stub(storage, 'readTokenFromStorage').returns( - Promise.resolve(fakeCachedAppCheckToken) - ); + storageReadStub.returns(Promise.resolve(fakeCachedAppCheckToken)); const clientStub = stub(client, 'exchangeToken'); @@ -245,12 +264,11 @@ describe('internal api', () => { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); - stub(storage, 'readTokenFromStorage').returns(Promise.resolve(undefined)); stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); stub(client, 'exchangeToken').returns( Promise.resolve(fakeRecaptchaAppCheckToken) ); - const storageWriteStub = stub(storage, 'writeTokenToStorage'); + storageWriteStub.resetHistory(); const result = await getToken(app, fakePlatformLoggingProvider); expect(result).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token }); expect(storageWriteStub).has.been.calledWith( @@ -285,7 +303,7 @@ describe('internal api', () => { stub(client, 'exchangeToken').returns( Promise.resolve({ token: 'new-recaptcha-app-check-token', - expireTimeMillis: 345, + expireTimeMillis: Date.now() + 60000, issuedAtTimeMillis: 0 }) ); @@ -324,13 +342,13 @@ describe('internal api', () => { addTokenListener(app, fakePlatformLoggingProvider, listener); - expect(getState(app).tokenListeners[0]).to.equal(listener); + expect(getState(app).tokenObservers[0].next).to.equal(listener); }); it('starts proactively refreshing token after adding the first listener', () => { const listener = (): void => {}; setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true }); - expect(getState(app).tokenListeners.length).to.equal(0); + expect(getState(app).tokenObservers.length).to.equal(0); expect(getState(app).tokenRefresher).to.equal(undefined); addTokenListener(app, fakePlatformLoggingProvider, listener); @@ -352,7 +370,7 @@ describe('internal api', () => { ...getState(app), token: { token: `fake-memory-app-check-token`, - expireTimeMillis: 123, + expireTimeMillis: Date.now() + 60000, issuedAtTimeMillis: 0 } }); @@ -366,10 +384,10 @@ describe('internal api', () => { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: true }); - stub(storage, 'readTokenFromStorage').returns( + storageReadStub.returns( Promise.resolve({ token: `fake-cached-app-check-token`, - expireTimeMillis: 123, + expireTimeMillis: Date.now() + 60000, issuedAtTimeMillis: 0 }) ); @@ -426,10 +444,10 @@ describe('internal api', () => { it('should remove token listeners', () => { const listener = (): void => {}; addTokenListener(app, fakePlatformLoggingProvider, listener); - expect(getState(app).tokenListeners.length).to.equal(1); + expect(getState(app).tokenObservers.length).to.equal(1); removeTokenListener(app, listener); - expect(getState(app).tokenListeners.length).to.equal(0); + expect(getState(app).tokenObservers.length).to.equal(0); }); it('should stop proactively refreshing token after deleting the last listener', () => { @@ -437,11 +455,11 @@ describe('internal api', () => { setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true }); addTokenListener(app, fakePlatformLoggingProvider, listener); - expect(getState(app).tokenListeners.length).to.equal(1); + expect(getState(app).tokenObservers.length).to.equal(1); expect(getState(app).tokenRefresher?.isRunning()).to.be.true; removeTokenListener(app, listener); - expect(getState(app).tokenListeners.length).to.equal(0); + expect(getState(app).tokenObservers.length).to.equal(0); expect(getState(app).tokenRefresher?.isRunning()).to.be.false; }); }); diff --git a/packages-exp/app-check-exp/src/internal-api.ts b/packages-exp/app-check-exp/src/internal-api.ts index 46f965c9658..a419a328f2f 100644 --- a/packages-exp/app-check-exp/src/internal-api.ts +++ b/packages-exp/app-check-exp/src/internal-api.ts @@ -18,9 +18,10 @@ import { FirebaseApp } from '@firebase/app-exp'; import { AppCheckTokenResult, - AppCheckTokenListener, - AppCheckTokenInternal + AppCheckTokenInternal, + AppCheckTokenObserver } from './types'; +import { AppCheckTokenListener } from './public-types'; import { getDebugState, getState, setState } from './state'; import { TOKEN_REFRESH_TIME } from './constants'; import { Refresher } from './proactive-refresh'; @@ -136,12 +137,17 @@ export async function getToken( export function addTokenListener( app: FirebaseApp, platformLoggerProvider: Provider<'platform-logger'>, - listener: AppCheckTokenListener + listener: AppCheckTokenListener, + onError?: (error: Error) => void ): void { const state = getState(app); + const tokenObserver: AppCheckTokenObserver = { + next: listener, + error: onError + }; const newState = { ...state, - tokenListeners: [...state.tokenListeners, listener] + tokenObservers: [...state.tokenObservers, tokenObserver] }; /** @@ -198,9 +204,11 @@ export function removeTokenListener( ): void { const state = getState(app); - const newListeners = state.tokenListeners.filter(l => l !== listener); + const newObservers = state.tokenObservers.filter( + tokenObserver => tokenObserver.next !== listener + ); if ( - newListeners.length === 0 && + newObservers.length === 0 && state.tokenRefresher && state.tokenRefresher.isRunning() ) { @@ -209,7 +217,7 @@ export function removeTokenListener( setState(app, { ...state, - tokenListeners: newListeners + tokenObservers: newObservers }); } @@ -271,11 +279,22 @@ function notifyTokenListeners( app: FirebaseApp, token: AppCheckTokenResult ): void { - const listeners = getState(app).tokenListeners; + const observers = getState(app).tokenObservers; - for (const listener of listeners) { + for (const observer of observers) { try { - listener(token); + if (observer.error) { + // If this listener has an error handler, handle errors differently + // from successes. + if (token.error) { + observer.error(token.error); + } else { + observer.next(token); + } + } else { + // Otherwise return the token, whether or not it has an error field. + observer.next(token); + } } catch (e) { // If any handler fails, ignore and run next handler. } diff --git a/packages-exp/app-check-exp/src/public-types.ts b/packages-exp/app-check-exp/src/public-types.ts index a9afdd42c1c..de4ceaa53dd 100644 --- a/packages-exp/app-check-exp/src/public-types.ts +++ b/packages-exp/app-check-exp/src/public-types.ts @@ -73,3 +73,20 @@ export interface CustomProviderOptions { */ getToken: () => Promise; } + +/** + * Result returned by `getToken()`. + * @public + */ +export interface AppCheckTokenResult { + /** + * The token string in JWT format. + */ + readonly token: string; +} + +/** + * A listener that is called whenever the App Check token changes. + * @public + */ +export type AppCheckTokenListener = (token: AppCheckTokenResult) => void; diff --git a/packages-exp/app-check-exp/src/state.ts b/packages-exp/app-check-exp/src/state.ts index 407b7f735b2..f7e53c428f9 100644 --- a/packages-exp/app-check-exp/src/state.ts +++ b/packages-exp/app-check-exp/src/state.ts @@ -19,14 +19,14 @@ import { FirebaseApp } from '@firebase/app-exp'; import { AppCheckProvider, AppCheckTokenInternal, - AppCheckTokenListener + AppCheckTokenObserver } from './types'; import { Refresher } from './proactive-refresh'; import { Deferred } from '@firebase/util'; import { GreCAPTCHA } from './recaptcha'; export interface AppCheckState { activated: boolean; - tokenListeners: AppCheckTokenListener[]; + tokenObservers: AppCheckTokenObserver[]; provider?: AppCheckProvider; token?: AppCheckTokenInternal; tokenRefresher?: Refresher; @@ -47,7 +47,7 @@ export interface DebugState { const APP_CHECK_STATES = new Map(); export const DEFAULT_STATE: AppCheckState = { activated: false, - tokenListeners: [] + tokenObservers: [] }; const DEBUG_STATE: DebugState = { diff --git a/packages-exp/app-check-exp/src/types.ts b/packages-exp/app-check-exp/src/types.ts index 5093f77bc46..0ffa303a872 100644 --- a/packages-exp/app-check-exp/src/types.ts +++ b/packages-exp/app-check-exp/src/types.ts @@ -16,7 +16,8 @@ */ import { FirebaseApp } from '@firebase/app-exp'; -import { AppCheckToken } from './public-types'; +import { PartialObserver } from '@firebase/util'; +import { AppCheckToken, AppCheckTokenListener } from './public-types'; export interface FirebaseAppCheckInternal { // Get the current AttestationToken. Attaches to the most recent in-flight request if one @@ -33,7 +34,11 @@ export interface FirebaseAppCheckInternal { removeTokenListener(listener: AppCheckTokenListener): void; } -export type AppCheckTokenListener = (token: AppCheckTokenResult) => void; +export interface AppCheckTokenObserver + extends PartialObserver { + // required + next: AppCheckTokenListener; +} // If the error field is defined, the token field will be populated with a dummy token export interface AppCheckTokenResult { diff --git a/packages-exp/app-check-exp/test/util.ts b/packages-exp/app-check-exp/test/util.ts index 7b77dc6e578..5018ba8f4d6 100644 --- a/packages-exp/app-check-exp/test/util.ts +++ b/packages-exp/app-check-exp/test/util.ts @@ -29,7 +29,7 @@ import { } from '@firebase/component'; import { PlatformLoggerService } from '@firebase/app-exp/dist/packages-exp/app-exp/src/types'; import { AppCheckService } from '../src/factory'; -import { CustomProvider } from '../src'; +import { AppCheck, CustomProvider } from '../src'; export const FAKE_SITE_KEY = 'fake-site-key'; @@ -52,6 +52,13 @@ export function getFakeApp(overrides: Record = {}): FirebaseApp { }; } +export function getFakeAppCheck(app: FirebaseApp): AppCheck { + return { + app, + platformLoggerProvider: getFakePlatformLoggingProvider() + } as AppCheck; +} + export function getFullApp(): FirebaseApp { const app = initializeApp(fakeConfig); _registerComponent( From bc2f60c79baa3236e6916709fbf95017354133e8 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Tue, 29 Jun 2021 17:15:44 -0700 Subject: [PATCH 2/5] Address PR comments --- packages-exp/app-check-exp/src/api.test.ts | 31 ++-- packages-exp/app-check-exp/src/api.ts | 9 +- packages-exp/app-check-exp/src/factory.ts | 30 ++-- packages-exp/app-check-exp/src/index.ts | 9 +- .../app-check-exp/src/internal-api.test.ts | 153 ++++++++++++------ .../app-check-exp/src/internal-api.ts | 39 +++-- packages-exp/app-check-exp/src/types.ts | 6 + 7 files changed, 168 insertions(+), 109 deletions(-) diff --git a/packages-exp/app-check-exp/src/api.test.ts b/packages-exp/app-check-exp/src/api.test.ts index 73ca61ac852..56ce9e8508b 100644 --- a/packages-exp/app-check-exp/src/api.test.ts +++ b/packages-exp/app-check-exp/src/api.test.ts @@ -16,7 +16,7 @@ */ import '../test/setup'; import { expect } from 'chai'; -import { match, spy, stub } from 'sinon'; +import { spy, stub } from 'sinon'; import { setTokenAutoRefreshEnabled, initializeAppCheck, @@ -29,7 +29,6 @@ import { getFakeApp, getFakeGreCAPTCHA, getFakeAppCheck, - getFakePlatformLoggingProvider, removegreCAPTCHAScriptsOnPage } from '../test/util'; import { clearState, getState } from './state'; @@ -41,6 +40,7 @@ import * as storage from './storage'; import * as internalApi from './internal-api'; import { deleteApp, FirebaseApp } from '@firebase/app-exp'; import { ReCaptchaV3Provider } from './providers'; +import { AppCheckService } from './factory'; describe('api', () => { let app: FirebaseApp; @@ -122,11 +122,7 @@ describe('api', () => { token: 'a-token-string' }); await getToken(appCheck, true); - expect(internalGetToken).to.be.calledWith( - appCheck.app, - match.any, // platformLoggerProvider - true - ); + expect(internalGetToken).to.be.calledWith(appCheck, true); }); it('getToken() throws errors returned with token', async () => { const app = getFakeApp({ automaticDataCollectionEnabled: true }); @@ -160,9 +156,7 @@ describe('api', () => { ); stub(storage, 'writeTokenToStorage').returns(Promise.resolve(undefined)); - const listener1 = (): void => { - throw new Error(); - }; + const listener1 = stub().throws(new Error()); const listener2 = spy(); const errorFn1 = spy(); @@ -173,11 +167,9 @@ describe('api', () => { expect(getState(app).tokenObservers.length).to.equal(2); - await internalApi.getToken( - appCheck.app, - getFakePlatformLoggingProvider() - ); + await internalApi.getToken(appCheck as AppCheckService); + expect(listener1).to.be.called; expect(listener2).to.be.calledWith({ token: fakeRecaptchaAppCheckToken.token }); @@ -194,7 +186,6 @@ describe('api', () => { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: true }); - const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); const fakeRecaptchaToken = 'fake-recaptcha-token'; const fakeRecaptchaAppCheckToken = { token: 'fake-recaptcha-app-check-token', @@ -207,9 +198,7 @@ describe('api', () => { ); stub(storage, 'writeTokenToStorage').returns(Promise.resolve(undefined)); - const listener1 = (): void => { - throw new Error(); - }; + const listener1 = stub().throws(new Error()); const listener2 = spy(); const errorFn1 = spy(); @@ -230,8 +219,9 @@ describe('api', () => { expect(getState(app).tokenObservers.length).to.equal(2); - await internalApi.getToken(appCheck.app, fakePlatformLoggingProvider); + await internalApi.getToken(appCheck as AppCheckService); + expect(listener1).to.be.called; expect(listener2).to.be.calledWith({ token: fakeRecaptchaAppCheckToken.token }); @@ -249,7 +239,6 @@ describe('api', () => { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: false }); - const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); const fakeRecaptchaToken = 'fake-recaptcha-token'; stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); stub(client, 'exchangeToken').rejects('exchange error'); @@ -261,7 +250,7 @@ describe('api', () => { const unsubscribe1 = onTokenChanged(appCheck, listener1, errorFn1); - await internalApi.getToken(app, fakePlatformLoggingProvider); + await internalApi.getToken(appCheck as AppCheckService); expect(getState(app).tokenObservers.length).to.equal(1); diff --git a/packages-exp/app-check-exp/src/api.ts b/packages-exp/app-check-exp/src/api.ts index 09d6d325445..d673a1382bb 100644 --- a/packages-exp/app-check-exp/src/api.ts +++ b/packages-exp/app-check-exp/src/api.ts @@ -27,7 +27,7 @@ import { Unsubscribe } from '@firebase/util'; import { AppCheckService } from './factory'; -import { AppCheckProvider } from './types'; +import { AppCheckProvider, ListenerType } from './types'; import { getToken as getTokenInternal, addTokenListener, @@ -141,8 +141,7 @@ export async function getToken( forceRefresh?: boolean ): Promise { const result = await getTokenInternal( - appCheckInstance.app, - (appCheckInstance as AppCheckService).platformLoggerProvider, + appCheckInstance as AppCheckService, forceRefresh ); if (result.error) { @@ -231,8 +230,8 @@ export function onTokenChanged( errorFn = onError; } addTokenListener( - appCheckInstance.app, - (appCheckInstance as AppCheckService).platformLoggerProvider, + appCheckInstance as AppCheckService, + ListenerType['3P'], nextFn, errorFn ); diff --git a/packages-exp/app-check-exp/src/factory.ts b/packages-exp/app-check-exp/src/factory.ts index d71aa8b5af4..bca9c21f923 100644 --- a/packages-exp/app-check-exp/src/factory.ts +++ b/packages-exp/app-check-exp/src/factory.ts @@ -16,8 +16,8 @@ */ import { AppCheck } from './public-types'; -import { FirebaseApp, _FirebaseService, _getProvider } from '@firebase/app-exp'; -import { FirebaseAppCheckInternal } from './types'; +import { FirebaseApp, _FirebaseService } from '@firebase/app-exp'; +import { FirebaseAppCheckInternal, ListenerType } from './types'; import { getToken, addTokenListener, @@ -29,29 +29,29 @@ import { Provider } from '@firebase/component'; * AppCheck Service class. */ export class AppCheckService implements AppCheck, _FirebaseService { - platformLoggerProvider: Provider<'platform-logger'>; - - constructor(public app: FirebaseApp) { - this.platformLoggerProvider = _getProvider(app, 'platform-logger'); - } + constructor( + public app: FirebaseApp, + public platformLoggerProvider: Provider<'platform-logger'> + ) {} _delete(): Promise { return Promise.resolve(); } } -export function factory(app: FirebaseApp): AppCheckService { - return new AppCheckService(app); +export function factory( + app: FirebaseApp, + platformLoggerProvider: Provider<'platform-logger'> +): AppCheckService { + return new AppCheckService(app, platformLoggerProvider); } export function internalFactory( - app: FirebaseApp, - platformLoggerProvider: Provider<'platform-logger'> + appCheck: AppCheckService ): FirebaseAppCheckInternal { return { - getToken: forceRefresh => - getToken(app, platformLoggerProvider, forceRefresh), + getToken: forceRefresh => getToken(appCheck, forceRefresh), addTokenListener: listener => - addTokenListener(app, platformLoggerProvider, listener), - removeTokenListener: listener => removeTokenListener(app, listener) + addTokenListener(appCheck, ListenerType['2P'], listener), + removeTokenListener: listener => removeTokenListener(appCheck.app, listener) }; } diff --git a/packages-exp/app-check-exp/src/index.ts b/packages-exp/app-check-exp/src/index.ts index ec52e7a47f9..b0f65722c43 100644 --- a/packages-exp/app-check-exp/src/index.ts +++ b/packages-exp/app-check-exp/src/index.ts @@ -39,7 +39,8 @@ function registerAppCheck(): void { container => { // getImmediate for FirebaseApp will always succeed const app = container.getProvider('app-exp').getImmediate(); - return factory(app); + const platformLoggerProvider = container.getProvider('platform-logger'); + return factory(app, platformLoggerProvider); }, ComponentType.PUBLIC ) @@ -50,10 +51,8 @@ function registerAppCheck(): void { new Component( APP_CHECK_NAME_INTERNAL, container => { - // getImmediate for FirebaseApp will always succeed - const app = container.getProvider('app-exp').getImmediate(); - const platformLoggerProvider = container.getProvider('platform-logger'); - return internalFactory(app, platformLoggerProvider); + const appCheck = container.getProvider('app-check-exp').getImmediate(); + return internalFactory(appCheck); }, ComponentType.PUBLIC ) diff --git a/packages-exp/app-check-exp/src/internal-api.test.ts b/packages-exp/app-check-exp/src/internal-api.test.ts index 46763cfb8c2..f8698502c96 100644 --- a/packages-exp/app-check-exp/src/internal-api.test.ts +++ b/packages-exp/app-check-exp/src/internal-api.test.ts @@ -23,7 +23,6 @@ import { FAKE_SITE_KEY, getFullApp, getFakeCustomTokenProvider, - getFakePlatformLoggingProvider, removegreCAPTCHAScriptsOnPage, getFakeGreCAPTCHA } from '../test/util'; @@ -43,8 +42,9 @@ import { getState, clearState, setState, getDebugState } from './state'; import { AppCheckTokenListener } from './public-types'; import { Deferred } from '@firebase/util'; import { ReCaptchaV3Provider } from './providers'; +import { AppCheckService } from './factory'; +import { ListenerType } from './types'; -const fakePlatformLoggingProvider = getFakePlatformLoggingProvider(); const fakeRecaptchaToken = 'fake-recaptcha-token'; const fakeRecaptchaAppCheckToken = { token: 'fake-recaptcha-app-check-token', @@ -81,8 +81,10 @@ describe('internal api', () => { const customTokenProvider = getFakeCustomTokenProvider(); const customProviderSpy = spy(customTokenProvider, 'getToken'); - initializeAppCheck(app, { provider: customTokenProvider }); - const token = await getToken(app, fakePlatformLoggingProvider); + const appCheck = initializeAppCheck(app, { + provider: customTokenProvider + }); + const token = await getToken(appCheck as AppCheckService); expect(customProviderSpy).to.be.called; expect(token).to.deep.equal({ @@ -91,7 +93,7 @@ describe('internal api', () => { }); it('uses reCAPTCHA token to exchange for AppCheck token', async () => { - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); @@ -103,7 +105,7 @@ describe('internal api', () => { 'exchangeToken' ).returns(Promise.resolve(fakeRecaptchaAppCheckToken)); - const token = await getToken(app, fakePlatformLoggingProvider); + const token = await getToken(appCheck as AppCheckService); expect(reCAPTCHASpy).to.be.called; @@ -119,7 +121,7 @@ describe('internal api', () => { it('resolves with a dummy token and an error if failed to get a token', async () => { const errorStub = stub(console, 'error'); - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); @@ -130,7 +132,7 @@ describe('internal api', () => { const error = new Error('oops, something went wrong'); stub(client, 'exchangeToken').returns(Promise.reject(error)); - const token = await getToken(app, fakePlatformLoggingProvider); + const token = await getToken(appCheck as AppCheckService); expect(reCAPTCHASpy).to.be.called; expect(token).to.deep.equal({ @@ -144,7 +146,7 @@ describe('internal api', () => { }); it('notifies listeners using cached token', async () => { - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: true }); @@ -154,10 +156,18 @@ describe('internal api', () => { const listener1 = spy(); const listener2 = spy(); - addTokenListener(app, fakePlatformLoggingProvider, listener1); - addTokenListener(app, fakePlatformLoggingProvider, listener2); + addTokenListener( + appCheck as AppCheckService, + ListenerType['2P'], + listener1 + ); + addTokenListener( + appCheck as AppCheckService, + ListenerType['2P'], + listener2 + ); - await getToken(app, fakePlatformLoggingProvider); + await getToken(appCheck as AppCheckService); clock.tick(1); @@ -172,7 +182,7 @@ describe('internal api', () => { }); it('notifies listeners using new token', async () => { - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: true }); @@ -184,10 +194,18 @@ describe('internal api', () => { const listener1 = spy(); const listener2 = spy(); - addTokenListener(app, fakePlatformLoggingProvider, listener1); - addTokenListener(app, fakePlatformLoggingProvider, listener2); + addTokenListener( + appCheck as AppCheckService, + ListenerType['2P'], + listener1 + ); + addTokenListener( + appCheck as AppCheckService, + ListenerType['2P'], + listener2 + ); - await getToken(app, fakePlatformLoggingProvider); + await getToken(appCheck as AppCheckService); expect(listener1).to.be.calledWith({ token: fakeRecaptchaAppCheckToken.token @@ -198,7 +216,7 @@ describe('internal api', () => { }); it('calls optional error handler if there is an error getting a token', async () => { - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: true }); @@ -207,16 +225,21 @@ describe('internal api', () => { const listener1 = spy(); const errorFn1 = spy(); - addTokenListener(app, fakePlatformLoggingProvider, listener1, errorFn1); + addTokenListener( + appCheck as AppCheckService, + ListenerType['2P'], + listener1, + errorFn1 + ); - await getToken(app, fakePlatformLoggingProvider); + await getToken(appCheck as AppCheckService); expect(errorFn1).to.be.calledOnce; expect(errorFn1.args[0][0].name).to.include('exchange error'); }); it('ignores listeners that throw', async () => { - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: true }); @@ -229,10 +252,18 @@ describe('internal api', () => { }; const listener2 = spy(); - addTokenListener(app, fakePlatformLoggingProvider, listener1); - addTokenListener(app, fakePlatformLoggingProvider, listener2); + addTokenListener( + appCheck as AppCheckService, + ListenerType['2P'], + listener1 + ); + addTokenListener( + appCheck as AppCheckService, + ListenerType['2P'], + listener2 + ); - await getToken(app, fakePlatformLoggingProvider); + await getToken(appCheck as AppCheckService); expect(listener2).to.be.calledWith({ token: fakeRecaptchaAppCheckToken.token @@ -241,7 +272,7 @@ describe('internal api', () => { it('loads persisted token to memory and returns it', async () => { const clock = useFakeTimers(); - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); @@ -250,7 +281,7 @@ describe('internal api', () => { const clientStub = stub(client, 'exchangeToken'); expect(getState(app).token).to.equal(undefined); - expect(await getToken(app, fakePlatformLoggingProvider)).to.deep.equal({ + expect(await getToken(appCheck as AppCheckService)).to.deep.equal({ token: fakeCachedAppCheckToken.token }); expect(getState(app).token).to.equal(fakeCachedAppCheckToken); @@ -260,7 +291,7 @@ describe('internal api', () => { }); it('persists token to storage', async () => { - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); @@ -269,7 +300,7 @@ describe('internal api', () => { Promise.resolve(fakeRecaptchaAppCheckToken) ); storageWriteStub.resetHistory(); - const result = await getToken(app, fakePlatformLoggingProvider); + const result = await getToken(appCheck as AppCheckService); expect(result).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token }); expect(storageWriteStub).has.been.calledWith( app, @@ -279,13 +310,13 @@ describe('internal api', () => { it('returns the valid token in memory without making network request', async () => { const clock = useFakeTimers(); - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); setState(app, { ...getState(app), token: fakeRecaptchaAppCheckToken }); const clientStub = stub(client, 'exchangeToken'); - expect(await getToken(app, fakePlatformLoggingProvider)).to.deep.equal({ + expect(await getToken(appCheck as AppCheckService)).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token }); expect(clientStub).to.not.have.been.called; @@ -294,7 +325,7 @@ describe('internal api', () => { }); it('force to get new token when forceRefresh is true', async () => { - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); setState(app, { ...getState(app), token: fakeRecaptchaAppCheckToken }); @@ -308,9 +339,7 @@ describe('internal api', () => { }) ); - expect( - await getToken(app, fakePlatformLoggingProvider, true) - ).to.deep.equal({ + expect(await getToken(appCheck as AppCheckService, true)).to.deep.equal({ token: 'new-recaptcha-app-check-token' }); }); @@ -324,11 +353,11 @@ describe('internal api', () => { debugState.enabled = true; debugState.token = new Deferred(); debugState.token.resolve('my-debug-token'); - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); - const token = await getToken(app, fakePlatformLoggingProvider); + const token = await getToken(appCheck as AppCheckService); expect(exchangeTokenStub.args[0][0].body['debug_token']).to.equal( 'my-debug-token' ); @@ -340,7 +369,11 @@ describe('internal api', () => { it('adds token listeners', () => { const listener = (): void => {}; - addTokenListener(app, fakePlatformLoggingProvider, listener); + addTokenListener( + { app } as AppCheckService, + ListenerType['2P'], + listener + ); expect(getState(app).tokenObservers[0].next).to.equal(listener); }); @@ -351,7 +384,11 @@ describe('internal api', () => { expect(getState(app).tokenObservers.length).to.equal(0); expect(getState(app).tokenRefresher).to.equal(undefined); - addTokenListener(app, fakePlatformLoggingProvider, listener); + addTokenListener( + { app } as AppCheckService, + ListenerType['2P'], + listener + ); expect(getState(app).tokenRefresher?.isRunning()).to.be.true; }); @@ -375,12 +412,16 @@ describe('internal api', () => { } }); - addTokenListener(app, fakePlatformLoggingProvider, fakeListener); + addTokenListener( + { app } as AppCheckService, + ListenerType['2P'], + fakeListener + ); }); it('notifies the listener with the valid token in storage', done => { const clock = useFakeTimers(); - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: true }); @@ -400,7 +441,11 @@ describe('internal api', () => { done(); }; - addTokenListener(app, fakePlatformLoggingProvider, fakeListener); + addTokenListener( + appCheck as AppCheckService, + ListenerType['2P'], + fakeListener + ); clock.tick(1); }); @@ -418,10 +463,14 @@ describe('internal api', () => { debugState.token = new Deferred(); debugState.token.resolve('my-debug-token'); - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); - addTokenListener(app, fakePlatformLoggingProvider, fakeListener); + addTokenListener( + appCheck as AppCheckService, + ListenerType['2P'], + fakeListener + ); }); it('does NOT start token refresher in debug mode', () => { @@ -430,10 +479,14 @@ describe('internal api', () => { debugState.token = new Deferred(); debugState.token.resolve('my-debug-token'); - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY) }); - addTokenListener(app, fakePlatformLoggingProvider, () => {}); + addTokenListener( + appCheck as AppCheckService, + ListenerType['2P'], + () => {} + ); const state = getState(app); expect(state.tokenRefresher).is.undefined; @@ -443,7 +496,11 @@ describe('internal api', () => { describe('removeTokenListener', () => { it('should remove token listeners', () => { const listener = (): void => {}; - addTokenListener(app, fakePlatformLoggingProvider, listener); + addTokenListener( + { app } as AppCheckService, + ListenerType['2P'], + listener + ); expect(getState(app).tokenObservers.length).to.equal(1); removeTokenListener(app, listener); @@ -454,7 +511,11 @@ describe('internal api', () => { const listener = (): void => {}; setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true }); - addTokenListener(app, fakePlatformLoggingProvider, listener); + addTokenListener( + { app } as AppCheckService, + ListenerType['2P'], + listener + ); expect(getState(app).tokenObservers.length).to.equal(1); expect(getState(app).tokenRefresher?.isRunning()).to.be.true; diff --git a/packages-exp/app-check-exp/src/internal-api.ts b/packages-exp/app-check-exp/src/internal-api.ts index a419a328f2f..afe3230b061 100644 --- a/packages-exp/app-check-exp/src/internal-api.ts +++ b/packages-exp/app-check-exp/src/internal-api.ts @@ -19,7 +19,8 @@ import { FirebaseApp } from '@firebase/app-exp'; import { AppCheckTokenResult, AppCheckTokenInternal, - AppCheckTokenObserver + AppCheckTokenObserver, + ListenerType } from './types'; import { AppCheckTokenListener } from './public-types'; import { getDebugState, getState, setState } from './state'; @@ -31,7 +32,7 @@ import { writeTokenToStorage, readTokenFromStorage } from './storage'; import { getDebugToken, isDebugMode } from './debug'; import { base64 } from '@firebase/util'; import { logger } from './logger'; -import { Provider } from '@firebase/component'; +import { AppCheckService } from './factory'; // Initial hardcoded value agreed upon across platforms for initial launch. // Format left open for possible dynamic error values and other fields in the future. @@ -57,10 +58,10 @@ export function formatDummyToken( * In case there is an error, the token field in the result will be populated with a dummy value */ export async function getToken( - app: FirebaseApp, - platformLoggerProvider: Provider<'platform-logger'>, + appCheck: AppCheckService, forceRefresh = false ): Promise { + const app = appCheck.app; ensureActivated(app); /** * DEBUG MODE @@ -69,7 +70,7 @@ export async function getToken( if (isDebugMode()) { const tokenFromDebugExchange: AppCheckTokenInternal = await exchangeToken( getExchangeDebugTokenRequest(app, await getDebugToken()), - platformLoggerProvider + appCheck.platformLoggerProvider ); return { token: tokenFromDebugExchange.token }; } @@ -135,15 +136,17 @@ export async function getToken( } export function addTokenListener( - app: FirebaseApp, - platformLoggerProvider: Provider<'platform-logger'>, + appCheck: AppCheckService, + type: ListenerType, listener: AppCheckTokenListener, onError?: (error: Error) => void ): void { + const { app } = appCheck; const state = getState(app); const tokenObserver: AppCheckTokenObserver = { next: listener, - error: onError + error: onError, + type }; const newState = { ...state, @@ -171,7 +174,7 @@ export function addTokenListener( * invoke the listener with the valid token, then start the token refresher */ if (!newState.tokenRefresher) { - const tokenRefresher = createTokenRefresher(app, platformLoggerProvider); + const tokenRefresher = createTokenRefresher(appCheck); newState.tokenRefresher = tokenRefresher; } @@ -221,10 +224,8 @@ export function removeTokenListener( }); } -function createTokenRefresher( - app: FirebaseApp, - platformLoggerProvider: Provider<'platform-logger'> -): Refresher { +function createTokenRefresher(appCheck: AppCheckService): Refresher { + const { app } = appCheck; return new Refresher( // Keep in mind when this fails for any reason other than the ones // for which we should retry, it will effectively stop the proactive refresh. @@ -234,9 +235,9 @@ function createTokenRefresher( // If there is a token, we force refresh it because we know it's going to expire soon let result; if (!state.token) { - result = await getToken(app, platformLoggerProvider); + result = await getToken(appCheck); } else { - result = await getToken(app, platformLoggerProvider, true); + result = await getToken(appCheck, true); } // getToken() always resolves. In case the result has an error field defined, it means the operation failed, and we should retry. @@ -292,8 +293,12 @@ function notifyTokenListeners( observer.next(token); } } else { - // Otherwise return the token, whether or not it has an error field. - observer.next(token); + // If this is a 2P listener, return the token, whether or not it + // has an error field. If it is a 3P listener with no error handler, + // ignore the error. + if (observer.type === ListenerType['2P']) { + observer.next(token); + } } } catch (e) { // If any handler fails, ignore and run next handler. diff --git a/packages-exp/app-check-exp/src/types.ts b/packages-exp/app-check-exp/src/types.ts index 0ffa303a872..635213c0773 100644 --- a/packages-exp/app-check-exp/src/types.ts +++ b/packages-exp/app-check-exp/src/types.ts @@ -38,6 +38,12 @@ export interface AppCheckTokenObserver extends PartialObserver { // required next: AppCheckTokenListener; + type: ListenerType; +} + +export enum ListenerType { + '2P' = '2P', + '3P' = '3P' } // If the error field is defined, the token field will be populated with a dummy token From 4e044e1f39aece715d658e07d50e89eb6eb51314 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Wed, 30 Jun 2021 12:00:42 -0700 Subject: [PATCH 3/5] Address PR comments --- .../app-check-exp/src/internal-api.test.ts | 4 ++-- .../app-check-exp/src/internal-api.ts | 23 ++++++++----------- packages-exp/app-check-exp/src/types.ts | 2 +- packages-exp/app-check-exp/tsconfig.json | 3 ++- 4 files changed, 14 insertions(+), 18 deletions(-) diff --git a/packages-exp/app-check-exp/src/internal-api.test.ts b/packages-exp/app-check-exp/src/internal-api.test.ts index f8698502c96..8d9deddcc9d 100644 --- a/packages-exp/app-check-exp/src/internal-api.test.ts +++ b/packages-exp/app-check-exp/src/internal-api.test.ts @@ -215,7 +215,7 @@ describe('internal api', () => { }); }); - it('calls optional error handler if there is an error getting a token', async () => { + it('calls 3P error handler if there is an error getting a token', async () => { const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: true @@ -227,7 +227,7 @@ describe('internal api', () => { addTokenListener( appCheck as AppCheckService, - ListenerType['2P'], + ListenerType['3P'], listener1, errorFn1 ); diff --git a/packages-exp/app-check-exp/src/internal-api.ts b/packages-exp/app-check-exp/src/internal-api.ts index afe3230b061..ce85ab7cd09 100644 --- a/packages-exp/app-check-exp/src/internal-api.ts +++ b/packages-exp/app-check-exp/src/internal-api.ts @@ -284,24 +284,19 @@ function notifyTokenListeners( for (const observer of observers) { try { - if (observer.error) { - // If this listener has an error handler, handle errors differently - // from successes. - if (token.error) { - observer.error(token.error); - } else { - observer.next(token); - } + if (observer.type === ListenerType['3P'] && token.error != null) { + // If this listener was added by a 3P call, send any token error to + // the supplied error handler. A 3P observer always has an error + // handler. + observer.error!(token.error); } else { + // If the token has no error field, always return the token. // If this is a 2P listener, return the token, whether or not it - // has an error field. If it is a 3P listener with no error handler, - // ignore the error. - if (observer.type === ListenerType['2P']) { - observer.next(token); - } + // has an error field. + observer.next(token); } } catch (e) { - // If any handler fails, ignore and run next handler. + // Errors in the listener function itself are always ignored. } } } diff --git a/packages-exp/app-check-exp/src/types.ts b/packages-exp/app-check-exp/src/types.ts index 635213c0773..6ee0571a40f 100644 --- a/packages-exp/app-check-exp/src/types.ts +++ b/packages-exp/app-check-exp/src/types.ts @@ -41,7 +41,7 @@ export interface AppCheckTokenObserver type: ListenerType; } -export enum ListenerType { +export const enum ListenerType { '2P' = '2P', '3P' = '3P' } diff --git a/packages-exp/app-check-exp/tsconfig.json b/packages-exp/app-check-exp/tsconfig.json index 356e7a53b8c..55067a950a1 100644 --- a/packages-exp/app-check-exp/tsconfig.json +++ b/packages-exp/app-check-exp/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../config/tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "strict": true + "strict": true, + "preserveConstEnums": true }, "exclude": ["dist/**/*"] } From 04a056065805e4f1aae015326f8d5de29b92c1d8 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Wed, 30 Jun 2021 14:11:59 -0700 Subject: [PATCH 4/5] Make fixes to ListenerType --- packages-exp/app-check-exp/src/api.ts | 2 +- packages-exp/app-check-exp/src/factory.ts | 2 +- .../app-check-exp/src/internal-api.test.ts | 30 +++++++++---------- .../app-check-exp/src/internal-api.ts | 2 +- packages-exp/app-check-exp/src/types.ts | 6 ++-- packages-exp/app-check-exp/tsconfig.json | 3 +- 6 files changed, 23 insertions(+), 22 deletions(-) diff --git a/packages-exp/app-check-exp/src/api.ts b/packages-exp/app-check-exp/src/api.ts index d673a1382bb..3c99e381f69 100644 --- a/packages-exp/app-check-exp/src/api.ts +++ b/packages-exp/app-check-exp/src/api.ts @@ -231,7 +231,7 @@ export function onTokenChanged( } addTokenListener( appCheckInstance as AppCheckService, - ListenerType['3P'], + ListenerType.EXTERNAL, nextFn, errorFn ); diff --git a/packages-exp/app-check-exp/src/factory.ts b/packages-exp/app-check-exp/src/factory.ts index bca9c21f923..f4d0c2ff96f 100644 --- a/packages-exp/app-check-exp/src/factory.ts +++ b/packages-exp/app-check-exp/src/factory.ts @@ -51,7 +51,7 @@ export function internalFactory( return { getToken: forceRefresh => getToken(appCheck, forceRefresh), addTokenListener: listener => - addTokenListener(appCheck, ListenerType['2P'], listener), + addTokenListener(appCheck, ListenerType.INTERNAL, listener), removeTokenListener: listener => removeTokenListener(appCheck.app, listener) }; } diff --git a/packages-exp/app-check-exp/src/internal-api.test.ts b/packages-exp/app-check-exp/src/internal-api.test.ts index 8d9deddcc9d..b71e7aa35fb 100644 --- a/packages-exp/app-check-exp/src/internal-api.test.ts +++ b/packages-exp/app-check-exp/src/internal-api.test.ts @@ -158,12 +158,12 @@ describe('internal api', () => { const listener2 = spy(); addTokenListener( appCheck as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, listener1 ); addTokenListener( appCheck as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, listener2 ); @@ -196,12 +196,12 @@ describe('internal api', () => { const listener2 = spy(); addTokenListener( appCheck as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, listener1 ); addTokenListener( appCheck as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, listener2 ); @@ -227,7 +227,7 @@ describe('internal api', () => { addTokenListener( appCheck as AppCheckService, - ListenerType['3P'], + ListenerType.EXTERNAL, listener1, errorFn1 ); @@ -254,12 +254,12 @@ describe('internal api', () => { addTokenListener( appCheck as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, listener1 ); addTokenListener( appCheck as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, listener2 ); @@ -371,7 +371,7 @@ describe('internal api', () => { addTokenListener( { app } as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, listener ); @@ -386,7 +386,7 @@ describe('internal api', () => { addTokenListener( { app } as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, listener ); @@ -414,7 +414,7 @@ describe('internal api', () => { addTokenListener( { app } as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, fakeListener ); }); @@ -443,7 +443,7 @@ describe('internal api', () => { addTokenListener( appCheck as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, fakeListener ); @@ -468,7 +468,7 @@ describe('internal api', () => { }); addTokenListener( appCheck as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, fakeListener ); }); @@ -484,7 +484,7 @@ describe('internal api', () => { }); addTokenListener( appCheck as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, () => {} ); @@ -498,7 +498,7 @@ describe('internal api', () => { const listener = (): void => {}; addTokenListener( { app } as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, listener ); expect(getState(app).tokenObservers.length).to.equal(1); @@ -513,7 +513,7 @@ describe('internal api', () => { addTokenListener( { app } as AppCheckService, - ListenerType['2P'], + ListenerType.INTERNAL, listener ); expect(getState(app).tokenObservers.length).to.equal(1); diff --git a/packages-exp/app-check-exp/src/internal-api.ts b/packages-exp/app-check-exp/src/internal-api.ts index ce85ab7cd09..f21ed7cc99d 100644 --- a/packages-exp/app-check-exp/src/internal-api.ts +++ b/packages-exp/app-check-exp/src/internal-api.ts @@ -284,7 +284,7 @@ function notifyTokenListeners( for (const observer of observers) { try { - if (observer.type === ListenerType['3P'] && token.error != null) { + if (observer.type === ListenerType.EXTERNAL && token.error != null) { // If this listener was added by a 3P call, send any token error to // the supplied error handler. A 3P observer always has an error // handler. diff --git a/packages-exp/app-check-exp/src/types.ts b/packages-exp/app-check-exp/src/types.ts index 6ee0571a40f..52cf2269e4d 100644 --- a/packages-exp/app-check-exp/src/types.ts +++ b/packages-exp/app-check-exp/src/types.ts @@ -42,8 +42,10 @@ export interface AppCheckTokenObserver } export const enum ListenerType { - '2P' = '2P', - '3P' = '3P' + // Listener added by a 2P library. + 'INTERNAL' = 'INTERNAL', + // Listener added by users using the public API. + 'EXTERNAL' = 'EXTERNAL' } // If the error field is defined, the token field will be populated with a dummy token diff --git a/packages-exp/app-check-exp/tsconfig.json b/packages-exp/app-check-exp/tsconfig.json index 55067a950a1..356e7a53b8c 100644 --- a/packages-exp/app-check-exp/tsconfig.json +++ b/packages-exp/app-check-exp/tsconfig.json @@ -2,8 +2,7 @@ "extends": "../../config/tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "strict": true, - "preserveConstEnums": true + "strict": true }, "exclude": ["dist/**/*"] } From 43c5c6dfe6dd6f0c2b3c51cdd465886018c88bcf Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Wed, 30 Jun 2021 14:25:23 -0700 Subject: [PATCH 5/5] Add app-check-compat (#5073) --- .changeset/config.json | 3 +- packages-exp/app-check-compat/.eslintrc.js | 33 ++++ packages-exp/app-check-compat/README.md | 5 + packages-exp/app-check-compat/karma.conf.js | 32 ++++ packages-exp/app-check-compat/package.json | 60 ++++++++ .../app-check-compat/rollup.config.js | 58 +++++++ .../app-check-compat/rollup.config.release.js | 67 +++++++++ .../app-check-compat/rollup.shared.js | 54 +++++++ packages-exp/app-check-compat/src/index.ts | 69 +++++++++ .../app-check-compat/src/service.test.ts | 142 ++++++++++++++++++ packages-exp/app-check-compat/src/service.ts | 79 ++++++++++ packages-exp/app-check-compat/tsconfig.json | 8 + 12 files changed, 609 insertions(+), 1 deletion(-) create mode 100644 packages-exp/app-check-compat/.eslintrc.js create mode 100644 packages-exp/app-check-compat/README.md create mode 100644 packages-exp/app-check-compat/karma.conf.js create mode 100644 packages-exp/app-check-compat/package.json create mode 100644 packages-exp/app-check-compat/rollup.config.js create mode 100644 packages-exp/app-check-compat/rollup.config.release.js create mode 100644 packages-exp/app-check-compat/rollup.shared.js create mode 100644 packages-exp/app-check-compat/src/index.ts create mode 100644 packages-exp/app-check-compat/src/service.test.ts create mode 100644 packages-exp/app-check-compat/src/service.ts create mode 100644 packages-exp/app-check-compat/tsconfig.json diff --git a/.changeset/config.json b/.changeset/config.json index 27db2bd13ab..8a7e96564c6 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -15,7 +15,9 @@ "firebase-messaging-integration-test", "firebase-compat-interop-test", "firebase-compat-typings-test", + "@firebase/app-compat", "@firebase/app-exp", + "@firebase/app-check-compat", "@firebase/app-check-exp", "@firebase/analytics-compat", "@firebase/analytics-exp", @@ -32,7 +34,6 @@ "@firebase/remote-config-exp", "@firebase/remote-config-compat", "firebase-exp", - "@firebase/app-compat", "@firebase/changelog-generator", "firebase-size-analysis" ], diff --git a/packages-exp/app-check-compat/.eslintrc.js b/packages-exp/app-check-compat/.eslintrc.js new file mode 100644 index 00000000000..468a2ee6a34 --- /dev/null +++ b/packages-exp/app-check-compat/.eslintrc.js @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const path = require('path'); + +module.exports = { + 'extends': '../../config/.eslintrc.js', + 'parserOptions': { + 'project': 'tsconfig.json', + 'tsconfigRootDir': __dirname + }, + rules: { + 'import/no-extraneous-dependencies': [ + 'error', + { + 'packageDir': [path.resolve(__dirname, '../../'), __dirname] + } + ] + } +}; diff --git a/packages-exp/app-check-compat/README.md b/packages-exp/app-check-compat/README.md new file mode 100644 index 00000000000..fab8226da30 --- /dev/null +++ b/packages-exp/app-check-compat/README.md @@ -0,0 +1,5 @@ +# @firebase/app-check-compat + +This is the Firebase App Check component (compat version) of the Firebase JS SDK. + +**This package is not intended for direct usage, and should only be used via the officially supported [`firebase`](https://www.npmjs.com/package/firebase) package.** diff --git a/packages-exp/app-check-compat/karma.conf.js b/packages-exp/app-check-compat/karma.conf.js new file mode 100644 index 00000000000..324777bcd54 --- /dev/null +++ b/packages-exp/app-check-compat/karma.conf.js @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const karmaBase = require('../../config/karma.base'); + +const files = [`**/*.test.ts`]; + +module.exports = function (config) { + config.set({ + ...karmaBase, + files, + preprocessors: { '**/*.ts': ['webpack', 'sourcemap'] }, + frameworks: ['mocha'] + }); +}; + +module.exports.files = files; diff --git a/packages-exp/app-check-compat/package.json b/packages-exp/app-check-compat/package.json new file mode 100644 index 00000000000..2dc071e49e1 --- /dev/null +++ b/packages-exp/app-check-compat/package.json @@ -0,0 +1,60 @@ +{ + "name": "@firebase/app-check-compat", + "version": "0.0.900", + "private": true, + "description": "A compat App Check package for new firebase packages", + "author": "Firebase (https://firebase.google.com/)", + "main": "dist/index.cjs.js", + "browser": "dist/index.esm2017.js", + "module": "dist/index.esm2017.js", + "files": [ + "dist" + ], + "scripts": { + "lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'", + "build": "rollup -c", + "build:release": "rollup -c rollup.config.release.js", + "build:deps": "lerna run --scope @firebase/app-check-compat --include-dependencies build", + "dev": "rollup -c -w", + "test": "run-p lint test:browser", + "test:ci": "node ../../scripts/run_tests_in_ci.js -s test:browser", + "test:browser": "karma start --single-run --nocache" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + }, + "dependencies": { + "@firebase/app-check-exp": "0.0.900", + "@firebase/logger": "0.2.6", + "@firebase/util": "1.1.0", + "@firebase/component": "0.5.3", + "tslib": "^2.1.0" + }, + "license": "Apache-2.0", + "devDependencies": { + "@firebase/app-compat": "0.0.900", + "rollup": "2.33.2", + "@rollup/plugin-commonjs": "17.1.0", + "@rollup/plugin-json": "4.1.0", + "@rollup/plugin-node-resolve": "11.2.0", + "rollup-plugin-typescript2": "0.29.0", + "typescript": "4.2.2" + }, + "repository": { + "directory": "packages/app-check", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "typings": "dist/src/index.d.ts", + "nyc": { + "extension": [ + ".ts" + ], + "reportDir": "./coverage/node" + }, + "esm5": "dist/index.esm.js" +} \ No newline at end of file diff --git a/packages-exp/app-check-compat/rollup.config.js b/packages-exp/app-check-compat/rollup.config.js new file mode 100644 index 00000000000..9b89b7772af --- /dev/null +++ b/packages-exp/app-check-compat/rollup.config.js @@ -0,0 +1,58 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import json from '@rollup/plugin-json'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; +import { es2017BuildsNoPlugin, es5BuildsNoPlugin } from './rollup.shared'; + +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript + }), + json() +]; + +const es5Builds = es5BuildsNoPlugin.map(build => ({ + ...build, + plugins: es5BuildPlugins +})); + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }), + json({ preferConst: true }) +]; + +const es2017Builds = es2017BuildsNoPlugin.map(build => ({ + ...build, + plugins: es2017BuildPlugins +})); + +export default [...es5Builds, ...es2017Builds]; diff --git a/packages-exp/app-check-compat/rollup.config.release.js b/packages-exp/app-check-compat/rollup.config.release.js new file mode 100644 index 00000000000..1896d7036f5 --- /dev/null +++ b/packages-exp/app-check-compat/rollup.config.release.js @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; +import json from '@rollup/plugin-json'; +import { importPathTransformer } from '../../scripts/exp/ts-transform-import-path'; +import { es2017BuildsNoPlugin, es5BuildsNoPlugin } from './rollup.shared'; + +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript, + clean: true, + abortOnError: false, + transformers: [importPathTransformer] + }), + json() +]; + +const es5Builds = es5BuildsNoPlugin.map(build => ({ + ...build, + plugins: es5BuildPlugins +})); + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + }, + abortOnError: false, + clean: true, + transformers: [importPathTransformer] + }), + json({ + preferConst: true + }) +]; + +const es2017Builds = es2017BuildsNoPlugin.map(build => ({ + ...build, + plugins: es2017BuildPlugins +})); + +export default [...es5Builds, ...es2017Builds]; diff --git a/packages-exp/app-check-compat/rollup.shared.js b/packages-exp/app-check-compat/rollup.shared.js new file mode 100644 index 00000000000..24bbc5a28c7 --- /dev/null +++ b/packages-exp/app-check-compat/rollup.shared.js @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import pkg from './package.json'; + +const deps = [ + ...Object.keys(Object.assign({}, pkg.peerDependencies, pkg.dependencies)), + '@firebase/app' +]; + +export const es5BuildsNoPlugin = [ + /** + * Browser Builds + */ + { + input: 'src/index.ts', + output: [ + { file: pkg.main, format: 'cjs', sourcemap: true }, + { file: pkg.esm5, format: 'es', sourcemap: true } + ], + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +/** + * ES2017 Builds + */ +export const es2017BuildsNoPlugin = [ + { + /** + * Browser Build + */ + input: 'src/index.ts', + output: { + file: pkg.browser, + format: 'es', + sourcemap: true + }, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; diff --git a/packages-exp/app-check-compat/src/index.ts b/packages-exp/app-check-compat/src/index.ts new file mode 100644 index 00000000000..d8aad99e612 --- /dev/null +++ b/packages-exp/app-check-compat/src/index.ts @@ -0,0 +1,69 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import firebase, { + _FirebaseNamespace, + FirebaseApp +} from '@firebase/app-compat'; +import { name, version } from '../package.json'; +import { + Component, + ComponentContainer, + ComponentType, + InstanceFactory +} from '@firebase/component'; +import { AppCheckService } from './service'; +import { FirebaseAppCheck } from '../../../packages/app-check-types'; + +declare module '@firebase/component' { + interface NameServiceMapping { + 'appCheck-compat': AppCheckService; + } +} + +const factory: InstanceFactory<'appCheck-compat'> = ( + container: ComponentContainer +) => { + // Dependencies + const app = container.getProvider('app-compat').getImmediate(); + const appCheckServiceExp = container + .getProvider('app-check-exp') + .getImmediate(); + + return new AppCheckService(app as FirebaseApp, appCheckServiceExp); +}; + +export function registerAppCheck(): void { + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + new Component('appCheck-compat', factory, ComponentType.PUBLIC) + ); +} + +registerAppCheck(); +firebase.registerVersion(name, version); + +/** + * Define extension behavior of `registerAppCheck` + */ +declare module '@firebase/app-compat' { + interface FirebaseNamespace { + appCheck(app?: FirebaseApp): FirebaseAppCheck; + } + interface FirebaseApp { + appCheck(): FirebaseAppCheck; + } +} diff --git a/packages-exp/app-check-compat/src/service.test.ts b/packages-exp/app-check-compat/src/service.test.ts new file mode 100644 index 00000000000..fe6cf762426 --- /dev/null +++ b/packages-exp/app-check-compat/src/service.test.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2017 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { expect, use } from 'chai'; +import { AppCheckService } from './service'; +import { firebase, FirebaseApp } from '@firebase/app-compat'; +import * as appCheckExp from '@firebase/app-check-exp'; +import { stub, match, SinonStub } from 'sinon'; +import * as sinonChai from 'sinon-chai'; +import { CustomProvider, ReCaptchaV3Provider } from '@firebase/app-check-exp'; +import { AppCheckTokenResult } from '../../../packages/app-check-types'; +import { PartialObserver } from '../../../packages/util/dist'; + +use(sinonChai); + +function createTestService(app: FirebaseApp): AppCheckService { + return new AppCheckService( + app, + appCheckExp.initializeAppCheck(app, { + provider: new ReCaptchaV3Provider('fake-site-key') + }) + ); +} + +describe('Firebase App Check > Service', () => { + let app: FirebaseApp; + let service: AppCheckService; + + beforeEach(() => { + app = firebase.initializeApp({ + apiKey: '456_LETTERS_AND_1234NUMBERS', + appId: '123lettersand:numbers', + projectId: 'my-project', + messagingSenderId: 'messaging-sender-id' + }); + }); + + afterEach(async () => { + await app.delete(); + }); + + it( + 'activate("string") calls modular initializeAppCheck() with a ' + + 'ReCaptchaV3Provider', + () => { + const initializeAppCheckStub = stub(appCheckExp, 'initializeAppCheck'); + service = new AppCheckService(app, {} as appCheckExp.AppCheck); + service.activate('my_site_key'); + expect(initializeAppCheckStub).to.be.calledWith(app, { + provider: match.instanceOf(ReCaptchaV3Provider), + isTokenAutoRefreshEnabled: undefined + }); + initializeAppCheckStub.restore(); + } + ); + + it( + 'activate(CustomProvider) calls modular initializeAppCheck() with' + + ' a CustomProvider', + () => { + const initializeAppCheckStub = stub(appCheckExp, 'initializeAppCheck'); + service = new AppCheckService(app, {} as appCheckExp.AppCheck); + const customGetTokenStub = stub(); + service.activate({ + getToken: customGetTokenStub + }); + expect(initializeAppCheckStub).to.be.calledWith(app, { + provider: match + .instanceOf(CustomProvider) + .and( + match.hasNested( + '_customProviderOptions.getToken', + customGetTokenStub + ) + ), + isTokenAutoRefreshEnabled: undefined + }); + initializeAppCheckStub.restore(); + } + ); + + it('setTokenAutoRefreshEnabled() calls modular setTokenAutoRefreshEnabled()', () => { + const setTokenAutoRefreshEnabledStub: SinonStub = stub( + appCheckExp, + 'setTokenAutoRefreshEnabled' + ); + service = createTestService(app); + service.setTokenAutoRefreshEnabled(true); + expect(setTokenAutoRefreshEnabledStub).to.be.calledWith( + service._delegate, + true + ); + setTokenAutoRefreshEnabledStub.restore(); + }); + + it('getToken() calls modular getToken()', async () => { + service = createTestService(app); + const getTokenStub = stub(appCheckExp, 'getToken'); + await service.getToken(true); + expect(getTokenStub).to.be.calledWith(service._delegate, true); + getTokenStub.restore(); + }); + + it('onTokenChanged() calls modular onTokenChanged() with observer', () => { + const onTokenChangedStub = stub(appCheckExp, 'onTokenChanged'); + service = createTestService(app); + const observer: PartialObserver = { + next: stub(), + error: stub() + }; + service.onTokenChanged(observer); + expect(onTokenChangedStub).to.be.calledWith(service._delegate, observer); + onTokenChangedStub.restore(); + }); + + it('onTokenChanged() calls modular onTokenChanged() with next/error fns', () => { + const onTokenChangedStub = stub(appCheckExp, 'onTokenChanged'); + service = createTestService(app); + const nextFn = stub(); + const errorFn = stub(); + service.onTokenChanged(nextFn, errorFn); + expect(onTokenChangedStub).to.be.calledWith( + service._delegate, + nextFn, + errorFn + ); + onTokenChangedStub.restore(); + }); +}); diff --git a/packages-exp/app-check-compat/src/service.ts b/packages-exp/app-check-compat/src/service.ts new file mode 100644 index 00000000000..a64e9c67b07 --- /dev/null +++ b/packages-exp/app-check-compat/src/service.ts @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + AppCheckProvider, + AppCheckTokenResult, + FirebaseAppCheck +} from '@firebase/app-check-types'; +import { _FirebaseService, FirebaseApp } from '@firebase/app-compat'; +import { + AppCheck as AppCheckServiceExp, + CustomProvider, + initializeAppCheck, + ReCaptchaV3Provider, + setTokenAutoRefreshEnabled as setTokenAutoRefreshEnabledExp, + getToken as getTokenExp, + onTokenChanged as onTokenChangedExp +} from '@firebase/app-check-exp'; +import { PartialObserver, Unsubscribe } from '@firebase/util'; + +export class AppCheckService implements FirebaseAppCheck, _FirebaseService { + constructor( + public app: FirebaseApp, + readonly _delegate: AppCheckServiceExp + ) {} + activate( + siteKeyOrProvider: string | AppCheckProvider, + isTokenAutoRefreshEnabled?: boolean + ): void { + let provider: ReCaptchaV3Provider | CustomProvider; + if (typeof siteKeyOrProvider === 'string') { + provider = new ReCaptchaV3Provider(siteKeyOrProvider); + } else { + provider = new CustomProvider({ getToken: siteKeyOrProvider.getToken }); + } + initializeAppCheck(this.app, { + provider, + isTokenAutoRefreshEnabled + }); + } + setTokenAutoRefreshEnabled(isTokenAutoRefreshEnabled: boolean): void { + setTokenAutoRefreshEnabledExp(this._delegate, isTokenAutoRefreshEnabled); + } + getToken(forceRefresh?: boolean): Promise { + return getTokenExp(this._delegate, forceRefresh); + } + onTokenChanged( + onNextOrObserver: + | PartialObserver + | ((tokenResult: AppCheckTokenResult) => void), + onError?: (error: Error) => void, + onCompletion?: () => void + ): Unsubscribe { + return onTokenChangedExp( + this._delegate, + /** + * Exp onTokenChanged() will handle both overloads but we need + * to specify one to not confuse Typescript. + */ + onNextOrObserver as (tokenResult: AppCheckTokenResult) => void, + onError, + onCompletion + ); + } +} diff --git a/packages-exp/app-check-compat/tsconfig.json b/packages-exp/app-check-compat/tsconfig.json new file mode 100644 index 00000000000..356e7a53b8c --- /dev/null +++ b/packages-exp/app-check-compat/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "strict": true + }, + "exclude": ["dist/**/*"] +}