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/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-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/**/*"] +} diff --git a/packages-exp/app-check-exp/src/api.test.ts b/packages-exp/app-check-exp/src/api.test.ts index 98954b02944..56ce9e8508b 100644 --- a/packages-exp/app-check-exp/src/api.test.ts +++ b/packages-exp/app-check-exp/src/api.test.ts @@ -16,22 +16,43 @@ */ 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 { spy, stub } from 'sinon'; +import { + setTokenAutoRefreshEnabled, + initializeAppCheck, + getToken, + onTokenChanged +} from './api'; +import { + FAKE_SITE_KEY, + getFullApp, + getFakeApp, + getFakeGreCAPTCHA, + getFakeAppCheck, + 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'; +import { AppCheckService } from './factory'; describe('api', () => { let app: FirebaseApp; beforeEach(() => { app = getFullApp(); + stub(util, 'getRecaptcha').returns(getFakeGreCAPTCHA()); }); afterEach(() => { + clearState(); + removegreCAPTCHAScriptsOnPage(); return deleteApp(app); }); @@ -88,8 +109,156 @@ 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, 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 = stub().throws(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 as AppCheckService); + + expect(listener1).to.be.called; + 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 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 = stub().throws(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 as AppCheckService); + + expect(listener1).to.be.called; + 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 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(appCheck as AppCheckService); + + 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..3c99e381f69 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 { AppCheckProvider, ListenerType } 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,114 @@ 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 as AppCheckService, + 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 as AppCheckService, + ListenerType.EXTERNAL, + 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..f4d0c2ff96f 100644 --- a/packages-exp/app-check-exp/src/factory.ts +++ b/packages-exp/app-check-exp/src/factory.ts @@ -17,7 +17,7 @@ import { AppCheck } from './public-types'; import { FirebaseApp, _FirebaseService } from '@firebase/app-exp'; -import { FirebaseAppCheckInternal } from './types'; +import { FirebaseAppCheckInternal, ListenerType } from './types'; import { getToken, addTokenListener, @@ -29,25 +29,29 @@ import { Provider } from '@firebase/component'; * AppCheck Service class. */ export class AppCheckService implements AppCheck, _FirebaseService { - constructor(public app: FirebaseApp) {} + 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.INTERNAL, 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 0f18a9dee68..b71e7aa35fb 100644 --- a/packages-exp/app-check-exp/src/internal-api.test.ts +++ b/packages-exp/app-check-exp/src/internal-api.test.ts @@ -23,8 +23,8 @@ import { FAKE_SITE_KEY, getFullApp, getFakeCustomTokenProvider, - getFakePlatformLoggingProvider, - removegreCAPTCHAScriptsOnPage + removegreCAPTCHAScriptsOnPage, + getFakeGreCAPTCHA } from '../test/util'; import { initializeAppCheck } from './api'; import { @@ -37,18 +37,37 @@ 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(); +import { AppCheckService } from './factory'; +import { ListenerType } from './types'; + +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,25 +77,14 @@ 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'); - 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({ @@ -85,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) }); @@ -97,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; @@ -113,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) }); @@ -124,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({ @@ -138,22 +146,28 @@ describe('internal api', () => { }); it('notifies listeners using cached token', async () => { - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: true }); const clock = useFakeTimers(); - stub(storage, 'readTokenFromStorage').returns( - Promise.resolve(fakeCachedAppCheckToken) - ); + storageReadStub.returns(Promise.resolve(fakeCachedAppCheckToken)); const listener1 = spy(); const listener2 = spy(); - addTokenListener(app, fakePlatformLoggingProvider, listener1); - addTokenListener(app, fakePlatformLoggingProvider, listener2); + addTokenListener( + appCheck as AppCheckService, + ListenerType.INTERNAL, + listener1 + ); + addTokenListener( + appCheck as AppCheckService, + ListenerType.INTERNAL, + listener2 + ); - await getToken(app, fakePlatformLoggingProvider); + await getToken(appCheck as AppCheckService); clock.tick(1); @@ -168,12 +182,11 @@ describe('internal api', () => { }); it('notifies listeners using new token', async () => { - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { provider: new ReCaptchaV3Provider(FAKE_SITE_KEY), isTokenAutoRefreshEnabled: true }); - stub(storage, 'readTokenFromStorage').returns(Promise.resolve(undefined)); stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); stub(client, 'exchangeToken').returns( Promise.resolve(fakeRecaptchaAppCheckToken) @@ -181,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.INTERNAL, + listener1 + ); + addTokenListener( + appCheck as AppCheckService, + ListenerType.INTERNAL, + listener2 + ); - await getToken(app, fakePlatformLoggingProvider); + await getToken(appCheck as AppCheckService); expect(listener1).to.be.calledWith({ token: fakeRecaptchaAppCheckToken.token @@ -194,8 +215,31 @@ describe('internal api', () => { }); }); + 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 + }); + stub(reCAPTCHA, 'getToken').returns(Promise.resolve(fakeRecaptchaToken)); + stub(client, 'exchangeToken').rejects('exchange error'); + const listener1 = spy(); + const errorFn1 = spy(); + + addTokenListener( + appCheck as AppCheckService, + ListenerType.EXTERNAL, + listener1, + errorFn1 + ); + + 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 }); @@ -208,10 +252,18 @@ describe('internal api', () => { }; const listener2 = spy(); - addTokenListener(app, fakePlatformLoggingProvider, listener1); - addTokenListener(app, fakePlatformLoggingProvider, listener2); + addTokenListener( + appCheck as AppCheckService, + ListenerType.INTERNAL, + listener1 + ); + addTokenListener( + appCheck as AppCheckService, + ListenerType.INTERNAL, + listener2 + ); - await getToken(app, fakePlatformLoggingProvider); + await getToken(appCheck as AppCheckService); expect(listener2).to.be.calledWith({ token: fakeRecaptchaAppCheckToken.token @@ -220,18 +272,16 @@ 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) }); - stub(storage, 'readTokenFromStorage').returns( - Promise.resolve(fakeCachedAppCheckToken) - ); + storageReadStub.returns(Promise.resolve(fakeCachedAppCheckToken)); 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); @@ -241,17 +291,16 @@ describe('internal api', () => { }); it('persists token to storage', async () => { - initializeAppCheck(app, { + const appCheck = initializeAppCheck(app, { 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'); - const result = await getToken(app, fakePlatformLoggingProvider); + storageWriteStub.resetHistory(); + const result = await getToken(appCheck as AppCheckService); expect(result).to.deep.equal({ token: fakeRecaptchaAppCheckToken.token }); expect(storageWriteStub).has.been.calledWith( app, @@ -261,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; @@ -276,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 }); @@ -285,14 +334,12 @@ describe('internal api', () => { stub(client, 'exchangeToken').returns( Promise.resolve({ token: 'new-recaptcha-app-check-token', - expireTimeMillis: 345, + expireTimeMillis: Date.now() + 60000, issuedAtTimeMillis: 0 }) ); - 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' }); }); @@ -306,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' ); @@ -322,18 +369,26 @@ describe('internal api', () => { it('adds token listeners', () => { const listener = (): void => {}; - addTokenListener(app, fakePlatformLoggingProvider, listener); + addTokenListener( + { app } as AppCheckService, + ListenerType.INTERNAL, + 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); + addTokenListener( + { app } as AppCheckService, + ListenerType.INTERNAL, + listener + ); expect(getState(app).tokenRefresher?.isRunning()).to.be.true; }); @@ -352,24 +407,28 @@ describe('internal api', () => { ...getState(app), token: { token: `fake-memory-app-check-token`, - expireTimeMillis: 123, + expireTimeMillis: Date.now() + 60000, issuedAtTimeMillis: 0 } }); - addTokenListener(app, fakePlatformLoggingProvider, fakeListener); + addTokenListener( + { app } as AppCheckService, + ListenerType.INTERNAL, + 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 }); - stub(storage, 'readTokenFromStorage').returns( + storageReadStub.returns( Promise.resolve({ token: `fake-cached-app-check-token`, - expireTimeMillis: 123, + expireTimeMillis: Date.now() + 60000, issuedAtTimeMillis: 0 }) ); @@ -382,7 +441,11 @@ describe('internal api', () => { done(); }; - addTokenListener(app, fakePlatformLoggingProvider, fakeListener); + addTokenListener( + appCheck as AppCheckService, + ListenerType.INTERNAL, + fakeListener + ); clock.tick(1); }); @@ -400,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.INTERNAL, + fakeListener + ); }); it('does NOT start token refresher in debug mode', () => { @@ -412,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.INTERNAL, + () => {} + ); const state = getState(app); expect(state.tokenRefresher).is.undefined; @@ -425,23 +496,31 @@ describe('internal api', () => { describe('removeTokenListener', () => { it('should remove token listeners', () => { const listener = (): void => {}; - addTokenListener(app, fakePlatformLoggingProvider, listener); - expect(getState(app).tokenListeners.length).to.equal(1); + addTokenListener( + { app } as AppCheckService, + ListenerType.INTERNAL, + listener + ); + 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', () => { const listener = (): void => {}; setState(app, { ...getState(app), isTokenAutoRefreshEnabled: true }); - addTokenListener(app, fakePlatformLoggingProvider, listener); - expect(getState(app).tokenListeners.length).to.equal(1); + addTokenListener( + { app } as AppCheckService, + ListenerType.INTERNAL, + listener + ); + 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..f21ed7cc99d 100644 --- a/packages-exp/app-check-exp/src/internal-api.ts +++ b/packages-exp/app-check-exp/src/internal-api.ts @@ -18,9 +18,11 @@ import { FirebaseApp } from '@firebase/app-exp'; import { AppCheckTokenResult, - AppCheckTokenListener, - AppCheckTokenInternal + AppCheckTokenInternal, + AppCheckTokenObserver, + ListenerType } from './types'; +import { AppCheckTokenListener } from './public-types'; import { getDebugState, getState, setState } from './state'; import { TOKEN_REFRESH_TIME } from './constants'; import { Refresher } from './proactive-refresh'; @@ -30,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. @@ -56,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 @@ -68,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 }; } @@ -134,14 +136,21 @@ export async function getToken( } export function addTokenListener( - app: FirebaseApp, - platformLoggerProvider: Provider<'platform-logger'>, - listener: AppCheckTokenListener + 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, + type + }; const newState = { ...state, - tokenListeners: [...state.tokenListeners, listener] + tokenObservers: [...state.tokenObservers, tokenObserver] }; /** @@ -165,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; } @@ -198,9 +207,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,14 +220,12 @@ export function removeTokenListener( setState(app, { ...state, - tokenListeners: newListeners + tokenObservers: newObservers }); } -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. @@ -226,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. @@ -271,13 +280,23 @@ 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.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. + 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. + 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/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..52cf2269e4d 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,19 @@ export interface FirebaseAppCheckInternal { removeTokenListener(listener: AppCheckTokenListener): void; } -export type AppCheckTokenListener = (token: AppCheckTokenResult) => void; +export interface AppCheckTokenObserver + extends PartialObserver { + // required + next: AppCheckTokenListener; + type: ListenerType; +} + +export const enum ListenerType { + // 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 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(