diff --git a/.github/workflows/scripts/firebase.json b/.github/workflows/scripts/firebase.json index 329eabe07d..58c49f8905 100644 --- a/.github/workflows/scripts/firebase.json +++ b/.github/workflows/scripts/firebase.json @@ -4,11 +4,9 @@ "indexes": "firestore.indexes.json" }, "functions": { - "predeploy": [ - "cd functions && yarn", - "cd functions && yarn --prefix \"$RESOURCE_DIR\" build" - ], - "source": "functions" + "predeploy": ["cd functions && yarn", "cd functions && yarn --prefix \"$RESOURCE_DIR\" build"], + "source": "functions", + "ignore": [".yarn", "yarn.lock", "*.log", "node_modules"] }, "database": { "rules": "database.rules" diff --git a/.github/workflows/scripts/functions/src/fetchAppCheckToken.ts b/.github/workflows/scripts/functions/src/fetchAppCheckToken.ts new file mode 100644 index 0000000000..068a0790e9 --- /dev/null +++ b/.github/workflows/scripts/functions/src/fetchAppCheckToken.ts @@ -0,0 +1,19 @@ +/* + * + * Testing tools for invertase/react-native-firebase use only. + * + * Copyright (C) 2018-present Invertase Limited + * + * See License file for more information. + */ + +import * as admin from 'firebase-admin'; +import * as functions from 'firebase-functions'; + +// Note: this will only work in a live environment, not locally via the Firebase emulator. +export const fetchAppCheckToken = functions.https.onCall(async data => { + const { appId } = data; + const expireTimeMillis = Math.floor(Date.now() / 1000) + 60 * 60; + const result = await admin.appCheck().createToken(appId); + return { ...result, expireTimeMillis }; +}); diff --git a/.github/workflows/scripts/functions/src/index.ts b/.github/workflows/scripts/functions/src/index.ts index 86fd8cbd89..106fb342a1 100644 --- a/.github/workflows/scripts/functions/src/index.ts +++ b/.github/workflows/scripts/functions/src/index.ts @@ -18,3 +18,4 @@ export const sleeper = functions.https.onCall(async data => { export { testFunctionCustomRegion } from './testFunctionCustomRegion'; export { testFunctionDefaultRegion } from './testFunctionDefaultRegion'; export { testFunctionRemoteConfigUpdate } from './testFunctionRemoteConfigUpdate'; +export { fetchAppCheckToken } from './fetchAppCheckToken'; diff --git a/packages/app-check/e2e/appcheck.e2e.js b/packages/app-check/e2e/appcheck.e2e.js index da7d48c1eb..d1ac67f606 100644 --- a/packages/app-check/e2e/appcheck.e2e.js +++ b/packages/app-check/e2e/appcheck.e2e.js @@ -57,29 +57,120 @@ function decodeJWT(token) { return payload; } -describe('appCheck() modular', function () { - describe('firebase v8 compatibility', function () { - before(function () { - rnfbProvider = firebase.appCheck().newReactNativeFirebaseAppCheckProvider(); - rnfbProvider.configure({ - android: { - provider: 'debug', - debugToken: '698956B2-187B-49C6-9E25-C3F3530EEBAF', +describe('appCheck()', function () { + describe('CustomProvider', function () { + if (!Platform.other) { + return; + } + + it('should throw an error if no provider options are defined', function () { + try { + new firebase.appCheck.CustomProvider(); + return Promise.reject(new Error('Did not throw an error.')); + } catch (e) { + e.message.should.containEql('no provider options defined'); + return Promise.resolve(); + } + }); + + it('should throw an error if no getToken function is defined', function () { + try { + new firebase.appCheck.CustomProvider({}); + return Promise.reject(new Error('Did not throw an error.')); + } catch (e) { + e.message.should.containEql('no getToken function defined'); + return Promise.resolve(); + } + }); + + it('should return a token from a custom provider', async function () { + const spy = sinon.spy(); + const provider = new firebase.appCheck.CustomProvider({ + getToken() { + spy(); + return FirebaseHelpers.fetchAppCheckToken(); }, - apple: { - provider: 'debug', - debugToken: '698956B2-187B-49C6-9E25-C3F3530EEBAF', + }); + + // Call from the provider directly. + const { token, expireTimeMillis } = await provider.getToken(); + spy.should.be.calledOnce(); + token.should.be.a.String(); + expireTimeMillis.should.be.a.Number(); + + // Call from the app check instance. + await firebase.appCheck().initializeAppCheck({ provider, isTokenAutoRefreshEnabled: false }); + const { token: tokenFromAppCheck } = await firebase.appCheck().getToken(true); + tokenFromAppCheck.should.be.a.String(); + + // Confirm that app check used the custom provider getToken function. + spy.should.be.calledTwice(); + }); + + it('should return a limited use token from a custom provider', async function () { + const provider = new firebase.appCheck.CustomProvider({ + getToken() { + return FirebaseHelpers.fetchAppCheckToken(); }, - web: { - provider: 'debug', - siteKey: 'none', + }); + + await firebase.appCheck().initializeAppCheck({ provider, isTokenAutoRefreshEnabled: false }); + const { token: tokenFromAppCheck } = await firebase.appCheck().getLimitedUseToken(); + tokenFromAppCheck.should.be.a.String(); + }); + + it('should listen for token changes', async function () { + const provider = new firebase.appCheck.CustomProvider({ + getToken() { + return FirebaseHelpers.fetchAppCheckToken(); }, }); + await firebase.appCheck().initializeAppCheck({ provider, isTokenAutoRefreshEnabled: false }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const unsubscribe = firebase.appCheck().onTokenChanged(_ => { + // TODO - improve testing cloud function to allow us to return tokens with low expiry + }); + + // TODO - improve testing cloud function to allow us to return tokens with low expiry + // result.should.be.an.Object(); + // const { token, expireTimeMillis } = result; + // token.should.be.a.String(); + // expireTimeMillis.should.be.a.Number(); + unsubscribe(); + }); + }); + + describe('firebase v8 compatibility', function () { + before(function () { + let provider; + + if (!Platform.other) { + provider = firebase.appCheck().newReactNativeFirebaseAppCheckProvider(); + provider.configure({ + android: { + provider: 'debug', + debugToken: '698956B2-187B-49C6-9E25-C3F3530EEBAF', + }, + apple: { + provider: 'debug', + debugToken: '698956B2-187B-49C6-9E25-C3F3530EEBAF', + }, + web: { + provider: 'debug', + siteKey: 'none', + }, + }); + } else { + provider = new firebase.appCheck.CustomProvider({ + getToken() { + return FirebaseHelpers.fetchAppCheckToken(); + }, + }); + } + // Our tests configure a debug provider with shared secret so we should get a valid token - firebase - .appCheck() - .initializeAppCheck({ provider: rnfbProvider, isTokenAutoRefreshEnabled: false }); + firebase.appCheck().initializeAppCheck({ provider, isTokenAutoRefreshEnabled: false }); }); describe('config', function () { @@ -221,6 +312,10 @@ describe('appCheck() modular', function () { }); describe('activate())', function () { + if (Platform.other) { + return; + } + it('should activate with default provider and defined token refresh', function () { firebase .appCheck() @@ -255,25 +350,35 @@ describe('appCheck() modular', function () { before(async function () { const { initializeAppCheck } = appCheckModular; - rnfbProvider = firebase.appCheck().newReactNativeFirebaseAppCheckProvider(); - rnfbProvider.configure({ - android: { - provider: 'debug', - debugToken: '698956B2-187B-49C6-9E25-C3F3530EEBAF', - }, - apple: { - provider: 'debug', - debugToken: '698956B2-187B-49C6-9E25-C3F3530EEBAF', - }, - web: { - provider: 'debug', - siteKey: 'none', - }, - }); + let provider; + + if (!Platform.other) { + provider = firebase.appCheck().newReactNativeFirebaseAppCheckProvider(); + provider.configure({ + android: { + provider: 'debug', + debugToken: '698956B2-187B-49C6-9E25-C3F3530EEBAF', + }, + apple: { + provider: 'debug', + debugToken: '698956B2-187B-49C6-9E25-C3F3530EEBAF', + }, + web: { + provider: 'debug', + siteKey: 'none', + }, + }); + } else { + provider = new firebase.appCheck.CustomProvider({ + getToken() { + return FirebaseHelpers.fetchAppCheckToken(); + }, + }); + } // Our tests configure a debug provider with shared secret so we should get a valid token appCheckInstance = await initializeAppCheck(undefined, { - provider: rnfbProvider, + provider, isTokenAutoRefreshEnabled: false, }); }); diff --git a/packages/app-check/lib/index.d.ts b/packages/app-check/lib/index.d.ts index 543b484cc0..f83908c9fb 100644 --- a/packages/app-check/lib/index.d.ts +++ b/packages/app-check/lib/index.d.ts @@ -68,16 +68,30 @@ export namespace FirebaseAppCheckTypes { getToken(): Promise; } + /** + * Custom provider class. + * @public + */ + export class CustomProvider implements AppCheckProvider { + constructor(customProviderOptions: CustomProviderOptions); + } + + export interface CustomProviderOptions { + /** + * Function to get an App Check token through a custom provider + * service. + */ + getToken: () => Promise; + } /** * Options for App Check initialization. */ export interface AppCheckOptions { /** - * A reCAPTCHA V3 provider, reCAPTCHA Enterprise provider, or custom provider. - * Note that in react-native-firebase provider should always be ReactNativeAppCheckCustomProvider, a cross-platform - * implementation of an AppCheck CustomProvider + * The App Check provider to use. This can be either the built-in reCAPTCHA provider + * or a custom provider. */ - provider: CustomProvider | ReCaptchaV3Provider | ReCaptchaEnterpriseProvider; + provider: CustomProvider; /** * If true, enables SDK to automatically @@ -185,6 +199,7 @@ export namespace FirebaseAppCheckTypes { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface Statics { // firebase.appCheck.* static props go here + CustomProvider: typeof CustomProvider; } /** @@ -210,18 +225,19 @@ export namespace FirebaseAppCheckTypes { */ export class Module extends FirebaseModule { /** - * create a ReactNativeFirebaseAppCheckProvider option for use in react-native-firebase + * Create a ReactNativeFirebaseAppCheckProvider option for use in react-native-firebase */ newReactNativeFirebaseAppCheckProvider(): ReactNativeFirebaseAppCheckProvider; /** - * initialize the AppCheck module. Note that in react-native-firebase AppCheckOptions must always + * Initialize the AppCheck module. Note that in react-native-firebase AppCheckOptions must always * be an object with a `provider` member containing `ReactNativeFirebaseAppCheckProvider` that has returned successfully * from a call to the `configure` method, with sub-providers for the various platforms configured to meet your project * requirements. This must be called prior to interacting with any firebase services protected by AppCheck * * @param options an AppCheckOptions with a configured ReactNativeFirebaseAppCheckProvider as the provider */ + // TODO wrong types initializeAppCheck(options: AppCheckOptions): Promise; /** @@ -282,6 +298,7 @@ export namespace FirebaseAppCheckTypes { * * @returns A function that unsubscribes this listener. */ + // TODO wrong types onTokenChanged(observer: PartialObserver): () => void; /** @@ -301,6 +318,7 @@ export namespace FirebaseAppCheckTypes { * * @returns A function that unsubscribes this listener. */ + // TODO wrong types onTokenChanged( onNext: (tokenResult: AppCheckListenerResult) => void, onError?: (error: Error) => void, diff --git a/packages/app-check/lib/index.js b/packages/app-check/lib/index.js index cecb4153f8..8e8f8339f2 100644 --- a/packages/app-check/lib/index.js +++ b/packages/app-check/lib/index.js @@ -15,7 +15,15 @@ * */ -import { isBoolean, isIOS, isString } from '@react-native-firebase/app/lib/common'; +import { + isBoolean, + isIOS, + isString, + isObject, + isFunction, + isUndefined, + isOther, +} from '@react-native-firebase/app/lib/common'; import { createModuleNamespace, FirebaseModule, @@ -23,6 +31,8 @@ import { } from '@react-native-firebase/app/lib/internal'; import { Platform } from 'react-native'; import ReactNativeFirebaseAppCheckProvider from './ReactNativeFirebaseAppCheckProvider'; +import { setReactNativeModule } from '@react-native-firebase/app/lib/internal/nativeModule'; +import fallBackModule from './web/RNFBAppCheckModule'; import version from './version'; @@ -34,12 +44,30 @@ export { setTokenAutoRefreshEnabled, } from './modular/index'; -const statics = {}; - const namespace = 'appCheck'; const nativeModuleName = 'RNFBAppCheckModule'; +export class CustomProvider { + constructor(_customProviderOptions) { + if (!isObject(_customProviderOptions)) { + throw new Error('Invalid configuration: no provider options defined.'); + } + if (!isFunction(_customProviderOptions.getToken)) { + throw new Error('Invalid configuration: no getToken function defined.'); + } + this._customProviderOptions = _customProviderOptions; + } + + async getToken() { + return this._customProviderOptions.getToken(); + } +} + +const statics = { + CustomProvider, +}; + class FirebaseAppCheckModule extends FirebaseModule { constructor(...args) { super(...args); @@ -63,6 +91,15 @@ class FirebaseAppCheckModule extends FirebaseModule { } initializeAppCheck(options) { + if (isOther) { + if (!isObject(options)) { + throw new Error('Invalid configuration: no options defined.'); + } + if (isUndefined(options.provider)) { + throw new Error('Invalid configuration: no provider defined.'); + } + return this.native.initializeAppCheck(options); + } // determine token refresh setting, if not specified if (!isBoolean(options.isTokenAutoRefreshEnabled)) { options.isTokenAutoRefreshEnabled = this.firebaseJson.app_check_token_auto_refresh; @@ -108,6 +145,9 @@ class FirebaseAppCheckModule extends FirebaseModule { } activate(siteKeyOrProvider, isTokenAutoRefreshEnabled) { + if (isOther) { + throw new Error('firebase.appCheck().activate(*) is not supported on other platforms'); + } if (!isString(siteKeyOrProvider)) { throw new Error('siteKeyOrProvider must be a string value to match firebase-js-sdk API'); } @@ -130,6 +170,7 @@ class FirebaseAppCheckModule extends FirebaseModule { return this.initializeAppCheck({ provider: rnfbProvider, isTokenAutoRefreshEnabled }); } + // TODO this is an async call setTokenAutoRefreshEnabled(isTokenAutoRefreshEnabled) { this.native.setTokenAutoRefreshEnabled(isTokenAutoRefreshEnabled); } @@ -203,3 +244,6 @@ export default createModuleNamespace({ // appCheck().X(...); // firebase.appCheck().X(...); export const firebase = getFirebaseRoot(); + +// Register the interop module for non-native platforms. +setReactNativeModule(nativeModuleName, fallBackModule); diff --git a/packages/app-check/lib/web/RNFBAppCheckModule.android.js b/packages/app-check/lib/web/RNFBAppCheckModule.android.js new file mode 100644 index 0000000000..af77c859b1 --- /dev/null +++ b/packages/app-check/lib/web/RNFBAppCheckModule.android.js @@ -0,0 +1,2 @@ +// No-op for android. +export default {}; diff --git a/packages/app-check/lib/web/RNFBAppCheckModule.ios.js b/packages/app-check/lib/web/RNFBAppCheckModule.ios.js new file mode 100644 index 0000000000..a3429ada0e --- /dev/null +++ b/packages/app-check/lib/web/RNFBAppCheckModule.ios.js @@ -0,0 +1,2 @@ +// No-op for ios. +export default {}; diff --git a/packages/app-check/lib/web/RNFBAppCheckModule.js b/packages/app-check/lib/web/RNFBAppCheckModule.js new file mode 100644 index 0000000000..634cf28ee2 --- /dev/null +++ b/packages/app-check/lib/web/RNFBAppCheckModule.js @@ -0,0 +1,95 @@ +import { + getApp, + initializeAppCheck, + getToken, + getLimitedUseToken, + setTokenAutoRefreshEnabled, + CustomProvider, + onTokenChanged, + makeIDBAvailable, +} from '@react-native-firebase/app/lib/internal/web/firebaseAppCheck'; +import { guard, emitEvent } from '@react-native-firebase/app/lib/internal/web/utils'; + +let appCheckInstances = {}; +let listenersForApp = {}; + +function getAppCheckInstanceForApp(appName) { + if (!appCheckInstances[appName]) { + throw new Error( + `firebase AppCheck instance for app ${appName} has not been initialized, ensure you have called initializeAppCheck() first.`, + ); + } + return appCheckInstances[appName]; +} + +/** + * This is a 'NativeModule' for the web platform. + * Methods here are identical to the ones found in + * the native android/ios modules e.g. `@ReactMethod` annotated + * java methods on Android. + */ +export default { + initializeAppCheck(appName, options) { + makeIDBAvailable(); + return guard(async () => { + if (appCheckInstances[appName]) { + return; + } + const { provider, isTokenAutoRefreshEnabled } = options; + const _provider = new CustomProvider({ + getToken() { + return provider.getToken(); + }, + }); + appCheckInstances[appName] = initializeAppCheck(getApp(appName), { + provider: _provider, + isTokenAutoRefreshEnabled, + }); + return null; + }); + }, + setTokenAutoRefreshEnabled(appName, isTokenAutoRefreshEnabled) { + return guard(async () => { + const instance = getAppCheckInstanceForApp(appName); + setTokenAutoRefreshEnabled(instance, isTokenAutoRefreshEnabled); + return null; + }); + }, + getLimitedUseToken(appName) { + return guard(async () => { + const instance = getAppCheckInstanceForApp(appName); + return getLimitedUseToken(instance); + }); + }, + getToken(appName, forceRefresh) { + return guard(async () => { + const instance = getAppCheckInstanceForApp(appName); + return getToken(instance, forceRefresh); + }); + }, + addAppCheckListener(appName) { + return guard(async () => { + if (listenersForApp[appName]) { + return; + } + const instance = getAppCheckInstanceForApp(appName); + listenersForApp[appName] = onTokenChanged(instance, tokenResult => { + emitEvent('appCheck_token_changed', { + appName, + ...tokenResult, + }); + }); + return null; + }); + }, + removeAppCheckListener(appName) { + return guard(async () => { + if (!listenersForApp[appName]) { + return; + } + listenersForApp[appName](); + delete listenersForApp[appName]; + return null; + }); + }, +}; diff --git a/packages/app-check/type-test.ts b/packages/app-check/type-test.ts index 4d66edf35c..dcf3d5f9a2 100644 --- a/packages/app-check/type-test.ts +++ b/packages/app-check/type-test.ts @@ -1,4 +1,6 @@ -import firebase from '.'; +import firebase from '.'; + +// TODO none of these seem to work, local issue? // checks module exists at root console.log(firebase.appCheck().app.name); diff --git a/packages/app/lib/internal/web/firebaseAppCheck.js b/packages/app/lib/internal/web/firebaseAppCheck.js new file mode 100644 index 0000000000..455eeaf078 --- /dev/null +++ b/packages/app/lib/internal/web/firebaseAppCheck.js @@ -0,0 +1,6 @@ +// We need to share firebase imports between modules, otherwise +// apps and instances of the firebase modules are not shared. +import 'firebase/app'; +export { getApp } from 'firebase/app'; +export * from 'firebase/app-check'; +export { makeIDBAvailable } from './memidb'; diff --git a/tests/app.js b/tests/app.js index e7405f2997..a9f18e7d78 100644 --- a/tests/app.js +++ b/tests/app.js @@ -32,6 +32,7 @@ if (Platform.other) { platformSupportedModules.push('storage'); platformSupportedModules.push('remoteConfig'); platformSupportedModules.push('analytics'); + platformSupportedModules.push('appCheck'); // TODO add more modules here once they are supported. } diff --git a/tests/globals.js b/tests/globals.js index 6890eb96ef..201491cdcc 100644 --- a/tests/globals.js +++ b/tests/globals.js @@ -189,6 +189,25 @@ global.FirebaseHelpers = { }; }, }, + async fetchAppCheckToken() { + const tokenRequest = await fetch( + 'https://us-central1-react-native-firebase-testing.cloudfunctions.net/fetchAppCheckToken', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + data: { + appId: global.FirebaseHelpers.app.config().appId, + }, + }), + redirect: 'follow', + }, + ); + const { result } = await tokenRequest.json(); + return result; + }, }; global.android = {