diff --git a/.github/workflows/infra.samples.client_app.yml b/.github/workflows/infra.samples.client_app.yml index d453fd1eada..b99070755c7 100644 --- a/.github/workflows/infra.samples.client_app.yml +++ b/.github/workflows/infra.samples.client_app.yml @@ -21,6 +21,7 @@ on: env: FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT: 1 + FIREBASE_APP_CHECK_BRANCH: main concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} @@ -44,6 +45,7 @@ jobs: method: xcodebuild os: ${{ matrix.os }} xcode: ${{ matrix.xcode }} + env_vars: '{"FIREBASE_APP_CHECK_BRANCH": "main"}' client-app-spm-source-firestore: strategy: @@ -63,6 +65,7 @@ jobs: os: ${{ matrix.os }} xcode: ${{ matrix.xcode }} setup_command: echo "FIREBASE_SOURCE_FIRESTORE=1" >> $GITHUB_ENV + env_vars: '{"FIREBASE_APP_CHECK_BRANCH": "main"}' client-app-cocoapods: strategy: @@ -80,3 +83,4 @@ jobs: os: ${{ matrix.os }} xcode: ${{ matrix.xcode }} setup_command: scripts/install_prereqs.sh ClientApp iOS xcodebuild + env_vars: '{"FIREBASE_APP_CHECK_BRANCH": "main"}' diff --git a/.github/workflows/infra.spm_global.yml b/.github/workflows/infra.spm_global.yml index d54294d3286..fe6ad359a7a 100644 --- a/.github/workflows/infra.spm_global.yml +++ b/.github/workflows/infra.spm_global.yml @@ -29,6 +29,7 @@ jobs: spm-package-resolved: env: FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT: 1 + FIREBASE_APP_CHECK_BRANCH: main runs-on: macos-26 outputs: cache_key: ${{ steps.generate_cache_key.outputs.cache_key }} @@ -55,6 +56,9 @@ jobs: # Don't run on private repo unless it is a PR. if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request' needs: [spm-package-resolved] + env: + FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT: 1 + FIREBASE_APP_CHECK_BRANCH: main strategy: matrix: include: @@ -105,6 +109,8 @@ jobs: # Don't run on private repo unless it is a PR. if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request' needs: [spm-package-resolved] + env: + FIREBASE_APP_CHECK_BRANCH: main strategy: matrix: include: @@ -144,6 +150,8 @@ jobs: # Don't run on private repo unless it is a PR. if: (github.repository == 'Firebase/firebase-ios-sdk' && github.event_name == 'schedule') || github.event_name == 'pull_request' needs: [spm-package-resolved] + env: + FIREBASE_APP_CHECK_BRANCH: main strategy: matrix: # Full set of Firebase-Package tests only run on iOS. Run subset on other platforms. diff --git a/.github/workflows/sdk.appcheck.yml b/.github/workflows/sdk.appcheck.yml index c47ff92a2c0..f93e9d17d25 100644 --- a/.github/workflows/sdk.appcheck.yml +++ b/.github/workflows/sdk.appcheck.yml @@ -30,6 +30,7 @@ jobs: uses: ./.github/workflows/_spm.yml with: target: ${{ matrix.target }} + env_vars: '{"FIREBASE_APP_CHECK_BRANCH": "main"}' catalyst: uses: ./.github/workflows/_catalyst.yml @@ -57,6 +58,7 @@ jobs: method: spm sanitizers: ${{ matrix.diagnostic }} setup_command: scripts/setup_spm_tests.sh + env_vars: '{"FIREBASE_APP_CHECK_BRANCH": "main"}' app_check-cron-only: needs: pod_lib_lint diff --git a/.github/workflows/sdk.core.yml b/.github/workflows/sdk.core.yml index 895f01d8a1f..8b26f81886d 100644 --- a/.github/workflows/sdk.core.yml +++ b/.github/workflows/sdk.core.yml @@ -28,6 +28,7 @@ jobs: uses: ./.github/workflows/_spm.yml with: target: CoreUnit + env_vars: '{"FIREBASE_APP_CHECK_BRANCH": "main"}' catalyst: uses: ./.github/workflows/_catalyst.yml diff --git a/.github/workflows/sdk.firestore.yml b/.github/workflows/sdk.firestore.yml index fa44dd6e3eb..f62bc0b0137 100644 --- a/.github/workflows/sdk.firestore.yml +++ b/.github/workflows/sdk.firestore.yml @@ -540,6 +540,7 @@ jobs: env: FIREBASECI_USE_LATEST_GOOGLEAPPMEASUREMENT: 1 FIREBASE_SOURCE_FIRESTORE: 1 + FIREBASE_APP_CHECK_BRANCH: main outputs: cache_key: ${{ steps.generate_cache_key.outputs.cache_key }} steps: @@ -591,6 +592,7 @@ jobs: runs-on: ${{ matrix.os }} env: FIREBASE_SOURCE_FIRESTORE: 1 + FIREBASE_APP_CHECK_BRANCH: main steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Xcode @@ -614,6 +616,7 @@ jobs: with: target: FirebaseFirestoreTests platforms: iOS + env_vars: '{"FIREBASE_APP_CHECK_BRANCH": "main"}' spm-source-cron: # Don't run on private repo. @@ -624,6 +627,7 @@ jobs: target: [tvOS, macOS, catalyst] env: FIREBASE_SOURCE_FIRESTORE: 1 + FIREBASE_APP_CHECK_BRANCH: main steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Xcode @@ -648,6 +652,8 @@ jobs: strategy: matrix: target: [tvOS, macOS, catalyst] + env: + FIREBASE_APP_CHECK_BRANCH: main steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Xcode diff --git a/FirebaseAppCheck.podspec b/FirebaseAppCheck.podspec index 5877abc1846..c3fef7a4391 100644 --- a/FirebaseAppCheck.podspec +++ b/FirebaseAppCheck.podspec @@ -44,7 +44,7 @@ Pod::Spec.new do |s| s.osx.weak_framework = 'DeviceCheck' s.tvos.weak_framework = 'DeviceCheck' - s.dependency 'AppCheckCore', '~> 11.0' + s.dependency 'AppCheckCore', '~> 11.3' s.dependency 'FirebaseAppCheckInterop', '~> 12.15.0' s.dependency 'FirebaseCore', '~> 12.15.0' s.dependency 'GoogleUtilities/Environment', '~> 8.1' @@ -93,7 +93,11 @@ Pod::Spec.new do |s| } swift_unit_tests.source_files = [ base_dir + 'Tests/Unit/Swift/**/*.swift', + 'SharedTestUtilities/ExceptionCatcher.[mh]' ] + swift_unit_tests.pod_target_xcconfig = { + 'SWIFT_OBJC_BRIDGING_HEADER' => '$(PODS_TARGET_SRCROOT)/FirebaseAppCheck/Tests/Unit/Swift/FirebaseAppCheck-unit-Bridging-Header.h' + } swift_unit_tests.dependency 'FirebaseCoreExtension', '~> 12.15.0' end diff --git a/FirebaseAppCheck/Apps/FIRAppCheckTestApp/E2E_TESTING.md b/FirebaseAppCheck/Apps/FIRAppCheckTestApp/E2E_TESTING.md new file mode 100644 index 00000000000..6d7f7fc272e --- /dev/null +++ b/FirebaseAppCheck/Apps/FIRAppCheckTestApp/E2E_TESTING.md @@ -0,0 +1,182 @@ +# E2E Testing with FIRAppCheckTestApp + +This document provides information on how to configure and run End-to-End (E2E) +tests for App Check providers using this sample app. + +## Configurability + +The app's behavior can be configured using environment variables passed during +test execution. + +### Environment Variables + +Starting with Xcode 13, you can pass environment variables directly to the +test runner by prefixing them with `TEST_RUNNER_`. The prefix is stripped when +it reaches the test process. + +- **`TEST_RUNNER_RECAPTCHA_SITE_KEY`**: The reCAPTCHA site key used + by the `AppCheckRecaptchaProvider`. + - **Access in Code**: Read via + `ProcessInfo.processInfo.environment["RECAPTCHA_SITE_KEY"]`. +- **`TEST_RUNNER_APP_CHECK_PROVIDER`**: Specifies which App Check provider + factory to use. + - **Supported Values**: `recaptcha` (default), `debug`. + - **Access in Code**: Read via + `ProcessInfo.processInfo.environment["APP_CHECK_PROVIDER"]`. + +### Manual Override + +For local debugging and manual testing, you can override the environment +variables by setting `manualProviderOverride` in `AppDelegate.swift`: + +```swift +let manualProviderOverride: String? = "debug" +``` + +## Running Tests + +The commands below should be run from the **repository root**. + +### Prerequisites +- Ensure you have a local checkout of the `app-check` repository if you are + developing it locally. Set `FIREBASE_APP_CHECK_LOCAL_PATH` to point to it. + +### Sample Commands + +#### Run tests with reCAPTCHA provider + +```bash +export TEST_RUNNER_RECAPTCHA_SITE_KEY="your_site_key_here" +export TEST_RUNNER_APP_CHECK_PROVIDER="recaptcha" +export FIREBASE_APP_CHECK_LOCAL_PATH="/path/to/your/local/app-check" +SIM_ID=$(xcrun simctl list devices available | grep "iPhone" | grep -E -o '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}' | head -n 1) + +xcodebuild test \ + -workspace FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcworkspace \ + -scheme FIRAppCheckTestApp \ + -destination "platform=iOS Simulator,id=$SIM_ID" +``` + +#### Run tests with Debug provider + +```bash +export TEST_RUNNER_APP_CHECK_PROVIDER="debug" +export FIREBASE_APP_CHECK_LOCAL_PATH="/path/to/your/local/app-check" +SIM_ID=$(xcrun simctl list devices available | grep "iPhone" | grep -E -o '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}' | head -n 1) + +xcodebuild test \ + -workspace FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcworkspace \ + -scheme FIRAppCheckTestApp \ + -destination "platform=iOS Simulator,id=$SIM_ID" +``` +*Note: The Debug provider might require you to register the generated debug token in the Firebase Console for the tests to pass if they interact with live services.* + +### Running and Testing in Xcode + +If you prefer to use the Xcode UI instead of `xcodebuild`, follow these steps +to configure the environment: + +#### 1. Resolve Local Dependency +If you are using a local checkout of the `app-check` repository, Xcode must be +launched from the terminal with the `FIREBASE_APP_CHECK_LOCAL_PATH` environment +variable set so that Swift Package Manager can resolve it correctly. + +Run the following command from the repository root: +```bash +open --env FIREBASE_APP_CHECK_LOCAL_PATH=/path/to/your/local/app-check FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcworkspace +``` + +#### 2. Configure Provider and Site Key +You have two options to configure the provider when running or testing in Xcode: + +**Option A: Via Manual Override in Code (Easiest for Running the App)** +If you just want to quickly run the app with a specific provider without +changing scheme settings: +1. Open `AppDelegate.swift`. +2. Locate `manualProviderOverride` in `application(_:didFinishLaunchingWithOptions:)`. +3. Set it to your desired provider: + ```swift + let manualProviderOverride: String? = "recaptcha" + ``` + *Note: Remember to revert this change before committing.* + +**Option B: Via Xcode Scheme (Recommended for Tests)** +This avoids modifying code and works for both running and testing. +1. In Xcode, go to **Product > Scheme > Edit Scheme...** (or press `⌘<`). +2. Select the **Run** or **Test** action in the left sidebar, depending on + what you are doing. +3. Go to the **Arguments** tab. +4. In the **Environment Variables** section, add: + * `APP_CHECK_PROVIDER`: Set to `recaptcha` or `debug`. + * `RECAPTCHA_SITE_KEY`: Set to your reCAPTCHA site key (required for + `recaptcha`). + +### Running and Testing with CocoaPods + +If you prefer to use the CocoaPods workflow instead of SPM: + +#### 0. Clean Up State (Optional but Recommended) +If you are switching from the SPM workflow or encounter issues, it is +recommended to clean up the CocoaPods state first: +```bash +pod deintegrate FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcodeproj +rm -rf FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcworkspace +rm -f FirebaseAppCheck/Apps/FIRAppCheckTestApp/Podfile.lock +``` + +#### 1. Install Dependencies +To ensure a clean update and avoid conflicts with local development paths or +stale state, it is recommended to remove the existing `Pods` directory and +`Podfile.lock` before updating. + +Run the following command from the repository root: +```bash +rm -rf FirebaseAppCheck/Apps/FIRAppCheckTestApp/Pods +rm -f FirebaseAppCheck/Apps/FIRAppCheckTestApp/Podfile.lock +FIREBASE_APP_CHECK_LOCAL_PATH="/path/to/your/local/app-check" pod update --repo-update --project-directory=FirebaseAppCheck/Apps/FIRAppCheckTestApp/ +``` + +#### 2. Open Workspace +Open the generated CocoaPods workspace instead of the project file: +```bash +open FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcworkspace +``` + +#### 3. Remove SPM Dependencies (If needed) +By default, the project file is configured for SPM. To avoid duplicate symbol +issues or conflicting resolutions when using CocoaPods: +1. In Xcode, select the project in the file navigator. +2. Select the project file at the top (not a target). +3. Go to the **Package Dependencies** tab. +4. Remove the `firebase-ios-sdk` or `app-check` package references if they + appear there. +5. Also, select the `FIRAppCheckTestApp` target, go to the **General** tab, + and scroll down to **Frameworks, Libraries, and Embedded Content**. +6. Remove any SPM-resolved frameworks from this list. + +#### 4. Configure and Run +You can configure the provider and site key either via the Xcode Scheme or by +passing environment variables to `xcodebuild`. + +**Via Xcode Scheme:** +Follow the instructions in **[Running and Testing in Xcode](#running-and-testing-in-xcode)**. + +**Via `xcodebuild` (Command Line):** +Run the following command from the repository root, replacing the site key with +your own: +```bash +export TEST_RUNNER_RECAPTCHA_SITE_KEY="your_site_key_here" +export TEST_RUNNER_APP_CHECK_PROVIDER="recaptcha" +SIM_ID=$(xcrun simctl list devices available | grep "iPhone" | grep -E -o '[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}' | head -n 1) + +xcodebuild test \ + -workspace FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcworkspace \ + -scheme FIRAppCheckTestApp \ + -destination "platform=iOS Simulator,id=$SIM_ID" +``` +*(Note: See [Running Tests](#running-tests) for how to dynamically find a valid +simulator destination).* + +## Project Structure + +- **`FIRAppCheckTestAppTests`**: A hosted unit test target containing the test cases. It runs inside the app process to have access to the full app context. diff --git a/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcodeproj/project.pbxproj b/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcodeproj/project.pbxproj index 4e71530ef0d..858db9c0aac 100644 --- a/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcodeproj/project.pbxproj +++ b/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcodeproj/project.pbxproj @@ -7,45 +7,93 @@ objects = { /* Begin PBXBuildFile section */ + + 8F1202DD209F881D67D20BA8 /* FIRAppCheckTestAppTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DBE09F2A53CEC2B3E7600F4 /* FIRAppCheckTestAppTests.swift */; }; 9AC7C27C2541C7E500F5DD80 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC7C27B2541C7E500F5DD80 /* AppDelegate.swift */; }; 9AC7C27E2541C7E500F5DD80 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC7C27D2541C7E500F5DD80 /* SceneDelegate.swift */; }; 9AC7C2852541C7E600F5DD80 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9AC7C2842541C7E600F5DD80 /* Assets.xcassets */; }; 9AC7C2882541C7E600F5DD80 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 9AC7C2862541C7E600F5DD80 /* LaunchScreen.storyboard */; }; - EAD122EF2DB97E10004D64C9 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = EAD122EE2DB97E10004D64C9 /* GoogleService-Info.plist */; }; + CB1E711C5CFEE1D2BFB9069D /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5A8784D39A5706A47E89D31 /* Foundation.framework */; }; + EAA2C1AB2FAA966B008D663E /* RecaptchaEnterprise in Frameworks */ = {isa = PBXBuildFile; productRef = EAA2C1AA2FAA966B008D663E /* RecaptchaEnterprise */; }; + EAA2C1AD2FABCB69008D663E /* FirebaseStorage in Frameworks */ = {isa = PBXBuildFile; productRef = EAA2C1AC2FABCB69008D663E /* FirebaseStorage */; }; + EABC1CB82FA9257600C35F73 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = EABC1CB72FA9257600C35F73 /* GoogleService-Info.plist */; }; EAD122F12DB98BD0004D64C9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD122F02DB98BCC004D64C9 /* ContentView.swift */; }; EAD122F32DB9920D004D64C9 /* AppCheckTestApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAD122F22DB99206004D64C9 /* AppCheckTestApp.swift */; }; EAD122F62DB9940E004D64C9 /* FirebaseAppCheck in Frameworks */ = {isa = PBXBuildFile; productRef = EAD122F52DB9940E004D64C9 /* FirebaseAppCheck */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 6394C6DF77B30A8569910AC4 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9AC7C2702541C7E500F5DD80 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 9AC7C2772541C7E500F5DD80; + remoteInfo = FIRAppCheckTestApp; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 2F8964B09A6746481927ACD1 /* FIRAppCheckTestAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FIRAppCheckTestAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9AC7C2782541C7E500F5DD80 /* FIRAppCheckTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FIRAppCheckTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 9AC7C27B2541C7E500F5DD80 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 9AC7C27D2541C7E500F5DD80 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 9AC7C2842541C7E600F5DD80 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 9AC7C2872541C7E600F5DD80 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 9AC7C2892541C7E600F5DD80 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - EAD122EE2DB97E10004D64C9 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; + 9DBE09F2A53CEC2B3E7600F4 /* FIRAppCheckTestAppTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FIRAppCheckTestAppTests.swift; path = FIRAppCheckTestAppTests/FIRAppCheckTestAppTests.swift; sourceTree = ""; }; + A5A8784D39A5706A47E89D31 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; }; + + EABC1CB72FA9257600C35F73 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; EAD122F02DB98BCC004D64C9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; EAD122F22DB99206004D64C9 /* AppCheckTestApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppCheckTestApp.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 2D060508510F1138A9FC12DE /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + CB1E711C5CFEE1D2BFB9069D /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9AC7C2752541C7E500F5DD80 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( EAD122F62DB9940E004D64C9 /* FirebaseAppCheck in Frameworks */, + EAA2C1AB2FAA966B008D663E /* RecaptchaEnterprise in Frameworks */, + EAA2C1AD2FABCB69008D663E /* FirebaseStorage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 4ABD2FB516E7A91E6E1527EA /* iOS */ = { + isa = PBXGroup; + children = ( + A5A8784D39A5706A47E89D31 /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; + 671F07FAFFBA7B1FCBCABDBD /* Frameworks */ = { + isa = PBXGroup; + children = ( + 4ABD2FB516E7A91E6E1527EA /* iOS */, + ); + name = Frameworks; + sourceTree = ""; + }; 9AC7C26F2541C7E500F5DD80 = { isa = PBXGroup; children = ( 9AC7C27A2541C7E500F5DD80 /* FIRAppCheckTestApp */, 9AC7C2792541C7E500F5DD80 /* Products */, + 671F07FAFFBA7B1FCBCABDBD /* Frameworks */, + 9DBE09F2A53CEC2B3E7600F4 /* FIRAppCheckTestAppTests.swift */, + ); sourceTree = ""; }; @@ -53,6 +101,7 @@ isa = PBXGroup; children = ( 9AC7C2782541C7E500F5DD80 /* FIRAppCheckTestApp.app */, + 2F8964B09A6746481927ACD1 /* FIRAppCheckTestAppTests.xctest */, ); name = Products; sourceTree = ""; @@ -60,7 +109,7 @@ 9AC7C27A2541C7E500F5DD80 /* FIRAppCheckTestApp */ = { isa = PBXGroup; children = ( - EAD122EE2DB97E10004D64C9 /* GoogleService-Info.plist */, + EABC1CB72FA9257600C35F73 /* GoogleService-Info.plist */, 9AC7C27B2541C7E500F5DD80 /* AppDelegate.swift */, 9AC7C27D2541C7E500F5DD80 /* SceneDelegate.swift */, EAD122F02DB98BCC004D64C9 /* ContentView.swift */, @@ -79,6 +128,7 @@ isa = PBXNativeTarget; buildConfigurationList = 9AC7C2A22541C7E600F5DD80 /* Build configuration list for PBXNativeTarget "FIRAppCheckTestApp" */; buildPhases = ( + 9AC7C2742541C7E500F5DD80 /* Sources */, 9AC7C2752541C7E500F5DD80 /* Frameworks */, 9AC7C2762541C7E500F5DD80 /* Resources */, @@ -92,6 +142,24 @@ productReference = 9AC7C2782541C7E500F5DD80 /* FIRAppCheckTestApp.app */; productType = "com.apple.product-type.application"; }; + DD8474378DDE4504369D6D2A /* FIRAppCheckTestAppTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 3829FC5F4FEBE88FE48726F9 /* Build configuration list for PBXNativeTarget "FIRAppCheckTestAppTests" */; + buildPhases = ( + DF4B7F4A1910F5A1E4CA3F06 /* Sources */, + 2D060508510F1138A9FC12DE /* Frameworks */, + FD0227B5A3BEC9A7E811417E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 06C131FB9DE2B06EDC5A605D /* PBXTargetDependency */, + ); + name = FIRAppCheckTestAppTests; + productName = FIRAppCheckTestAppTests; + productReference = 2F8964B09A6746481927ACD1 /* FIRAppCheckTestAppTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -116,13 +184,15 @@ ); mainGroup = 9AC7C26F2541C7E500F5DD80; packageReferences = ( - EAD122F42DB9940E004D64C9 /* XCLocalSwiftPackageReference "../../../../firebase-ios-sdk" */, + EAD122F42DB9940E004D64C9 /* XCLocalSwiftPackageReference "firebase-ios-sdk" */, + EAA2C1A92FAA966B008D663E /* XCRemoteSwiftPackageReference "recaptcha-enterprise-mobile-sdk" */, ); productRefGroup = 9AC7C2792541C7E500F5DD80 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 9AC7C2772541C7E500F5DD80 /* FIRAppCheckTestApp */, + DD8474378DDE4504369D6D2A /* FIRAppCheckTestAppTests */, ); }; /* End PBXProject section */ @@ -133,13 +203,25 @@ buildActionMask = 2147483647; files = ( 9AC7C2882541C7E600F5DD80 /* LaunchScreen.storyboard in Resources */, - EAD122EF2DB97E10004D64C9 /* GoogleService-Info.plist in Resources */, 9AC7C2852541C7E600F5DD80 /* Assets.xcassets in Resources */, + EABC1CB82FA9257600C35F73 /* GoogleService-Info.plist in Resources */, + + ); + runOnlyForDeploymentPostprocessing = 0; + }; + FD0227B5A3BEC9A7E811417E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 9AC7C2742541C7E500F5DD80 /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -152,8 +234,25 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + DF4B7F4A1910F5A1E4CA3F06 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 8F1202DD209F881D67D20BA8 /* FIRAppCheckTestAppTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 06C131FB9DE2B06EDC5A605D /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = FIRAppCheckTestApp; + target = 9AC7C2772541C7E500F5DD80 /* FIRAppCheckTestApp */; + targetProxy = 6394C6DF77B30A8569910AC4 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 9AC7C2862541C7E600F5DD80 /* LaunchScreen.storyboard */ = { isa = PBXVariantGroup; @@ -290,6 +389,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = EQHXZ8M8AV; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EQHXZ8M8AV; INFOPLIST_FILE = FIRAppCheckTestApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -298,6 +398,7 @@ PRODUCT_BUNDLE_IDENTIFIER = com.google.firebase.appcheck.testapp.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "Firebase App Check Dev"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Firebase App Check Dev"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; @@ -311,6 +412,7 @@ CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_STYLE = Manual; DEVELOPMENT_TEAM = EQHXZ8M8AV; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EQHXZ8M8AV; INFOPLIST_FILE = FIRAppCheckTestApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -319,14 +421,71 @@ PRODUCT_BUNDLE_IDENTIFIER = com.google.firebase.appcheck.testapp.dev; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = "Firebase App Check Dev"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Firebase App Check Dev"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; + B539FF780C152B469B6CBAD9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_IDENTITY = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EQHXZ8M8AV; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = com.google.firebase.appcheck.testapp.dev; + PRODUCT_NAME = FIRAppCheckTestAppTests; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Firebase App Check Dev"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FIRAppCheckTestApp.app/FIRAppCheckTestApp"; + WRAPPER_EXTENSION = xctest; + }; + name = Debug; + }; + F6B3E8BA0B5DC042AD569583 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_IDENTITY = ""; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = EQHXZ8M8AV; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = com.google.firebase.appcheck.testapp.dev; + PRODUCT_NAME = FIRAppCheckTestAppTests; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "Firebase App Check Dev"; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/FIRAppCheckTestApp.app/FIRAppCheckTestApp"; + VALIDATE_PRODUCT = YES; + WRAPPER_EXTENSION = xctest; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 3829FC5F4FEBE88FE48726F9 /* Build configuration list for PBXNativeTarget "FIRAppCheckTestAppTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + F6B3E8BA0B5DC042AD569583 /* Release */, + B539FF780C152B469B6CBAD9 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 9AC7C2732541C7E500F5DD80 /* Build configuration list for PBXProject "FIRAppCheckTestApp" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -348,13 +507,34 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - EAD122F42DB9940E004D64C9 /* XCLocalSwiftPackageReference "../../../../firebase-ios-sdk" */ = { + EAD122F42DB9940E004D64C9 /* XCLocalSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCLocalSwiftPackageReference; relativePath = "../../../../firebase-ios-sdk"; }; /* End XCLocalSwiftPackageReference section */ +/* Begin XCRemoteSwiftPackageReference section */ + EAA2C1A92FAA966B008D663E /* XCRemoteSwiftPackageReference "recaptcha-enterprise-mobile-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/GoogleCloudPlatform/recaptcha-enterprise-mobile-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 18.9.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ + EAA2C1AA2FAA966B008D663E /* RecaptchaEnterprise */ = { + isa = XCSwiftPackageProductDependency; + package = EAA2C1A92FAA966B008D663E /* XCRemoteSwiftPackageReference "recaptcha-enterprise-mobile-sdk" */; + productName = RecaptchaEnterprise; + }; + EAA2C1AC2FABCB69008D663E /* FirebaseStorage */ = { + isa = XCSwiftPackageProductDependency; + package = EAD122F42DB9940E004D64C9 /* XCLocalSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseStorage; + }; EAD122F52DB9940E004D64C9 /* FirebaseAppCheck */ = { isa = XCSwiftPackageProductDependency; productName = FirebaseAppCheck; diff --git a/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcodeproj/xcshareddata/xcschemes/FIRAppCheckTestApp.xcscheme b/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcodeproj/xcshareddata/xcschemes/FIRAppCheckTestApp.xcscheme index 3c69bd4673b..70885fda1a9 100644 --- a/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcodeproj/xcshareddata/xcschemes/FIRAppCheckTestApp.xcscheme +++ b/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp.xcodeproj/xcshareddata/xcschemes/FIRAppCheckTestApp.xcscheme @@ -27,12 +27,13 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + diff --git a/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp/AppDelegate.swift b/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp/AppDelegate.swift index d9b7a3ca49f..73f3bb01b6b 100644 --- a/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp/AppDelegate.swift +++ b/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestApp/AppDelegate.swift @@ -16,29 +16,74 @@ import UIKit +import AppCheckCore import FirebaseAppCheck import FirebaseCore +import FirebaseStorage class AppDelegate: UIResponder, UIApplicationDelegate { + private(set) static var shared: AppDelegate? + + override init() { + super.init() + AppDelegate.shared = self + } + func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication .LaunchOptionsKey: Any]?) -> Bool { - let providerFactory = AppCheckDebugProviderFactory() - AppCheck.setAppCheckProviderFactory(providerFactory) + // Manual override for testing/debugging. + // Change this to explicitly set a provider, or leave nil to use environment variable. + let manualProviderOverride: String? = nil // e.g., "debug" or "recaptcha" - FirebaseApp.configure() + let options = setupAppCheck(overrideProvider: manualProviderOverride) - requestLimitedUseToken() + FirebaseApp.configure(options: options) - requestDeviceCheckToken() + return true + } - requestDebugToken() + private func setupAppCheck(overrideProvider: String?) -> FirebaseOptions { + // Note: If running via `xcodebuild test`, pass this with the `TEST_RUNNER_` prefix + // (e.g., `TEST_RUNNER_APP_CHECK_PROVIDER="debug"`). Xcode strips the prefix at runtime. + let providerType = overrideProvider ?? ProcessInfo.processInfo + .environment["APP_CHECK_PROVIDER"] ?? "debug" - if #available(iOS 14.0, *) { - requestAppAttestToken() + if overrideProvider == nil && ProcessInfo.processInfo.environment["APP_CHECK_PROVIDER"] == nil { + print("⚠️ Warning: APP_CHECK_PROVIDER environment variable is missing. Defaulting to 'debug'.") } - return true + print("Info: Using App Check provider: '\(providerType)'") + + guard let options = FirebaseOptions.defaultOptions() else { + fatalError( + "Failed to load default Firebase options. Ensure GoogleService-Info.plist is added to the project." + ) + } + + let providerFactory: AppCheckProviderFactory + switch providerType { + case "recaptcha": + guard let siteKey = ProcessInfo.processInfo.environment["RECAPTCHA_SITE_KEY"], + !siteKey.isEmpty else { + fatalError( + "Error: RECAPTCHA_SITE_KEY environment variable is missing or empty. E2E tests require this key." + ) + } + options.recaptchaSiteKey = siteKey + providerFactory = RecaptchaProviderFactory() + case "debug": + providerFactory = AppCheckDebugProviderFactory() + default: + print( + "Warning: Unknown APP_CHECK_PROVIDER '\(providerType)'. Falling back to Debug provider." + ) + providerFactory = AppCheckDebugProviderFactory() + } + + AppCheck.setAppCheckProviderFactory(providerFactory) + + return options } // MARK: UISceneSession Lifecycle @@ -70,12 +115,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return } - DeviceCheckProvider(app: firebaseApp)?.getToken { token, error in - if let token { - print("DeviceCheck token: \(token.token), expiration date: \(token.expirationDate)") - } - - if let error { + Task { + do { + if let provider = DeviceCheckProvider(app: firebaseApp) { + let token = try await provider.getToken() + print("DeviceCheck token: \(token.token), expiration date: \(token.expirationDate)") + } + } catch { print("DeviceCheck error: \((error as NSError).userInfo)") } } @@ -89,12 +135,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { if let debugProvider = AppCheckDebugProvider(app: firebaseApp) { print("Debug token: \(debugProvider.currentDebugToken())") - debugProvider.getToken { token, error in - if let token { + Task { + do { + let token = try await debugProvider.getToken() print("Debug FAC token: \(token.token), expiration date: \(token.expirationDate)") - } - - if let error { + } catch { print("Debug error: \(error)") } } @@ -103,19 +148,47 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // MARK: App Check API - func requestLimitedUseToken() { - AppCheck.appCheck().limitedUseToken { result, error in - if let result { - print("FAC limited-use token: \(result.token), expiration date: \(result.expirationDate)") - } + @discardableResult + func fetchAppCheckToken(forcingRefresh: Bool = false) async throws -> AppCheckToken { + let token = try await AppCheck.appCheck().token(forcingRefresh: forcingRefresh) - if let error { - print("Error: \(String(describing: error))") - } + let ttl = token.expirationDate.timeIntervalSinceNow + print("[NON-LIMITED USE] Token: \(token.token)") + print(" - Expiration date: \(token.expirationDate)") + print(" - TTL: \(Int(ttl)) seconds") + + try await readFromStorage() + + return token + } + + func readFromStorage() async throws { + print("Attempting to read from Cloud Storage...") + let storage = Storage.storage() + let storageRef = storage.reference() + // NOTE: This path corresponds to the security rules configured for the test project. + // The rules allow public read on '/cep/ping'. If these rules change, this test may fail. + let pingRef = storageRef.child("cep/ping") + + let data = try await pingRef.data(maxSize: 1 * 1024 * 1024) + + // This shouldn't be possible, but we want to know if it ever happens. + guard let string = String(data: data, encoding: .utf8) else { + fatalError( + "Unexpected state: data is not valid UTF-8. This shouldn't happen, but we want to know if it does." + ) } + + print("Storage content: \(string)") + } + + func requestLimitedUseToken() async throws -> String { + let result = try await AppCheck.appCheck().limitedUseToken() + print("[LIMITED USE] Token: \(result.token)") + print(" - Expiration date: \(result.expirationDate)") + return result.token } - @available(iOS 14.0, *) func requestAppAttestToken() { guard let firebaseApp = FirebaseApp.app() else { return @@ -126,12 +199,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return } - appAttestProvider.getToken { token, error in - if let token { + Task { + do { + let token = try await appAttestProvider.getToken() print("App Attest FAC token: \(token.token), expiration date: \(token.expirationDate)") - } - - if let error { + } catch { print("App Attest error: \(error)") } } diff --git a/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestAppTests/FIRAppCheckTestAppTests.swift b/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestAppTests/FIRAppCheckTestAppTests.swift new file mode 100644 index 00000000000..b0d2073b726 --- /dev/null +++ b/FirebaseAppCheck/Apps/FIRAppCheckTestApp/FIRAppCheckTestAppTests/FIRAppCheckTestAppTests.swift @@ -0,0 +1,52 @@ +// Copyright 2026 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 AppCheckCore +@testable import FIRAppCheckTestApp +import FirebaseAppCheck +import XCTest + +final class FIRAppCheckTestAppTests: XCTestCase { + var appDelegate: AppDelegate! + + @MainActor + override func setUp() async throws { + try await super.setUp() + appDelegate = try XCTUnwrap(AppDelegate.shared, "AppDelegate.shared is nil") + } + + func testTokenAcquisitionAndStorageAccess() async throws { + let token = try await appDelegate.fetchAppCheckToken() + XCTAssertGreaterThan(token.expirationDate, Date(), "Token should not be expired") + } + + func testLimitedUseTokenAcquisition() async throws { + let token = try await appDelegate.requestLimitedUseToken() + XCTAssertFalse(token.isEmpty, "Limited-use token should not be empty") + } + + func testCacheWorks() async throws { + let token1 = try await appDelegate.fetchAppCheckToken().token + let token2 = try await appDelegate.fetchAppCheckToken().token + + XCTAssertEqual(token1, token2, "Tokens should be identical (cached)") + } + + func testForceRefresh() async throws { + let token1 = try await appDelegate.fetchAppCheckToken().token + let token2 = try await appDelegate.fetchAppCheckToken(forcingRefresh: true).token + + XCTAssertNotEqual(token1, token2, "Tokens should be different after forced refresh") + } +} diff --git a/FirebaseAppCheck/Apps/FIRAppCheckTestApp/Podfile b/FirebaseAppCheck/Apps/FIRAppCheckTestApp/Podfile index 4ba2cf4fe4c..9cecb04b6d7 100644 --- a/FirebaseAppCheck/Apps/FIRAppCheckTestApp/Podfile +++ b/FirebaseAppCheck/Apps/FIRAppCheckTestApp/Podfile @@ -11,4 +11,17 @@ target 'FIRAppCheckTestApp' do pod 'FirebaseAppCheck', :path => '../../../' pod 'FirebaseCore', :path => '../../../' + # FirebaseStorage is used for testing AppCheck protected access. + pod 'FirebaseStorage', :path => '../../../' + # RecaptchaEnterprise is needed for Firebase App Check reCAPTCHA provider. + pod 'RecaptchaEnterprise' + + # Use local AppCheckCore if the environment variable is set + if ENV['FIREBASE_APP_CHECK_LOCAL_PATH'] + pod 'AppCheckCore', :path => ENV['FIREBASE_APP_CHECK_LOCAL_PATH'] + end + + target 'FIRAppCheckTestAppTests' do + inherit! :search_paths + end end diff --git a/FirebaseAppCheck/CHANGELOG.md b/FirebaseAppCheck/CHANGELOG.md index 8c089b381a1..89f10b32eb4 100644 --- a/FirebaseAppCheck/CHANGELOG.md +++ b/FirebaseAppCheck/CHANGELOG.md @@ -1,11 +1,22 @@ # Unreleased +- [added] Added a reCAPTCHA attestation provider. Using this provider requires the + [reCAPTCHA Enterprise SDK to be installed](https://docs.cloud.google.com/recaptcha/docs/instrument-ios-apps#prepare-environment), + enabling the reCAPTCHA provider on the Firebase App Check console, and + replacing the local `GoogleService-Info.plist` with one redownloaded from + the project settings on the Firebase console. - [changed] Updated `AppCheckDebugProvider` documentation to recommend the generic `AppCheckDebugToken` environment variable instead of the legacy `FIRAAppCheckDebugToken`. Note that `FIRAAppCheckDebugToken` remains supported for backwards compatibility, with `AppCheckDebugToken` taking priority if both are set. (#16230) - [changed] The default App Check provider when running on a simulator is now - the debug provider; physical devices continue to default to DeviceCheck. (#16190) + the [debug provider](https://firebase.google.com/docs/app-check/ios/debug-provider); + physical devices now default to reCAPTCHA if the + [reCAPTCHA Enterprise SDK is + installed](https://docs.cloud.google.com/recaptcha/docs/instrument-ios-apps#prepare-environment), + and fall back to the + [DeviceCheck provider](https://firebase.google.com/docs/app-check/ios/devicecheck-provider) + otherwise. (#16190) - [changed] Removed redundant debug token warning log. (#16197) - [changed] Log an actionable warning when debug token exchange fails. (#16232) diff --git a/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.h b/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.h index 9f49697dd63..3b7c673fea4 100644 --- a/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.h +++ b/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.h @@ -36,6 +36,16 @@ FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageCodeDebugToken; // FIRDeviceCheckProvider.m FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageDeviceCheckProviderIncompleteFIROptions; +// FIRRecaptchaProvider.m +FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageRecaptchaProviderIncompleteFIROptions; +FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageRecaptchaProviderMissingSiteKey; +FOUNDATION_EXPORT NSString *const + kFIRLoggerAppCheckMessageRecaptchaProviderMissingRecaptchaEnterpriseSDK; + +// FIRDefaultProviderFactory.m +FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageCodeRecaptchaFallbackToDeviceCheck; +FOUNDATION_EXPORT NSString *const kFIRLoggerAppCheckMessageCodeDeviceCheckProviderUnavailable; + void FIRAppCheckDebugLog(NSString *messageCode, NSString *message, ...); GACAppCheckLogLevel FIRGetGACAppCheckLogLevel(void); diff --git a/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.m b/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.m index 0e0a0149797..b49db045bb3 100644 --- a/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.m +++ b/FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.m @@ -36,6 +36,16 @@ // FIRDeviceCheckProvider.m NSString *const kFIRLoggerAppCheckMessageDeviceCheckProviderIncompleteFIROptions = @"I-FAA006001"; +// FIRRecaptchaProvider.m +NSString *const kFIRLoggerAppCheckMessageRecaptchaProviderIncompleteFIROptions = @"I-FAA007001"; +NSString *const kFIRLoggerAppCheckMessageRecaptchaProviderMissingSiteKey = @"I-FAA007002"; +NSString *const kFIRLoggerAppCheckMessageRecaptchaProviderMissingRecaptchaEnterpriseSDK = + @"I-FAA007003"; + +// FIRDefaultProviderFactory.m +NSString *const kFIRLoggerAppCheckMessageCodeRecaptchaFallbackToDeviceCheck = @"I-FAA008001"; +NSString *const kFIRLoggerAppCheckMessageCodeDeviceCheckProviderUnavailable = @"I-FAA008002"; + #pragma mark - Log functions void FIRAppCheckDebugLog(NSString *messageCode, NSString *message, ...) { diff --git a/FirebaseAppCheck/Sources/DefaultProviderFactory/FIRDefaultProviderFactory.m b/FirebaseAppCheck/Sources/DefaultProviderFactory/FIRDefaultProviderFactory.m index 866f458d325..cc88114a9b9 100644 --- a/FirebaseAppCheck/Sources/DefaultProviderFactory/FIRDefaultProviderFactory.m +++ b/FirebaseAppCheck/Sources/DefaultProviderFactory/FIRDefaultProviderFactory.m @@ -14,9 +14,13 @@ #import "FirebaseAppCheck/Sources/DefaultProviderFactory/FIRDefaultProviderFactory.h" +#import "FirebaseAppCheck/Sources/Core/FIRApp+AppCheck.h" +#import "FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.h" #import "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRAppCheck.h" #import "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRAppCheckDebugProviderFactory.h" #import "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRDeviceCheckProviderFactory.h" +#import "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRRecaptchaProviderFactory.h" +#import "FirebaseAppCheck/Sources/RecaptchaProvider/FIRRecaptchaProvider+Internal.h" @implementation FIRDefaultProviderFactory @@ -27,10 +31,32 @@ + (void)load { - (nullable id)createProviderWithApp:(nonnull FIRApp *)app { #if TARGET_OS_SIMULATOR return [[[FIRAppCheckDebugProviderFactory alloc] init] createProviderWithApp:app]; -// TODO(ncooke3): Add elif case for future reCAPTCHA provider. -#else - return [[[FIRDeviceCheckProviderFactory alloc] init] createProviderWithApp:app]; +#else // !TARGET_OS_SIMULATOR + +#if (TARGET_OS_IOS && !TARGET_OS_MACCATALYST) || TARGET_OS_VISION + if (app.options.recaptchaSiteKey.length > 0) { + return [[[FIRRecaptchaProviderFactory alloc] init] createProviderWithApp:app]; + } else { + FIRLogWarning(kFIRLoggerAppCheck, kFIRLoggerAppCheckMessageCodeRecaptchaFallbackToDeviceCheck, + @"reCAPTCHA Enterprise site key not found in Firebase options for app: %@. " + @"If you want to use reCAPTCHA, please ensure the provider is enabled in the " + @"Firebase Console and redownload your GoogleService-Info.plist. " + @"Default attestation provider is falling back to DeviceCheck. If DeviceCheck is " + @"not configured, App Check enforcement will fail.", + app.name); + } #endif + + if (@available(watchOS 9.0, *)) { + return [[[FIRDeviceCheckProviderFactory alloc] init] createProviderWithApp:app]; + } else { + FIRLogWarning(kFIRLoggerAppCheck, kFIRLoggerAppCheckMessageCodeDeviceCheckProviderUnavailable, + @"DeviceCheck is not supported on this device/OS version. " + @"App Check enforcement will fail."); + return nil; + } + +#endif // TARGET_OS_SIMULATOR } @end diff --git a/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRRecaptchaProvider.h b/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRRecaptchaProvider.h new file mode 100644 index 00000000000..288994ed67b --- /dev/null +++ b/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRRecaptchaProvider.h @@ -0,0 +1,63 @@ +/* + * Copyright 2026 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 +#import "FIRAppCheckAvailability.h" +#import "FIRAppCheckProvider.h" + +@class FIRApp; + +NS_ASSUME_NONNULL_BEGIN + +/// App Check provider that verifies app integrity using +/// [reCAPTCHA Enterprise for iOS](https://cloud.google.com/recaptcha/docs/instrument-ios-apps) +/// API. +NS_SWIFT_NAME(RecaptchaProvider) +API_AVAILABLE(ios(15.0), visionos(1.0)) +API_UNAVAILABLE(macos, tvos, watchos, macCatalyst) +@interface FIRRecaptchaProvider : NSObject + +- (instancetype)init NS_UNAVAILABLE; + +/// The default initializer. +/// @param app A `FirebaseApp` instance. +/// @return An instance of `RecaptchaProvider` if the provided +/// `FirebaseApp` instance contains all required parameters. +- (nullable instancetype)initWithApp:(FIRApp *)app; + +/* Jazzy doesn't generate documentation for protocol-inherited + * methods, so this is copied over from the protocol declaration. + */ +/// Returns a new Firebase App Check token. +/// @param handler The completion handler. Make sure to call the handler with either a token +/// or an error. +- (void)getTokenWithCompletion: + (void (^)(FIRAppCheckToken *_Nullable token, NSError *_Nullable error))handler + NS_SWIFT_NAME(getToken(completion:)); + +/// Returns a new Firebase App Check token. +/// When implementing this method for your custom provider, the token returned should be suitable +/// for consumption in a limited-use scenario. If you do not implement this method, the +/// getTokenWithCompletion will be invoked instead whenever a limited-use token is requested. +/// @param handler The completion handler. Make sure to call the handler with either a token +/// or an error. +- (void)getLimitedUseTokenWithCompletion: + (void (^)(FIRAppCheckToken *_Nullable token, NSError *_Nullable error))handler + NS_SWIFT_NAME(getLimitedUseToken(completion:)); + +@end + +NS_ASSUME_NONNULL_END diff --git a/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRRecaptchaProviderFactory.h b/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRRecaptchaProviderFactory.h new file mode 100644 index 00000000000..5b063906271 --- /dev/null +++ b/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRRecaptchaProviderFactory.h @@ -0,0 +1,31 @@ +/* + * Copyright 2026 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 +#import "FIRAppCheckProviderFactory.h" + +NS_ASSUME_NONNULL_BEGIN + +/// An implementation of `AppCheckProviderFactory` that creates a new instance of +/// `AppCheckRecaptchaProvider` when requested. +NS_SWIFT_NAME(RecaptchaProviderFactory) +API_AVAILABLE(ios(15.0), visionos(1.0)) +API_UNAVAILABLE(macos, tvos, watchos, macCatalyst) +@interface FIRRecaptchaProviderFactory : NSObject + +@end + +NS_ASSUME_NONNULL_END diff --git a/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FirebaseAppCheck.h b/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FirebaseAppCheck.h index fbe5b9f8946..bfb29a5968e 100644 --- a/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FirebaseAppCheck.h +++ b/FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FirebaseAppCheck.h @@ -31,3 +31,7 @@ // App Attest provider. #import "FIRAppAttestProvider.h" #import "FIRAppAttestProviderFactory.h" + +// Recaptcha provider +#import "FIRRecaptchaProvider.h" +#import "FIRRecaptchaProviderFactory.h" diff --git a/FirebaseAppCheck/Sources/RecaptchaProvider/FIRRecaptchaProvider+Internal.h b/FirebaseAppCheck/Sources/RecaptchaProvider/FIRRecaptchaProvider+Internal.h new file mode 100644 index 00000000000..5c5dbbdea4a --- /dev/null +++ b/FirebaseAppCheck/Sources/RecaptchaProvider/FIRRecaptchaProvider+Internal.h @@ -0,0 +1,30 @@ +/* + * Copyright 2026 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 "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRRecaptchaProvider.h" + +NS_ASSUME_NONNULL_BEGIN + +API_AVAILABLE(ios(15.0), visionos(1.0)) +API_UNAVAILABLE(macos, tvos, watchos, macCatalyst) +@interface FIRRecaptchaProvider (Internal) + +/// Returns `YES` if the reCAPTCHA Enterprise SDK is linked and available at runtime. ++ (BOOL)isSupported; + +@end + +NS_ASSUME_NONNULL_END diff --git a/FirebaseAppCheck/Sources/RecaptchaProvider/FIRRecaptchaProvider.m b/FirebaseAppCheck/Sources/RecaptchaProvider/FIRRecaptchaProvider.m new file mode 100644 index 00000000000..f75de034393 --- /dev/null +++ b/FirebaseAppCheck/Sources/RecaptchaProvider/FIRRecaptchaProvider.m @@ -0,0 +1,156 @@ +/* + * Copyright 2026 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 "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRRecaptchaProvider.h" +#import "FirebaseAppCheck/Sources/RecaptchaProvider/FIRRecaptchaProvider+Internal.h" + +#import + +#if SWIFT_PACKAGE +@import AppCheckRecaptchaProvider; +#elif __has_include() +#import +#elif __has_include("AppCheckCore-Swift.h") +// If frameworks are not available, fall back to importing the header as it +// should be findable from a header search path pointing to the build +// directory. See #12611 for more context. +#import "AppCheckCore-Swift.h" +#endif + +#import "FirebaseAppCheck/Sources/Core/FIRApp+AppCheck.h" + +#import "FirebaseAppCheck/Sources/Core/FIRAppCheckLogger.h" +#import "FirebaseAppCheck/Sources/Core/FIRAppCheckToken+Internal.h" +#import "FirebaseAppCheck/Sources/Core/FIRAppCheckValidator.h" +#import "FirebaseAppCheck/Sources/Core/FIRHeartbeatLogger+AppCheck.h" + +@interface FIRRecaptchaProvider () + +@property(nonatomic, readonly) id recaptchaProvider; + +- (instancetype)initWithRecaptchaProvider:(id)recaptchaProvider; + +@end + +@implementation FIRRecaptchaProvider + ++ (BOOL)isSupported { +#if (TARGET_OS_IOS && !TARGET_OS_MACCATALYST) || TARGET_OS_VISION + return [GACRecaptchaProvider isSupported]; +#else + return NO; +#endif +} + +- (nullable instancetype)initWithApp:(FIRApp *)app { +#if (TARGET_OS_IOS && !TARGET_OS_MACCATALYST) || TARGET_OS_VISION + // 1. Validate options and raise exceptions on invalid configuration + NSString *siteKey = app.options.recaptchaSiteKey; + if (siteKey.length == 0) { + NSString *message = [NSString + stringWithFormat: + @"Cannot instantiate `RecaptchaProvider` for app: %@. " + @"`FirebaseOptions.recaptchaSiteKey` " + @"is missing or empty. " + @"Please ensure you have downloaded the latest `GoogleService-Info.plist` from the " + @"Firebase console or set `recaptchaSiteKey` on `FirebaseOptions` programmatically.", + app.name]; + FIRLogError(kFIRLoggerAppCheck, kFIRLoggerAppCheckMessageRecaptchaProviderMissingSiteKey, @"%@", + message); + [NSException raise:NSInvalidArgumentException format:@"%@", message]; + } + NSArray *missingOptionsFields = + [FIRAppCheckValidator tokenExchangeMissingFieldsInOptions:app.options]; + if (missingOptionsFields.count > 0) { + FIRLogError(kFIRLoggerAppCheck, kFIRLoggerAppCheckMessageRecaptchaProviderIncompleteFIROptions, + @"Cannot instantiate `RecaptchaProvider` for app: %@. The following " + @"`FirebaseOptions` fields are " + @"missing: %@. " + @"Please ensure your `GoogleService-Info.plist` is complete or these fields are " + @"set on `FirebaseOptions` programmatically.", + app.name, [missingOptionsFields componentsJoinedByString:@", "]); + return nil; + } + + // 2. Validate SDK Linkage + if (![FIRRecaptchaProvider isSupported]) { + NSString *message = [NSString + stringWithFormat: + @"Cannot instantiate `RecaptchaProvider` for app: %@. The reCAPTCHA Enterprise SDK " + @"is " + @"not linked. " + @"Please ensure you have installed the `FirebaseAppCheck` package along with " + @"the underlying reCAPTCHA Enterprise dependency. " + @"See " + @"https://cloud.google.com/recaptcha/docs/instrument-ios-apps#prepare-environment " + @"for details.", + app.name]; + FIRLogError(kFIRLoggerAppCheck, + kFIRLoggerAppCheckMessageRecaptchaProviderMissingRecaptchaEnterpriseSDK, @"%@", + message); + [NSException raise:NSInternalInconsistencyException format:@"%@", message]; + } + + id heartbeatHook = [app.heartbeatLogger requestHook]; + GACRecaptchaProvider *recaptchaProvider = + [[GACRecaptchaProvider alloc] initWithSiteKey:siteKey + resourceName:app.resourceName + APIKey:app.options.APIKey + requestHooks:heartbeatHook ? @[ heartbeatHook ] : @[]]; + + return [self initWithRecaptchaProvider:recaptchaProvider]; +#else + return nil; +#endif +} + +- (instancetype)initWithRecaptchaProvider:(id)recaptchaProvider { + self = [super init]; + if (self) { + _recaptchaProvider = recaptchaProvider; + } + return self; +} + +#pragma mark - FIRAppCheckProvider + +- (void)getTokenWithCompletion:(void (^)(FIRAppCheckToken *_Nullable token, + NSError *_Nullable error))handler { + [self.recaptchaProvider getTokenWithCompletion:^(GACAppCheckToken *_Nullable internalToken, + NSError *_Nullable error) { + if (error) { + handler(nil, error); + return; + } + + handler([[FIRAppCheckToken alloc] initWithInternalToken:internalToken], nil); + }]; +} + +- (void)getLimitedUseTokenWithCompletion:(void (^)(FIRAppCheckToken *_Nullable, + NSError *_Nullable))handler { + [self.recaptchaProvider getLimitedUseTokenWithCompletion:^( + GACAppCheckToken *_Nullable internalToken, NSError *_Nullable error) { + if (error) { + handler(nil, error); + return; + } + + handler([[FIRAppCheckToken alloc] initWithInternalToken:internalToken], nil); + }]; +} + +@end diff --git a/FirebaseAppCheck/Sources/RecaptchaProvider/FIRRecaptchaProviderFactory.m b/FirebaseAppCheck/Sources/RecaptchaProvider/FIRRecaptchaProviderFactory.m new file mode 100644 index 00000000000..dca26a5b3e1 --- /dev/null +++ b/FirebaseAppCheck/Sources/RecaptchaProvider/FIRRecaptchaProviderFactory.m @@ -0,0 +1,31 @@ +/* + * Copyright 2026 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 "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRRecaptchaProviderFactory.h" + +#import "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRRecaptchaProvider.h" + +@implementation FIRRecaptchaProviderFactory + +- (nullable id)createProviderWithApp:(nonnull FIRApp *)app { +#if (TARGET_OS_IOS && !TARGET_OS_MACCATALYST) || TARGET_OS_VISION + return [[FIRRecaptchaProvider alloc] initWithApp:app]; +#else + return nil; +#endif +} + +@end diff --git a/FirebaseAppCheck/Tests/Unit/DefaultProviderFactory/FIRDefaultProviderFactoryTests.m b/FirebaseAppCheck/Tests/Unit/DefaultProviderFactory/FIRDefaultProviderFactoryTests.m index 8b00fb6f2e3..feb2fe2a337 100644 --- a/FirebaseAppCheck/Tests/Unit/DefaultProviderFactory/FIRDefaultProviderFactoryTests.m +++ b/FirebaseAppCheck/Tests/Unit/DefaultProviderFactory/FIRDefaultProviderFactoryTests.m @@ -18,34 +18,118 @@ #import "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRAppCheckAvailability.h" #import "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRAppCheckDebugProvider.h" #import "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRDeviceCheckProvider.h" +#import "FirebaseAppCheck/Sources/Public/FirebaseAppCheck/FIRRecaptchaProvider.h" +#import #import "FirebaseCore/Extension/FirebaseCoreInternal.h" +#import "FirebaseAppCheck/Sources/RecaptchaProvider/FIRRecaptchaProvider+Internal.h" + FIR_DEVICE_CHECK_PROVIDER_AVAILABILITY @interface FIRDefaultProviderFactoryTests : XCTestCase @end @implementation FIRDefaultProviderFactoryTests -- (void)testCreateProviderWithApp { +- (FIRApp *)mockAppWithRecaptchaSiteKey:(nullable NSString *)siteKey { FIROptions *options = [[FIROptions alloc] initWithGoogleAppID:@"app_id" GCMSenderID:@"sender_id"]; options.APIKey = @"api_key"; options.projectID = @"project_id"; + if (siteKey) { + options.recaptchaSiteKey = siteKey; + } FIRApp *app = [[FIRApp alloc] initInstanceWithName:@"testInitWithValidApp" options:options]; - // The following disables automatic token refresh, which could interfere with tests. app.dataCollectionDefaultEnabled = NO; + return app; +} +- (void)testCreateProvider_Simulator { +#if TARGET_OS_SIMULATOR + FIRApp *app = [self mockAppWithRecaptchaSiteKey:nil]; FIRDefaultProviderFactory *factory = [[FIRDefaultProviderFactory alloc] init]; id provider = [factory createProviderWithApp:app]; XCTAssertNotNil(provider); + XCTAssert([provider isKindOfClass:[FIRAppCheckDebugProvider class]]); +#endif +} + +- (void)testCreateProvider_Device_RecaptchaLinked { +#if ((TARGET_OS_IOS && !TARGET_OS_MACCATALYST) || TARGET_OS_VISION) && !TARGET_OS_SIMULATOR + FIRApp *app = [self mockAppWithRecaptchaSiteKey:@"site_key"]; + + id recaptchaMock = OCMClassMock([FIRRecaptchaProvider class]); + OCMStub([recaptchaMock isSupported]).andReturn(YES); + + FIRDefaultProviderFactory *factory = [[FIRDefaultProviderFactory alloc] init]; + id provider = [factory createProviderWithApp:app]; + + XCTAssertNotNil(provider); + XCTAssert([provider isKindOfClass:[FIRRecaptchaProvider class]]); + + [recaptchaMock stopMocking]; +#endif +} + +- (void)testCreateProvider_Device_RecaptchaNotLinked { +#if !TARGET_OS_SIMULATOR + FIRApp *app = [self mockAppWithRecaptchaSiteKey:nil]; + +#if (TARGET_OS_IOS && !TARGET_OS_MACCATALYST) || TARGET_OS_VISION + id recaptchaMock = OCMClassMock([FIRRecaptchaProvider class]); + OCMStub([recaptchaMock isSupported]).andReturn(NO); +#endif + + FIRDefaultProviderFactory *factory = [[FIRDefaultProviderFactory alloc] init]; + id provider = [factory createProviderWithApp:app]; + + XCTAssertNotNil(provider); + XCTAssert([provider isKindOfClass:[FIRDeviceCheckProvider class]]); + +#if (TARGET_OS_IOS && !TARGET_OS_MACCATALYST) || TARGET_OS_VISION + [recaptchaMock stopMocking]; +#endif +#endif +} +- (void)testCreateProvider_Device_RecaptchaNotLinked_WithSiteKey_Throws { +#if ((TARGET_OS_IOS && !TARGET_OS_MACCATALYST) || TARGET_OS_VISION) && !TARGET_OS_SIMULATOR + FIRApp *app = [self mockAppWithRecaptchaSiteKey:@"site_key"]; + + id recaptchaMock = OCMClassMock([FIRRecaptchaProvider class]); + OCMStub([recaptchaMock isSupported]).andReturn(NO); + + FIRDefaultProviderFactory *factory = [[FIRDefaultProviderFactory alloc] init]; + + XCTAssertThrows([factory createProviderWithApp:app]); + + [recaptchaMock stopMocking]; +#endif +} + +- (void)testCreateProviderWithApp_PublicAPI { + // Verifies that the public API doesn't crash and returns a provider. + FIRApp *app = [self mockAppWithRecaptchaSiteKey:nil]; + +#if (TARGET_OS_IOS && !TARGET_OS_MACCATALYST) || TARGET_OS_VISION + id recaptchaMock = OCMClassMock([FIRRecaptchaProvider class]); + OCMStub([recaptchaMock isSupported]).andReturn(NO); +#endif + + FIRDefaultProviderFactory *factory = [[FIRDefaultProviderFactory alloc] init]; + id provider = [factory createProviderWithApp:app]; + + XCTAssertNotNil(provider); #if TARGET_OS_SIMULATOR XCTAssert([provider isKindOfClass:[FIRAppCheckDebugProvider class]]); #else XCTAssert([provider isKindOfClass:[FIRDeviceCheckProvider class]]); #endif + +#if (TARGET_OS_IOS && !TARGET_OS_MACCATALYST) || TARGET_OS_VISION + [recaptchaMock stopMocking]; +#endif } @end diff --git a/FirebaseAppCheck/Tests/Unit/Swift/FirebaseAppCheck-unit-Bridging-Header.h b/FirebaseAppCheck/Tests/Unit/Swift/FirebaseAppCheck-unit-Bridging-Header.h new file mode 100644 index 00000000000..8d9e31f63f2 --- /dev/null +++ b/FirebaseAppCheck/Tests/Unit/Swift/FirebaseAppCheck-unit-Bridging-Header.h @@ -0,0 +1,15 @@ +// Copyright 2026 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 "SharedTestUtilities/ExceptionCatcher.h" diff --git a/FirebaseAppCheck/Tests/Unit/Swift/RecaptchaProvider+Test.swift b/FirebaseAppCheck/Tests/Unit/Swift/RecaptchaProvider+Test.swift new file mode 100644 index 00000000000..aa85b4a51a5 --- /dev/null +++ b/FirebaseAppCheck/Tests/Unit/Swift/RecaptchaProvider+Test.swift @@ -0,0 +1,36 @@ +// Copyright 2026 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. + +#if os(iOS) && !targetEnvironment(macCatalyst) || os(visionOS) + + import AppCheckCore + import FirebaseAppCheck + + /// Internal Objective-C interface for test helper methods. + @objc protocol RecaptchaProviderTesting { + @objc(initWithRecaptchaProvider:) + func initWithRecaptchaProvider(_ recaptchaProvider: AppCheckCoreProvider) -> RecaptchaProvider + } + + @objc extension RecaptchaProvider { + /// Safe, compile-time checked test helper that bypasses production validation checks. + class func testInstance(recaptchaProvider: AppCheckCoreProvider) -> RecaptchaProvider { + let providerClass = RecaptchaProvider.self as AnyObject + let allocated = providerClass.perform(NSSelectorFromString("alloc")).takeUnretainedValue() + let uninitialized = unsafeBitCast(allocated, to: RecaptchaProviderTesting.self) + return uninitialized.initWithRecaptchaProvider(recaptchaProvider) + } + } + +#endif diff --git a/FirebaseAppCheck/Tests/Unit/Swift/RecaptchaProviderTests.swift b/FirebaseAppCheck/Tests/Unit/Swift/RecaptchaProviderTests.swift new file mode 100644 index 00000000000..cd15642d64f --- /dev/null +++ b/FirebaseAppCheck/Tests/Unit/Swift/RecaptchaProviderTests.swift @@ -0,0 +1,216 @@ +// Copyright 2026 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. + +#if os(iOS) && !targetEnvironment(macCatalyst) || os(visionOS) + + import AppCheckCore + import FirebaseAppCheck + import FirebaseCore + #if SWIFT_PACKAGE + import SharedTestUtilities + #endif + import XCTest + + class FakeInternalProvider: NSObject, AppCheckCoreProvider { + var stubbedToken: AppCheckCoreToken? + var stubbedError: Error? + + @objc(getTokenWithCompletion:) + func getToken(completion handler: @escaping (AppCheckCoreToken?, Error?) -> Void) { + handler(stubbedToken, stubbedError) + } + + @objc(getLimitedUseTokenWithCompletion:) + func getLimitedUseToken(completion handler: @escaping (AppCheckCoreToken?, Error?) -> Void) { + handler(stubbedToken, stubbedError) + } + } + + final class RecaptchaProviderTests: XCTestCase { + var provider: RecaptchaProvider! + var fakeInternalProvider: FakeInternalProvider! + + override func setUp() { + super.setUp() + fakeInternalProvider = FakeInternalProvider() + provider = RecaptchaProvider.testInstance(recaptchaProvider: fakeInternalProvider) + } + + override func tearDown() { + provider = nil + fakeInternalProvider = nil + super.tearDown() + } + + func testInitWithIncompleteApp() { + let options = FirebaseOptions(googleAppID: "1:123456789:ios:abc123", gcmSenderID: "sender_id") + options.projectID = "project_id" + options.recaptchaSiteKey = "test_site_key" + + let appName = "testInitWithIncompleteApp1" + let missingAPIKeyApp: FirebaseApp + if let existingApp = FirebaseApp.app(name: appName) { + missingAPIKeyApp = existingApp + } else { + FirebaseApp.configure(name: appName, options: options) + missingAPIKeyApp = FirebaseApp.app(name: appName)! + } + missingAPIKeyApp.isDataCollectionDefaultEnabled = false + + XCTAssertNil(RecaptchaProvider(app: missingAPIKeyApp)) + + options.projectID = nil + options.apiKey = "api_key" + options.recaptchaSiteKey = "test_site_key" + + let appName2 = "testInitWithIncompleteApp2" + let missingProjectIDApp: FirebaseApp + if let existingApp = FirebaseApp.app(name: appName2) { + missingProjectIDApp = existingApp + } else { + FirebaseApp.configure(name: appName2, options: options) + missingProjectIDApp = FirebaseApp.app(name: appName2)! + } + missingProjectIDApp.isDataCollectionDefaultEnabled = false + XCTAssertNil(RecaptchaProvider(app: missingProjectIDApp)) + } + + func testInitWithMissingSiteKey() { + let options = FirebaseOptions(googleAppID: "1:123456789:ios:abc123", gcmSenderID: "sender_id") + options.apiKey = "api_key" + options.projectID = "project_id" + // options.recaptchaSiteKey is nil + + let appName = "testInitWithMissingSiteKey" + let app: FirebaseApp + if let existingApp = FirebaseApp.app(name: appName) { + app = existingApp + } else { + FirebaseApp.configure(name: appName, options: options) + app = FirebaseApp.app(name: appName)! + } + app.isDataCollectionDefaultEnabled = false + + XCTAssertThrowsError(try ExceptionCatcher.catchException { + _ = RecaptchaProvider(app: app) + }) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, NSExceptionName.invalidArgumentException.rawValue) + XCTAssertEqual(nsError.code, -114) + XCTAssertTrue((nsError.userInfo["ExceptionReason"] as? String)? + .contains("recaptchaSiteKey") ?? false) + } + } + + func testInitWithMissingSDKThrows() { + let options = FirebaseOptions(googleAppID: "1:123456789:ios:abc123", gcmSenderID: "sender_id") + options.apiKey = "api_key" + options.projectID = "project_id" + options.recaptchaSiteKey = "test_site_key" + + let appName = "testInitWithMissingSDKThrows" + let app: FirebaseApp + if let existingApp = FirebaseApp.app(name: appName) { + app = existingApp + } else { + FirebaseApp.configure(name: appName, options: options) + app = FirebaseApp.app(name: appName)! + } + app.isDataCollectionDefaultEnabled = false + + XCTAssertThrowsError(try ExceptionCatcher.catchException { + _ = RecaptchaProvider(app: app) + }) { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, NSExceptionName.internalInconsistencyException.rawValue) + XCTAssertEqual(nsError.code, -114) + XCTAssertTrue((nsError.userInfo["ExceptionReason"] as? String)? + .contains("reCAPTCHA Enterprise SDK is not linked") ?? false) + } + } + + func testGetTokenSuccess() { + let date = Date() + let validInternalToken = AppCheckCoreToken( + token: "valid_token", + expirationDate: date, + receivedAt: date + ) + fakeInternalProvider.stubbedToken = validInternalToken + + let expectation = self.expectation(description: "getToken") + + provider.getToken { token, error in + XCTAssertEqual(token?.token, validInternalToken.token) + XCTAssertEqual(token?.expirationDate, validInternalToken.expirationDate) + XCTAssertNil(error) + expectation.fulfill() + } + + waitForExpectations(timeout: 1, handler: nil) + } + + func testGetTokenAPIError() { + let expectedError = NSError(domain: "testGetTokenAPIError", code: -1, userInfo: nil) + fakeInternalProvider.stubbedError = expectedError + + let expectation = self.expectation(description: "getTokenError") + + provider.getToken { token, error in + XCTAssertNil(token) + XCTAssertEqual(error as NSError?, expectedError) + expectation.fulfill() + } + + waitForExpectations(timeout: 1, handler: nil) + } + + func testGetLimitedUseTokenSuccess() { + let date = Date() + let validInternalToken = AppCheckCoreToken( + token: "TEST_ValidToken", + expirationDate: date, + receivedAt: date + ) + fakeInternalProvider.stubbedToken = validInternalToken + + let expectation = self.expectation(description: "getLimitedUseToken") + + provider.getLimitedUseToken { token, error in + XCTAssertEqual(token?.token, validInternalToken.token) + XCTAssertEqual(token?.expirationDate, validInternalToken.expirationDate) + XCTAssertNil(error) + expectation.fulfill() + } + + waitForExpectations(timeout: 1, handler: nil) + } + + func testGetLimitedUseTokenProviderError() { + let expectedError = NSError(domain: "TEST_LimitedUseToken_Error", code: -1, userInfo: nil) + fakeInternalProvider.stubbedError = expectedError + + let expectation = self.expectation(description: "getLimitedUseTokenError") + + provider.getLimitedUseToken { token, error in + XCTAssertNil(token) + XCTAssertEqual(error as NSError?, expectedError) + expectation.fulfill() + } + + waitForExpectations(timeout: 1, handler: nil) + } + } + +#endif diff --git a/FirebaseCore/Sources/FIROptions.m b/FirebaseCore/Sources/FIROptions.m index 95a3480706d..6e9a495fc51 100644 --- a/FirebaseCore/Sources/FIROptions.m +++ b/FirebaseCore/Sources/FIROptions.m @@ -29,6 +29,7 @@ NSString *const kFIRBundleID = @"BUNDLE_ID"; // The key to locate the project identifier in the plist file. NSString *const kFIRProjectID = @"PROJECT_ID"; +NSString *const kFIRRecaptchaSiteKey = @"RECAPTCHA_SITE_KEY"; NSString *const kFIRIsMeasurementEnabled = @"IS_MEASUREMENT_ENABLED"; NSString *const kFIRIsAnalyticsCollectionEnabled = @"FIREBASE_ANALYTICS_COLLECTION_ENABLED"; @@ -306,6 +307,15 @@ - (void)setStorageBucket:(NSString *)storageBucket { _optionsDictionary[kFIRStorageBucket] = [storageBucket copy]; } +- (NSString *)recaptchaSiteKey { + return self.optionsDictionary[kFIRRecaptchaSiteKey]; +} + +- (void)setRecaptchaSiteKey:(NSString *)recaptchaSiteKey { + [self checkEditingLocked]; + _optionsDictionary[kFIRRecaptchaSiteKey] = [recaptchaSiteKey copy]; +} + - (NSString *)bundleID { return self.optionsDictionary[kFIRBundleID]; } diff --git a/FirebaseCore/Sources/Public/FirebaseCore/FIROptions.h b/FirebaseCore/Sources/Public/FirebaseCore/FIROptions.h index 4e9f8853097..31d1f05932b 100644 --- a/FirebaseCore/Sources/Public/FirebaseCore/FIROptions.h +++ b/FirebaseCore/Sources/Public/FirebaseCore/FIROptions.h @@ -82,6 +82,11 @@ NS_SWIFT_NAME(FirebaseOptions) */ @property(nonatomic, copy, nullable) NSString *appGroupID; +/** + * The reCAPTCHA site key used by App Check. + */ +@property(nonatomic, copy, nullable) NSString *recaptchaSiteKey; + /** * Initializes a customized instance of FirebaseOptions from the file at the given plist file path. * This will read the file synchronously from disk. diff --git a/FirebaseCore/Tests/Unit/FIROptionsTest.m b/FirebaseCore/Tests/Unit/FIROptionsTest.m index a829bb7b2cc..ced922fc727 100644 --- a/FirebaseCore/Tests/Unit/FIROptionsTest.m +++ b/FirebaseCore/Tests/Unit/FIROptionsTest.m @@ -154,6 +154,19 @@ - (void)testInitCustomizedOptions { XCTAssertFalse(options.usingOptionsFromDefaultPlist); } +- (void)testRecaptchaSiteKey { + NSString *siteKey = @"placeholder_site_key"; + NSDictionary *optionsDictionary = @{@"RECAPTCHA_SITE_KEY" : siteKey}; + FIROptions *options = [[FIROptions alloc] initInternalWithOptionsDictionary:optionsDictionary]; + XCTAssertEqualObjects(options.recaptchaSiteKey, siteKey); +} + +- (void)testSetRecaptchaSiteKey { + FIROptions *options = [[FIROptions alloc] initWithGoogleAppID:@"app_id" GCMSenderID:@"sender_id"]; + options.recaptchaSiteKey = @"manual_site_key"; + XCTAssertEqualObjects(options.recaptchaSiteKey, @"manual_site_key"); +} + - (void)assertOptionsMatchDefaults:(FIROptions *)options andProjectID:(BOOL)matchProjectID { XCTAssertEqualObjects(options.googleAppID, kGoogleAppID); XCTAssertEqualObjects(options.APIKey, kAPIKey); diff --git a/Package.swift b/Package.swift index b51260cc7aa..ac3a3f633a6 100644 --- a/Package.swift +++ b/Package.swift @@ -172,8 +172,7 @@ let package = Package( url: "https://github.com/google/interop-ios-for-google-sdks.git", "101.0.0" ..< "102.0.0" ), - .package(url: "https://github.com/google/app-check.git", - "11.0.1" ..< "12.0.0"), + appCheckDependency(), ], targets: [ .target( @@ -945,6 +944,7 @@ let package = Package( .target( name: "SharedTestUtilities", dependencies: ["FirebaseCore", + "FirebaseCoreExtension", "FirebaseAppCheckInterop", "FirebaseAuthInterop", "FirebaseMessagingInterop", @@ -1268,6 +1268,7 @@ let package = Package( "FirebaseAppCheckInterop", "FirebaseCore", .product(name: "AppCheckCore", package: "app-check"), + .product(name: "AppCheckRecaptchaProvider", package: "app-check"), .product(name: "GULEnvironment", package: "GoogleUtilities"), .product(name: "GULUserDefaults", package: "GoogleUtilities"), ], @@ -1316,8 +1317,13 @@ let package = Package( dependencies: [ "FirebaseAppCheck", "FirebaseCoreExtension", + "SharedTestUtilities", + .product(name: "AppCheckCore", package: "app-check"), ], path: "FirebaseAppCheck/Tests/Unit/Swift", + cSettings: [ + .headerSearchPath("../../../"), + ], swiftSettings: [ .swiftLanguageMode(SwiftLanguageMode.v5), ] @@ -1675,3 +1681,17 @@ func isFoundationModelsSupportedPlatformSwiftSetting() -> SwiftSetting { .when(platforms: [.iOS, .macCatalyst, .macOS, .visionOS]) ) } + +func appCheckDependency() -> Package.Dependency { + let appCheckURL = "https://github.com/google/app-check.git" + + if let localPath = Context.environment["FIREBASE_APP_CHECK_LOCAL_PATH"] { + return .package(path: localPath) + } + + if let branch = Context.environment["FIREBASE_APP_CHECK_BRANCH"] { + return .package(url: appCheckURL, branch: branch) + } + + return .package(url: appCheckURL, "11.3.0" ..< "12.0.0") +} diff --git a/SharedTestUtilities/FIRAuthInteropFake.h b/SharedTestUtilities/FIRAuthInteropFake.h index ef245e20eda..ff0e34b21df 100644 --- a/SharedTestUtilities/FIRAuthInteropFake.h +++ b/SharedTestUtilities/FIRAuthInteropFake.h @@ -16,7 +16,11 @@ #import +#if SWIFT_PACKAGE +#import +#else #import "FirebaseAuth/Interop/Public/FirebaseAuthInterop/FIRAuthInterop.h" +#endif NS_ASSUME_NONNULL_BEGIN diff --git a/SharedTestUtilities/FIRAuthInteropFake.m b/SharedTestUtilities/FIRAuthInteropFake.m index ca7aeab94e1..970257de2ff 100644 --- a/SharedTestUtilities/FIRAuthInteropFake.m +++ b/SharedTestUtilities/FIRAuthInteropFake.m @@ -16,7 +16,11 @@ #import "SharedTestUtilities/FIRAuthInteropFake.h" +#if SWIFT_PACKAGE +#import +#else #import "FirebaseAuth/Interop/Public/FirebaseAuthInterop/FIRAuthInterop.h" +#endif NS_ASSUME_NONNULL_BEGIN diff --git a/SharedTestUtilities/FIRComponentTestUtilities.h b/SharedTestUtilities/FIRComponentTestUtilities.h index 40d7f76a88d..b3f11b306ea 100644 --- a/SharedTestUtilities/FIRComponentTestUtilities.h +++ b/SharedTestUtilities/FIRComponentTestUtilities.h @@ -16,7 +16,11 @@ #import +#if SWIFT_PACKAGE +#import +#else #import "FirebaseCore/Extension/FirebaseCoreInternal.h" +#endif NS_ASSUME_NONNULL_BEGIN diff --git a/SharedTestUtilities/FIRMessagingInteropFake.h b/SharedTestUtilities/FIRMessagingInteropFake.h index 56ef0dd9183..27e10f6e9b6 100644 --- a/SharedTestUtilities/FIRMessagingInteropFake.h +++ b/SharedTestUtilities/FIRMessagingInteropFake.h @@ -16,7 +16,11 @@ #import +#if SWIFT_PACKAGE +#import +#else #import "FirebaseMessaging/Interop/FIRMessagingInterop.h" +#endif NS_ASSUME_NONNULL_BEGIN diff --git a/SharedTestUtilities/FIRSampleAppUtilities.m b/SharedTestUtilities/FIRSampleAppUtilities.m index 8f088b93420..2679c42a3af 100644 --- a/SharedTestUtilities/FIRSampleAppUtilities.m +++ b/SharedTestUtilities/FIRSampleAppUtilities.m @@ -22,7 +22,11 @@ #import #endif +#if SWIFT_PACKAGE +#import +#else #import "FirebaseCore/Extension/FirebaseCoreInternal.h" +#endif NSString *const kGoogleAppIDPlistKey = @"GOOGLE_APP_ID"; // Dummy plist GOOGLE_APP_ID diff --git a/scripts/check_imports.swift b/scripts/check_imports.swift index 288724a5bf6..28f6e0b9863 100755 --- a/scripts/check_imports.swift +++ b/scripts/check_imports.swift @@ -135,8 +135,12 @@ private func checkFile(_ file: String, logger: ErrorLogger, inRepo repoURL: URL, let importFile = line.components(separatedBy: " ")[1] if inSwiftPackageElse { if importFile.first != "<" { - logger - .importLog("Import in SWIFT_PACKAGE #else should start with \"<\".", file, lineNum) + // SharedTestUtilities files are included directly in test targets and + // use repo-relative imports. + if !file.contains("SharedTestUtilities/") { + logger + .importLog("Import in SWIFT_PACKAGE #else should start with \"<\".", file, lineNum) + } } continue }