diff --git a/package.json b/package.json index b6f3b6886c..6428796b66 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,8 @@ "tests:emulator:start": "yarn tests:emulator:prepare && cd ./.github/workflows/scripts && ./start-firebase-emulator.sh --no-daemon", "tests:emulator:start:windows": "yarn tests:emulator:prepare && cd ./.github/workflows/scripts && ./start-firebase-emulator.bat --no-daemon", "tests:emulator:start-ci": "yarn tests:emulator:prepare && cd ./.github/workflows/scripts && ./start-firebase-emulator.sh", - "tests:android:build": "cd tests && cross-env FIREBASE_APP_CHECK_DEBUG_TOKEN=\"698956B2-187B-49C6-9E25-C3F3530EEBAF\" yarn detox build --configuration android.emu.debug", - "tests:android:build:windows": "cd tests && cross-env FIREBASE_APP_CHECK_DEBUG_TOKEN=\"698956B2-187B-49C6-9E25-C3F3530EEBAF\" yarn detox build --configuration android.emu.debug.windows", + "tests:android:build": "cd tests && yarn detox build --configuration android.emu.debug", + "tests:android:build:windows": "cd tests && yarn detox build --configuration android.emu.debug.windows", "tests:android:build-release": "cd tests && yarn detox build --configuration android.emu.release", "tests:android:test": "yarn tests:android:emulator:forward && cd tests && yarn detox test --configuration android.emu.debug", "tests:android:test:debug": "yarn tests:android:emulator:forward && cd tests && yarn detox test --configuration android.emu.debug --inspect", diff --git a/packages/app-check/android/build.gradle b/packages/app-check/android/build.gradle index f253ca2b05..3a97bbdee8 100644 --- a/packages/app-check/android/build.gradle +++ b/packages/app-check/android/build.gradle @@ -89,6 +89,7 @@ repositories { dependencies { api appProject implementation platform("com.google.firebase:firebase-bom:${ReactNative.ext.getVersion('firebase', 'bom')}") + implementation 'com.google.firebase:firebase-appcheck-playintegrity' implementation "com.google.firebase:firebase-appcheck-safetynet" implementation "com.google.firebase:firebase-appcheck-debug" } diff --git a/packages/app-check/android/src/main/java/io/invertase/firebase/appcheck/ReactNativeFirebaseAppCheckModule.java b/packages/app-check/android/src/main/java/io/invertase/firebase/appcheck/ReactNativeFirebaseAppCheckModule.java index 676d555eb7..5f99bb236a 100644 --- a/packages/app-check/android/src/main/java/io/invertase/firebase/appcheck/ReactNativeFirebaseAppCheckModule.java +++ b/packages/app-check/android/src/main/java/io/invertase/firebase/appcheck/ReactNativeFirebaseAppCheckModule.java @@ -23,65 +23,125 @@ import com.facebook.react.bridge.*; import com.google.android.gms.tasks.Tasks; import com.google.firebase.FirebaseApp; -import com.google.firebase.appcheck.AppCheckProviderFactory; import com.google.firebase.appcheck.FirebaseAppCheck; -import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory; -import com.google.firebase.appcheck.safetynet.SafetyNetAppCheckProviderFactory; +import io.invertase.firebase.common.ReactNativeFirebaseJSON; +import io.invertase.firebase.common.ReactNativeFirebaseMeta; import io.invertase.firebase.common.ReactNativeFirebaseModule; -import java.lang.reflect.*; +import io.invertase.firebase.common.ReactNativeFirebasePreferences; public class ReactNativeFirebaseAppCheckModule extends ReactNativeFirebaseModule { private static final String TAG = "AppCheck"; + private static final String LOGTAG = "RNFBAppCheck"; + private static final String KEY_APPCHECK_TOKEN_REFRESH_ENABLED = "app_check_token_auto_refresh"; + ReactNativeFirebaseAppCheckProviderFactory providerFactory = + new ReactNativeFirebaseAppCheckProviderFactory(); + + static boolean isAppCheckTokenRefreshEnabled() { + boolean enabled; + ReactNativeFirebaseJSON json = ReactNativeFirebaseJSON.getSharedInstance(); + ReactNativeFirebaseMeta meta = ReactNativeFirebaseMeta.getSharedInstance(); + ReactNativeFirebasePreferences prefs = ReactNativeFirebasePreferences.getSharedInstance(); + + if (prefs.contains(KEY_APPCHECK_TOKEN_REFRESH_ENABLED)) { + enabled = prefs.getBooleanValue(KEY_APPCHECK_TOKEN_REFRESH_ENABLED, true); + Log.d(LOGTAG, "isAppCheckCollectionEnabled via RNFBPreferences: " + enabled); + } else if (json.contains(KEY_APPCHECK_TOKEN_REFRESH_ENABLED)) { + enabled = json.getBooleanValue(KEY_APPCHECK_TOKEN_REFRESH_ENABLED, true); + Log.d(LOGTAG, "isAppCheckCollectionEnabled via RNFBJSON: " + enabled); + } else { + enabled = meta.getBooleanValue(KEY_APPCHECK_TOKEN_REFRESH_ENABLED, true); + Log.d(LOGTAG, "isAppCheckCollectionEnabled via RNFBMeta: " + enabled); + } + + if (BuildConfig.DEBUG) { + if (!json.getBooleanValue(KEY_APPCHECK_TOKEN_REFRESH_ENABLED, false)) { + enabled = false; + } + Log.d( + LOGTAG, + "isAppCheckTokenRefreshEnabled after checking " + + KEY_APPCHECK_TOKEN_REFRESH_ENABLED + + ": " + + enabled); + } + + Log.d(LOGTAG, "isAppCheckTokenRefreshEnabled final value: " + enabled); + return enabled; + } + + private boolean isAppDebuggable() throws Exception { + boolean isDebuggable = false; + PackageManager pm = getContext().getPackageManager(); + if (pm != null) { + isDebuggable = + (0 + != (pm.getApplicationInfo(getContext().getPackageName(), 0).flags + & ApplicationInfo.FLAG_DEBUGGABLE)); + } + Log.d(LOGTAG, "debuggable status? " + isDebuggable); + return isDebuggable; + } ReactNativeFirebaseAppCheckModule(ReactApplicationContext reactContext) { super(reactContext, TAG); + + // Our default token refresh config comes from config files, set it + FirebaseAppCheck firebaseAppCheck = FirebaseAppCheck.getInstance(); + firebaseAppCheck.setTokenAutoRefreshEnabled(isAppCheckTokenRefreshEnabled()); + } + + @ReactMethod + public void configureProvider( + String appName, String providerName, String debugToken, Promise promise) { + Log.d( + LOGTAG, + "configureProvider - appName/providerName/debugToken: " + + appName + + "/" + + providerName + + (debugToken != null ? "/(not shown)" : "/null")); + try { + providerFactory.configure(appName, providerName, debugToken); + FirebaseAppCheck.getInstance(FirebaseApp.getInstance(appName)) + .installAppCheckProviderFactory(providerFactory); + promise.resolve(null); + } catch (Exception e) { + rejectPromiseWithCodeAndMessage(promise, "unknown", "internal-error", e.getMessage()); + } } @ReactMethod public void activate( String appName, String siteKeyProvider, boolean isTokenAutoRefreshEnabled, Promise promise) { try { - FirebaseAppCheck firebaseAppCheck = FirebaseAppCheck.getInstance(); - firebaseAppCheck.setTokenAutoRefreshEnabled(isTokenAutoRefreshEnabled); - boolean isDebuggable = false; - PackageManager pm = getContext().getPackageManager(); - if (pm != null) { - isDebuggable = - (0 - != (pm.getApplicationInfo(getContext().getPackageName(), 0).flags - & ApplicationInfo.FLAG_DEBUGGABLE)); - } - if (isDebuggable) { + FirebaseAppCheck firebaseAppCheck = + FirebaseAppCheck.getInstance(FirebaseApp.getInstance(appName)); + firebaseAppCheck.setTokenAutoRefreshEnabled(isTokenAutoRefreshEnabled); + // Configure our new proxy factory in a backwards-compatible way for old API + if (isAppDebuggable()) { + Log.d(LOGTAG, "app is debuggable, configuring AppCheck for testing mode"); if (BuildConfig.FIREBASE_APP_CHECK_DEBUG_TOKEN != "null") { - // Get DebugAppCheckProviderFactory class - Class debugACFactoryClass = - DebugAppCheckProviderFactory.class; - - // Get the (undocumented) constructor accepting a debug token as string - Class[] argType = {String.class}; - Constructor c = debugACFactoryClass.getDeclaredConstructor(argType); - - // Create a object containing the constructor arguments - // and initialize a new instance. - Object[] cArgs = {BuildConfig.FIREBASE_APP_CHECK_DEBUG_TOKEN}; - firebaseAppCheck.installAppCheckProviderFactory( - (AppCheckProviderFactory) c.newInstance(cArgs)); + Log.d(LOGTAG, "debug app check token found in BuildConfig. Installing known token."); + providerFactory.configure(appName, "debug", BuildConfig.FIREBASE_APP_CHECK_DEBUG_TOKEN); } else { - firebaseAppCheck.installAppCheckProviderFactory( - DebugAppCheckProviderFactory.getInstance()); + Log.d( + LOGTAG, + "no debug app check token found in BuildConfig. Check Log for dynamic test token to" + + " configure in console."); + providerFactory.configure(appName, "debug", null); } - } else { - firebaseAppCheck.installAppCheckProviderFactory( - SafetyNetAppCheckProviderFactory.getInstance()); + providerFactory.configure(appName, "safetyNet", null); } + + FirebaseAppCheck.getInstance(FirebaseApp.getInstance(appName)) + .installAppCheckProviderFactory(providerFactory); + promise.resolve(null); } catch (Exception e) { - rejectPromiseWithCodeAndMessage(promise, "unknown", "internal-error", "unimplemented"); - return; + rejectPromiseWithCodeAndMessage(promise, "unknown", "internal-error", e.getMessage()); } - promise.resolve(null); } @ReactMethod @@ -92,6 +152,7 @@ public void setTokenAutoRefreshEnabled(String appName, boolean isTokenAutoRefres @ReactMethod public void getToken(String appName, boolean forceRefresh, Promise promise) { + Log.d(LOGTAG, "getToken appName/forceRefresh: " + appName + "/" + forceRefresh); FirebaseApp firebaseApp = FirebaseApp.getInstance(appName); Tasks.call( getExecutor(), @@ -108,7 +169,7 @@ public void getToken(String appName, boolean forceRefresh, Promise promise) { promise.resolve(tokenResultMap); } else { Log.e( - TAG, + LOGTAG, "RNFB: Unknown error while fetching AppCheck token " + task.getException().getMessage()); rejectPromiseWithCodeAndMessage( diff --git a/packages/app-check/android/src/main/java/io/invertase/firebase/appcheck/ReactNativeFirebaseAppCheckProvider.java b/packages/app-check/android/src/main/java/io/invertase/firebase/appcheck/ReactNativeFirebaseAppCheckProvider.java new file mode 100644 index 0000000000..41ff632107 --- /dev/null +++ b/packages/app-check/android/src/main/java/io/invertase/firebase/appcheck/ReactNativeFirebaseAppCheckProvider.java @@ -0,0 +1,85 @@ +package io.invertase.firebase.appcheck; + +/* + * Copyright (c) 2023-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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 android.util.Log; +import com.google.android.gms.tasks.Task; +import com.google.firebase.FirebaseApp; +import com.google.firebase.appcheck.AppCheckProvider; +import com.google.firebase.appcheck.AppCheckProviderFactory; +import com.google.firebase.appcheck.AppCheckToken; +import com.google.firebase.appcheck.debug.DebugAppCheckProviderFactory; +import com.google.firebase.appcheck.playintegrity.PlayIntegrityAppCheckProviderFactory; +import com.google.firebase.appcheck.safetynet.SafetyNetAppCheckProviderFactory; +import java.lang.reflect.Constructor; + +// Facade for all possible provider factory delegates, +// configurable dynamically instead of at startup +public class ReactNativeFirebaseAppCheckProvider implements AppCheckProvider { + private static final String LOGTAG = "RNFBAppCheck"; + + AppCheckProvider delegateProvider; + + @Override + public Task getToken() { + Log.d(LOGTAG, "Provider::getToken - delegating to native provider"); + return delegateProvider.getToken(); + } + + public void configure(String appName, String providerName, String debugToken) { + Log.d( + LOGTAG, + "Provider::configure with appName/providerName/debugToken: " + + appName + + "/" + + providerName + + "/" + + (debugToken != null ? "(not shown)" : "null")); + + try { + FirebaseApp app = FirebaseApp.getInstance(appName); + + if ("debug".equals(providerName)) { + + // the debug configuration may have a token, or may not + if (debugToken != null) { + // Create a debug provider using hidden factory constructor and our debug token + Class debugACFactoryClass = + DebugAppCheckProviderFactory.class; + Class[] argType = {String.class}; + Constructor c = debugACFactoryClass.getDeclaredConstructor(argType); + Object[] cArgs = {debugToken}; + delegateProvider = ((AppCheckProviderFactory) c.newInstance(cArgs)).create(app); + } else { + delegateProvider = DebugAppCheckProviderFactory.getInstance().create(app); + } + } + + if ("safetyNet".equals(providerName)) { + delegateProvider = SafetyNetAppCheckProviderFactory.getInstance().create(app); + } + + if ("playIntegrity".equals(providerName)) { + delegateProvider = PlayIntegrityAppCheckProviderFactory.getInstance().create(app); + } + } catch (Exception e) { + // This will bubble up and result in a rejected promise with the underlying message + throw new RuntimeException(e.getMessage()); + } + } +} diff --git a/packages/app-check/android/src/main/java/io/invertase/firebase/appcheck/ReactNativeFirebaseAppCheckProviderFactory.java b/packages/app-check/android/src/main/java/io/invertase/firebase/appcheck/ReactNativeFirebaseAppCheckProviderFactory.java new file mode 100644 index 0000000000..46dcdbe212 --- /dev/null +++ b/packages/app-check/android/src/main/java/io/invertase/firebase/appcheck/ReactNativeFirebaseAppCheckProviderFactory.java @@ -0,0 +1,66 @@ +package io.invertase.firebase.appcheck; + +/* + * Copyright (c) 2023-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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 android.util.Log; +import com.google.firebase.FirebaseApp; +import com.google.firebase.appcheck.AppCheckProvider; +import com.google.firebase.appcheck.AppCheckProviderFactory; +import java.util.HashMap; +import java.util.Map; + +public class ReactNativeFirebaseAppCheckProviderFactory implements AppCheckProviderFactory { + private static final String LOGTAG = "RNFBAppCheck"; + + // This object has one job - create + maintain control over one provider per app + public Map providers = new HashMap(); + + // Our provider will serve as a facade to all the supported native providers, + // we will just pass through configuration calls to it + public void configure(String appName, String providerName, String debugToken) { + Log.d( + LOGTAG, + "ProviderFactory::configure - appName/providerName/debugToken: " + + appName + + "/" + + providerName + + (debugToken != null ? "/(not shown)" : "/null")); + + ReactNativeFirebaseAppCheckProvider provider = null; + + // Look up the correct provider for the given appName, create it if not created + provider = providers.get(appName); + if (provider == null) { + provider = new ReactNativeFirebaseAppCheckProvider(); + providers.put(appName, provider); + } + provider.configure(appName, providerName, debugToken); + } + + public AppCheckProvider create(FirebaseApp firebaseApp) { + String appName = firebaseApp.getName(); + Log.d(LOGTAG, "ProviderFactory::create - fetching provider for app " + appName); + ReactNativeFirebaseAppCheckProvider provider = providers.get(appName); + if (provider == null) { + Log.d(LOGTAG, "ProviderFactory::create - provider not configured for this app."); + throw new RuntimeException( + "ReactNativeFirebaseAppCheckProvider not configured for app " + appName); + } + return provider; + } +} diff --git a/packages/app-check/e2e/appcheck.e2e.js b/packages/app-check/e2e/appcheck.e2e.js index dcd3ae2d02..440c1925b6 100644 --- a/packages/app-check/e2e/appcheck.e2e.js +++ b/packages/app-check/e2e/appcheck.e2e.js @@ -18,6 +18,28 @@ const jwt = require('jsonwebtoken'); describe('appCheck()', function () { + before(function () { + rnfbProvider = firebase.appCheck().newReactNativeFirebaseAppCheckProvider(); + rnfbProvider.configure({ + android: { + provider: 'debug', + debugToken: '698956B2-187B-49C6-9E25-C3F3530EEBAF', + }, + apple: { + provider: 'debug', + }, + web: { + provider: 'debug', + siteKey: 'none', + }, + }); + + // Our tests configure a debug provider with shared secret so we should get a valid token + firebase + .appCheck() + .initializeAppCheck({ provider: rnfbProvider, isTokenAutoRefreshEnabled: false }); + }); + describe('config', function () { // This depends on firebase.json containing a false value for token auto refresh, we // verify here that it was carried in to the Info.plist correctly @@ -36,15 +58,30 @@ describe('appCheck()', function () { }); describe('setTokenAutoRefresh())', function () { - it('should set token refresh', function () { + it('should set token refresh', async function () { firebase.appCheck().setTokenAutoRefreshEnabled(false); + + // Only iOS lets us assert on this unfortunately, other platforms have no accessor + if (device.getPlatform() === 'ios') { + let tokenRefresh = await NativeModules.RNFBAppCheckModule.isTokenAutoRefreshEnabled( + '[DEFAULT]', + ); + tokenRefresh.should.equal(false); + } + firebase.appCheck().setTokenAutoRefreshEnabled(true); + // Only iOS lets us assert on this unfortunately, other platforms have no accessor + if (device.getPlatform() === 'ios') { + tokenRefresh = await NativeModules.RNFBAppCheckModule.isTokenAutoRefreshEnabled( + '[DEFAULT]', + ); + tokenRefresh.should.equal(true); + } }); }); + describe('getToken())', function () { - it('token fetch attempt should work', async function () { - await firebase.appCheck().activate('ignored', false); - // Our tests configure a debug provider with shared secret so we should get a valid token - const { token } = await firebase.appCheck().getToken(); + it('token fetch attempt with configured debug token should work', async function () { + const { token } = await firebase.appCheck().getToken(true); token.should.not.equal(''); const decodedToken = jwt.decode(token); decodedToken.aud[1].should.equal('projects/react-native-firebase-testing'); @@ -52,6 +89,9 @@ describe('appCheck()', function () { Promise.reject('Token already expired'); } + // on android if you move too fast, you may not get a fresh token + await Utils.sleep(2000); + // Force refresh should get a different token? // TODO sometimes fails on android https://github.com/firebase/firebase-android-sdk/issues/2954 const { token: token2 } = await firebase.appCheck().getToken(true); @@ -63,7 +103,60 @@ describe('appCheck()', function () { } (token === token2).should.be.false(); }); + + it('token fetch with config switch to invalid then valid should fail then work', async function () { + rnfbProvider = firebase.appCheck().newReactNativeFirebaseAppCheckProvider(); + rnfbProvider.configure({ + android: { + provider: 'playIntegrity', + }, + apple: { + provider: 'appAttest', + }, + web: { + provider: 'debug', + siteKey: 'none', + }, + }); + + // Our tests configure a debug provider with shared secret so we should get a valid token + firebase + .appCheck() + .initializeAppCheck({ provider: rnfbProvider, isTokenAutoRefreshEnabled: false }); + try { + await firebase.appCheck().getToken(true); + return Promise.reject(new Error('should have thrown an error')); + } catch (e) { + e.message.should.containEql('appCheck/token-error'); + } + + rnfbProvider.configure({ + android: { + provider: 'debug', + debugToken: '698956B2-187B-49C6-9E25-C3F3530EEBAF', + }, + apple: { + provider: 'debug', + }, + web: { + provider: 'debug', + siteKey: 'none', + }, + }); + firebase + .appCheck() + .initializeAppCheck({ provider: rnfbProvider, isTokenAutoRefreshEnabled: false }); + + const { token } = await firebase.appCheck().getToken(true); + token.should.not.equal(''); + const decodedToken = jwt.decode(token); + decodedToken.aud[1].should.equal('projects/react-native-firebase-testing'); + if (decodedToken.exp < Date.now()) { + Promise.reject('Token already expired'); + } + }); }); + describe('activate())', function () { it('should activate with default provider and defined token refresh', function () { firebase diff --git a/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckDebugProvider.h b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckDebugProvider.h new file mode 100644 index 0000000000..82a496cf77 --- /dev/null +++ b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckDebugProvider.h @@ -0,0 +1,7 @@ +// Subclass FIRAppCheckDebugProvider here +// https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/MethodOverriding.html + +// 1. have a new nullable configuredDebugToken property +// 2. override implementation of currentDebutToken to: +// - return configuredDebugToken if it exists, +// - else return [super currentDebugToken] if not diff --git a/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckModule.h b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckModule.h index bd8fe9a23c..999f403a6e 100644 --- a/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckModule.h +++ b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckModule.h @@ -19,6 +19,12 @@ #import +#import "RNFBAppCheckProviderFactory.h" + @interface RNFBAppCheckModule : NSObject +@property RNFBAppCheckProviderFactory* _Nullable providerFactory; + ++ (_Nonnull instancetype)sharedInstance; + @end diff --git a/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckModule.m b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckModule.m index 672cc87dc8..f962a7cb69 100644 --- a/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckModule.m +++ b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckModule.m @@ -33,6 +33,17 @@ - (dispatch_queue_t)methodQueue { return dispatch_get_main_queue(); } ++ (instancetype)sharedInstance { + static dispatch_once_t once; + __strong static RNFBAppCheckModule *sharedInstance; + dispatch_once(&once, ^{ + sharedInstance = [[RNFBAppCheckModule alloc] init]; + sharedInstance.providerFactory = [[RNFBAppCheckProviderFactory alloc] init]; + [FIRAppCheck setAppCheckProviderFactory:sharedInstance.providerFactory]; + }); + return sharedInstance; +} + #pragma mark - #pragma mark Firebase AppCheck Methods @@ -42,21 +53,35 @@ - (dispatch_queue_t)methodQueue { : (BOOL)isTokenAutoRefreshEnabled : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { - // From SDK docs: - // NOTE: Make sure to call this method before FirebaseApp.configure(). - // If this method is called after configuring Firebase, the changes will not take effect. - - // So in react-native-firebase we will only use this to set the isTokenAutoRefreshEnabled - // parameter, but if AppCheck is included on iOS it wlil be active with DeviceCheckProviderFactory + DLog(@"deprecated API, provider will be deviceCheck / token refresh %d for app %@", + isTokenAutoRefreshEnabled, firebaseApp.name); + [[RNFBAppCheckModule sharedInstance].providerFactory configure:firebaseApp + providerName:@"deviceCheck" + debugToken:nil]; FIRAppCheck *appCheck = [FIRAppCheck appCheckWithApp:firebaseApp]; appCheck.isTokenAutoRefreshEnabled = isTokenAutoRefreshEnabled; resolve([NSNull null]); } +RCT_EXPORT_METHOD(configureProvider + : (FIRApp *)firebaseApp + : (nonnull NSString *)providerName + : (nullable NSString *)debugToken + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) { + DLog(@"appName/providerName/debugToken: %@/%@/%@", firebaseApp.name, providerName, + (debugToken == nil ? @"null" : @"(not shown)")); + [[RNFBAppCheckModule sharedInstance].providerFactory configure:firebaseApp + providerName:providerName + debugToken:debugToken]; + resolve([NSNull null]); +} + RCT_EXPORT_METHOD(setTokenAutoRefreshEnabled : (FIRApp *)firebaseApp : (BOOL)isTokenAutoRefreshEnabled) { + DLog(@"app/isTokenAutoRefreshEnabled: %@/%d", firebaseApp.name, isTokenAutoRefreshEnabled); FIRAppCheck *appCheck = [FIRAppCheck appCheckWithApp:firebaseApp]; appCheck.isTokenAutoRefreshEnabled = isTokenAutoRefreshEnabled; } @@ -69,6 +94,7 @@ - (dispatch_queue_t)methodQueue { : (RCTPromiseRejectBlock)reject) { FIRAppCheck *appCheck = [FIRAppCheck appCheckWithApp:firebaseApp]; BOOL isTokenAutoRefreshEnabled = appCheck.isTokenAutoRefreshEnabled; + DLog(@"app/isTokenAutoRefreshEnabled: %@/%d", firebaseApp.name, isTokenAutoRefreshEnabled); resolve([NSNumber numberWithBool:isTokenAutoRefreshEnabled]); } @@ -78,41 +104,34 @@ - (dispatch_queue_t)methodQueue { : (RCTPromiseResolveBlock)resolve : (RCTPromiseRejectBlock)reject) { FIRAppCheck *appCheck = [FIRAppCheck appCheckWithApp:firebaseApp]; - [appCheck tokenForcingRefresh:forceRefresh - completion:^(FIRAppCheckToken *_Nullable token, NSError *_Nullable error) { - if (error != nil) { - // Handle any errors if the token was not retrieved. - DLog(@"Unable to retrieve App Check token: %@", error); - [RNFBSharedUtils - rejectPromiseWithUserInfo:reject - userInfo:(NSMutableDictionary *)@{ - @"code" : @"token-error", - @"message" : [error localizedDescription], - }]; - return; - } - if (token == nil) { - DLog(@"Unable to retrieve App Check token."); - [RNFBSharedUtils rejectPromiseWithUserInfo:reject - userInfo:(NSMutableDictionary *)@{ - @"code" : @"token-null", - @"message" : @"no token fetched", - }]; - return; - } - - NSMutableDictionary *tokenResultDictionary = [NSMutableDictionary new]; - tokenResultDictionary[@"token"] = token.token; - resolve(tokenResultDictionary); - }]; + DLog(@"appName %@", firebaseApp.name); + [appCheck + tokenForcingRefresh:forceRefresh + completion:^(FIRAppCheckToken *_Nullable token, NSError *_Nullable error) { + if (error != nil) { + // Handle any errors if the token was not retrieved. + DLog(@"RNFBAppCheck - getToken - Unable to retrieve App Check token: %@", error); + [RNFBSharedUtils rejectPromiseWithUserInfo:reject + userInfo:(NSMutableDictionary *)@{ + @"code" : @"token-error", + @"message" : [error localizedDescription], + }]; + return; + } + if (token == nil) { + DLog(@"RNFBAppCheck - getToken - Unable to retrieve App Check token."); + [RNFBSharedUtils rejectPromiseWithUserInfo:reject + userInfo:(NSMutableDictionary *)@{ + @"code" : @"token-null", + @"message" : @"no token fetched", + }]; + return; + } + + NSMutableDictionary *tokenResultDictionary = [NSMutableDictionary new]; + tokenResultDictionary[@"token"] = token.token; + resolve(tokenResultDictionary); + }]; } -// TODO -// - set up DeviceCheckProvider and Debug provider -// FIRAppCheckDebugProviderFactory *providerFactory = -// [[FIRAppCheckDebugProviderFactory alloc] init]; -// [FIRAppCheck setAppCheckProviderFactory:providerFactory]; - -// Write a custom provider factory, and allow the AppAttest provider - @end diff --git a/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckProvider.h b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckProvider.h new file mode 100644 index 0000000000..e1877a1d9d --- /dev/null +++ b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckProvider.h @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2023-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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 +#import + +@interface RNFBAppCheckProvider : NSObject + +@property FIRApp *app; + +@property id delegateProvider; + +- (void)configure:(FIRApp *)app + providerName:(NSString *)providerName + debugToken:(NSString *)debugToken; + +- (id)initWithApp:(FIRApp *)app; + +@end diff --git a/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckProvider.m b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckProvider.m new file mode 100644 index 0000000000..6415b7048e --- /dev/null +++ b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckProvider.m @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2023-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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 "RNFBAppCheckProvider.h" +#import "RNFBApp/RNFBSharedUtils.h" + +@implementation RNFBAppCheckProvider + +- (id)initWithApp:app { + DLog(@"RNFBAppCheck init with app %@", [app name]); + self = [super init]; + if (self) { + self.app = app; + } + return self; +} + +- (void)configure:(FIRApp *)app + providerName:(NSString *)providerName + debugToken:(NSString *)debugToken { + DLog(@"appName/providerName/debugToken: %@/%@/%@", app.name, providerName, + (debugToken == nil ? @"null" : @"(not shown)")); + + DLog(@"appName %@", app.name); + + // - determine if debugToken is provided via nullable arg + if ([providerName isEqualToString:@"debug"]) { + // TODO: Currently not handling debugToken argument, relying on existing environment + // variable configuration style. + // - maybe directly setting an environment variable could work? + // https://stackoverflow.com/questions/27139589/whats-the-idiomatic-way-of-setting-an-environment-variable-in-objective-c-coco + // - ...otherwise if env var does not work + // - subclass style: RNFBAppCheckDebugProvider, and we should print local token + // - if a debugToken parameter was supplied, set + // RNFBAppCheckDebugProvider.configuredDebugToken + // - print local token + // https://github.com/firebase/firebase-ios-sdk/blob/c7e95996ff/FirebaseAppCheck/Sources/DebugProvider/FIRAppCheckDebugProviderFactory.m + // - print if current token in provided by configuration, by environment variable, or local + // token? + + self.delegateProvider = [[FIRAppCheckDebugProvider new] initWithApp:app]; + } + + if ([providerName isEqualToString:@"deviceCheck"]) { + self.delegateProvider = [[FIRDeviceCheckProvider new] initWithApp:app]; + } + + if ([providerName isEqualToString:@"appAttest"]) { + if (@available(iOS 14.0, macCatalyst 14.0, tvOS 15.0, watchOS 9.0, *)) { + self.delegateProvider = [[FIRAppAttestProvider alloc] initWithApp:app]; + } else { + // This is not a valid configuration. + DLog(@"AppAttest unavailable: it requires iOS14+, macCatalyst14+ or tvOS15+. Installing " + @"debug provider to guarantee invalid tokens in this invalid configuration."); + self.delegateProvider = [[FIRAppCheckDebugProvider new] initWithApp:app]; + } + } + + if ([providerName isEqualToString:@"appAttestWithDeviceCheckFallback"]) { + if (@available(iOS 14.0, *)) { + self.delegateProvider = [[FIRAppAttestProvider alloc] initWithApp:app]; + } else { + self.delegateProvider = [[FIRDeviceCheckProvider alloc] initWithApp:app]; + } + } +} + +- (void)getTokenWithCompletion:(nonnull void (^)(FIRAppCheckToken *_Nullable, + NSError *_Nullable))handler { + DLog(@"proxying to delegateProvider..."); + [self.delegateProvider getTokenWithCompletion:handler]; +} + +@end diff --git a/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckProviderFactory.h b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckProviderFactory.h new file mode 100644 index 0000000000..0fae9ab05c --- /dev/null +++ b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckProviderFactory.h @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2023-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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 + +@interface RNFBAppCheckProviderFactory : NSObject + +@property NSMutableDictionary *_Nullable providers; + +- (void)configure:(FIRApp *_Nonnull)app + providerName:(NSString *_Nonnull)providerName + debugToken:(NSString *_Nullable)debugToken; + +@end diff --git a/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckProviderFactory.m b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckProviderFactory.m new file mode 100644 index 0000000000..5fcf9a4451 --- /dev/null +++ b/packages/app-check/ios/RNFBAppCheck/RNFBAppCheckProviderFactory.m @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2023-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library 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 "RNFBAppCheckProviderFactory.h" +#import +#import +#import "RNFBApp/RNFBSharedUtils.h" +#import "RNFBAppCheckProvider.h" + +@implementation RNFBAppCheckProviderFactory + +- (nullable id)createProviderWithApp:(FIRApp *)app { + DLog(@"appName %@", app.name); + + // The SDK may try to call this before we have been configured, + // so we will configure ourselves and set the provider up as a default to start + // pre-configure + if (self.providers == nil) { + DLog(@"providers dictionary initializing for app %@", app.name); + self.providers = [NSMutableDictionary new]; + } + + if (self.providers[app.name] == nil) { + DLog(@"provider initializing (with default to debug) for app %@", app.name); + self.providers[app.name] = [RNFBAppCheckProvider new]; + RNFBAppCheckProvider *provider = self.providers[app.name]; + [provider configure:app providerName:@"debug" debugToken:nil]; + } + + return self.providers[app.name]; +} + +- (void)configure:(FIRApp *)app + providerName:(NSString *)providerName + debugToken:(NSString *)debugToken { + DLog(@"appName/providerName/debugToken: %@/%@/%@", app.name, providerName, + (debugToken == nil ? @"null" : @"(not shown)")); + if (self.providers == nil) { + self.providers = [NSMutableDictionary new]; + } + + if (self.providers[app.name] == nil) { + self.providers[app.name] = [RNFBAppCheckProvider new]; + } + + RNFBAppCheckProvider *provider = self.providers[app.name]; + [provider configure:app providerName:providerName debugToken:debugToken]; +} + +@end diff --git a/packages/app-check/lib/ReactNativeFirebaseAppCheckProvider.js b/packages/app-check/lib/ReactNativeFirebaseAppCheckProvider.js new file mode 100644 index 0000000000..b328d8aeba --- /dev/null +++ b/packages/app-check/lib/ReactNativeFirebaseAppCheckProvider.js @@ -0,0 +1,9 @@ +export default class ReactNativeFirebaseAppCheckProvider { + providerOptions; + + constructor() {} + + configure(options) { + this.providerOptions = options; + } +} diff --git a/packages/app-check/lib/index.d.ts b/packages/app-check/lib/index.d.ts index 7937a1164d..b26ade0cf4 100644 --- a/packages/app-check/lib/index.d.ts +++ b/packages/app-check/lib/index.d.ts @@ -68,6 +68,81 @@ export namespace FirebaseAppCheckTypes { 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 + */ + provider: CustomProvider | ReCaptchaV3Provider | ReCaptchaEnterpriseProvider; + + /** + * If true, enables SDK to automatically + * refresh AppCheck token as needed. If undefined, the value will default + * to the value of `app.automaticDataCollectionEnabled`. That property + * defaults to false and can be set in the app config. + */ + isTokenAutoRefreshEnabled?: boolean; + } + + export interface ReactNativeFirebaseAppCheckProviderOptions { + /** + * debug token to use, if any. Defaults to undefined, pre-configure tokens in firebase web console if needed + */ + debugToken?: string; + } + + export interface ReactNativeFirebaseAppCheckProviderWebOptions + extends ReactNativeFirebaseAppCheckProviderOptions { + /** + * The web provider to use, either `reCaptchaV3` or `reCaptchaEnterprise`, defaults to `reCaptchaV3` + */ + provider?: 'debug' | 'reCaptchaV3' | 'reCaptchaEnterprise'; + + /** + * siteKey for use in web queries, defaults to `none` + */ + siteKey?: string; + } + + export interface ReactNativeFirebaseAppCheckProviderAppleOptions + extends ReactNativeFirebaseAppCheckProviderOptions { + /** + * The apple provider to use, either `deviceCheck` or `appAttest`, or `appAttestWithDeviceCheckFallback`, + * defaults to `DeviceCheck`. `appAttest` requires iOS 14+ or will fail, `appAttestWithDeviceCheckFallback` + * will use `appAttest` for iOS14+ and fallback to `deviceCheck` on devices with ios13 and lower + */ + provider?: 'debug' | 'deviceCheck' | 'appAttest' | 'appAttestWithDeviceCheckFallback'; + } + + export interface ReactNativeFirebaseAppCheckProviderAndroidOptions + extends ReactNativeFirebaseAppCheckProviderOptions { + /** + * The android provider to use, either `safetyNet` or `playIntegrity`. default is `playIntegrity`, `safetyNet` is deprecated. + */ + provider?: 'debug' | 'safetyNet' | 'playIntegrity'; + } + + export interface ReactNativeFirebaseAppCheckProvider extends AppCheckProvider { + /** + * Specify how the app check provider should be configured. The new configuration is + * in effect when this call returns. You must call `getToken()` + * after this call to get a token using the new configuration. + * This custom provider allows for delayed configuration and re-configuration on all platforms + * so AppCheck has the same experience across all platforms, with the only difference being the native + * providers you choose to use on each platform. + */ + configure( + web?: ReactNativeFirebaseAppCheckProviderWebOptions, + android?: ReactNativeFirebaseAppCheckProviderAndroidOptions, + apple?: ReactNativeFirebaseAppCheckProviderAppleOptions, + isTokenAutoRefreshEnabled?: boolean, + ): Promise; + } + /** * Result returned by `getToken()`. */ @@ -118,6 +193,16 @@ export namespace FirebaseAppCheckTypes { * */ export class Module extends FirebaseModule { + /** + * 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 + */ + initializeAppCheck(options: AppCheckOptions): Promise; + /** * Activate App Check * On iOS App Check is activated with DeviceCheck provider simply by including the module, using the token auto refresh default or @@ -127,6 +212,7 @@ export namespace FirebaseAppCheckTypes { * On iOS if you want to set a specific AppCheckProviderFactory (for instance to FIRAppCheckDebugProviderFactory or * FIRAppAttestProvider) you must manually do that in your AppDelegate.m prior to calling [FIRApp configure] * + * @deprecated use initializeAppCheck to gain access to all platform providers and firebase-js-sdk v9 compatibility * @param siteKeyOrProvider - This is ignored, Android uses DebugProviderFactory if the app is debuggable (https://firebase.google.com/docs/app-check/android/debug-provider) * Android uses SafetyNetProviderFactory for release builds. * iOS uses DeviceCheckProviderFactory by default unless altered in AppDelegate.m manually diff --git a/packages/app-check/lib/index.js b/packages/app-check/lib/index.js index 210b9ffba2..9c2bdfba1c 100644 --- a/packages/app-check/lib/index.js +++ b/packages/app-check/lib/index.js @@ -21,6 +21,8 @@ import { FirebaseModule, getFirebaseRoot, } from '@react-native-firebase/app/lib/internal'; +import { Platform } from 'react-native'; +import ReactNativeFirebaseAppCheckProvider from './ReactNativeFirebaseAppCheckProvider'; import version from './version'; @@ -31,27 +33,70 @@ const namespace = 'appCheck'; const nativeModuleName = 'RNFBAppCheckModule'; class FirebaseAppCheckModule extends FirebaseModule { - activate(siteKeyOrProvider, isTokenAutoRefreshEnabled) { - if (!isString(siteKeyOrProvider)) { - throw new Error('siteKeyOrProvider must be a string value to match firebase-js-sdk API'); - } + getIsTokenRefreshEnabledDefault() { + // no default to start + isTokenAutoRefreshEnabled = undefined; - // If the caller did not specify token refresh, attempt to use app-check specific setting: - if (!isBoolean(isTokenAutoRefreshEnabled)) { - isTokenAutoRefreshEnabled = this.firebaseJson.app_check_token_auto_refresh; + return isTokenAutoRefreshEnabled; + } + + newReactNativeFirebaseAppCheckProvider() { + return new ReactNativeFirebaseAppCheckProvider(); + } + + initializeAppCheck(options) { + // determine token refresh setting, if not specified + if (!isBoolean(options.isTokenAutoRefreshEnabled)) { + options.isTokenAutoRefreshEnabled = this.firebaseJson.app_check_token_auto_refresh; } // If that was not defined, attempt to use app-wide data collection setting per docs: - if (!isBoolean(isTokenAutoRefreshEnabled)) { - isTokenAutoRefreshEnabled = this.firebaseJson.app_data_collection_default_enabled; + if (!isBoolean(options.isTokenAutoRefreshEnabled)) { + options.isTokenAutoRefreshEnabled = this.firebaseJson.app_data_collection_default_enabled; } // If that also was not defined, the default is documented as true. - if (!isBoolean(isTokenAutoRefreshEnabled)) { - isTokenAutoRefreshEnabled = true; + if (!isBoolean(options.isTokenAutoRefreshEnabled)) { + options.isTokenAutoRefreshEnabled = true; + } + this.native.setTokenAutoRefreshEnabled(options.isTokenAutoRefreshEnabled); + + if (Platform.OS === 'android') { + return this.native.configureProvider( + options.provider.providerOptions.android.provider, + options.provider.providerOptions.android.debugToken, + ); + } + if (Platform.OS === 'ios' || Platform.OS === 'macos') { + return this.native.configureProvider( + options.provider.providerOptions.apple.provider, + options.provider.providerOptions.apple.debugToken, + ); + } + throw new Error('Unsupported platform: ' + Platform.OS); + } + + activate(siteKeyOrProvider, isTokenAutoRefreshEnabled) { + if (!isString(siteKeyOrProvider)) { + throw new Error('siteKeyOrProvider must be a string value to match firebase-js-sdk API'); } - return this.native.activate(siteKeyOrProvider, isTokenAutoRefreshEnabled); + // We wrap our new flexible interface, with compatible defaults + rnfbProvider = new ReactNativeFirebaseAppCheckProvider(); + rnfbProvider.configure({ + android: { + provider: 'safetyNet', + }, + apple: { + provider: 'deviceCheck', + }, + web: { + provider: 'reCaptchaV3', + siteKey: 'none', + }, + }); + + return this.initializeAppCheck({ provider: rnfbProvider, isTokenAutoRefreshEnabled }); } setTokenAutoRefreshEnabled(isTokenAutoRefreshEnabled) { diff --git a/tests/ios/testing/AppDelegate.mm b/tests/ios/testing/AppDelegate.mm index 7ee78e2767..2d80aa7cf5 100644 --- a/tests/ios/testing/AppDelegate.mm +++ b/tests/ios/testing/AppDelegate.mm @@ -18,6 +18,7 @@ #import "AppDelegate.h" #import "RNFBMessagingModule.h" +#import "RNFBAppCheckModule.h" #import #import @@ -45,12 +46,17 @@ @interface AppDelegate () @implementation AppDelegate - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + // Initialize RNFBAppCheckModule, it sets the custom RNFBAppCheckProviderFactory + // which lets us configure any of the available native platform providers, + // and reconfigure if needed, dynamically after `[FIRApp configure]` just like the other platforms. + [RNFBAppCheckModule sharedInstance]; + // Install the AppCheck debug provider so we may get tokens on iOS Simulators for testing. // See https://firebase.google.com/docs/app-check/ios/debug-provider for instructions on configuring a debug token // This *must* be done before the `[FIRApp configure]` line, so it must be done in AppDelegate for any app // that wants to enforce AppCheck restrictions on their backend while also doing testing on iOS Simulator. - FIRAppCheckDebugProviderFactory *providerFactory = [[FIRAppCheckDebugProviderFactory alloc] init]; - [FIRAppCheck setAppCheckProviderFactory:providerFactory]; +// FIRAppCheckDebugProviderFactory *providerFactory = [[FIRAppCheckDebugProviderFactory alloc] init]; +// [FIRAppCheck setAppCheckProviderFactory:providerFactory]; if ([FIRApp defaultApp] == nil) { [FIRApp configure];