feat: Add Recaptcha attestation provider (#94)

Signed-off-by: Nick Cooke <36927374+ncooke3@users.noreply.github.com>
Co-authored-by: Hitesh Maurya <hiteshmaurya@google.com>
Co-authored-by: Paul Beusterien <paulbeusterien@google.com>
This commit is contained in:
authored and GitHub committed 2026-06-09 16:44:19 -04:00
1 parent f50adb523d
commit f6b4582ed1
57 files changed
+1869 -272

No files matched your search

@@ -0,0 +1,160 @@
// 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 SWIFT_PACKAGE
import AppCheckCore
#endif
import Foundation
import Promises
import RecaptchaInterop
/// Firebase App Check provider that verifies app integrity using the
/// [reCAPTCHA Enterprise](https://cloud.google.com/recaptcha/docs/instrument-ios-apps)
/// API. This class's platform and OS availability matches reCAPTCHA
/// Enterprise's.
@available(iOS 15.0, visionOS 1.0, *)
@available(macOS, unavailable)
@available(macCatalyst, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@objc(GACRecaptchaProvider)
public final class AppCheckRecaptchaProvider: NSObject, AppCheckCoreProvider {
// This action name should never change without coordination with the backend.
private static let appCheckActionName = "app_check_ios"
@objc public static func isSupported() -> Bool {
return RecaptchaEnterpriseSDKLoader.isLinked
}
private let tokenGenerator: RecaptchaTokenGenerator?
private let apiService: RecaptchaAPIService
/// The default initializer.
/// - Parameters:
/// - siteKey: The reCAPTCHA site key.
/// - resourceName: The name of the resource protected by App Check; for a Firebase App this is
/// "projects/{project_id}/apps/{app_id}".
/// - APIKey: The Google Cloud Platform API key.
/// - requestHooks: Hooks that will be invoked on requests through this service.
// `@convention(block)` is required because the Swift compiler cannot automatically
// bridge collections of closures (like an Array) to Objective-C blocks. This attribute
// changes the closure's representation to match the Objective-C block heap layout.
@objc public convenience init?(siteKey: String, resourceName: String, APIKey: String,
requestHooks: [@convention(block) (NSMutableURLRequest) -> Void]? =
nil) {
self.init(
siteKey: siteKey,
resourceName: resourceName,
APIKey: APIKey,
requestHooks: requestHooks,
actionName: Self.appCheckActionName
)
}
@objc public convenience init?(siteKey: String, resourceName: String, APIKey: String,
requestHooks: [@convention(block) (NSMutableURLRequest) -> Void]? =
nil,
actionName: String) {
guard let sdk = RecaptchaEnterpriseSDKLoader(customAction: actionName) else {
return nil
}
let backoffWrapper = _GACAppCheckBackoffWrapper()
let tokenGenerator = RecaptchaTokenGenerator(
siteKey: siteKey,
recaptchaAction: sdk.action,
recaptchaClass: sdk.recaptchaClass,
backoffWrapper: backoffWrapper
)
let urlSession = URLSession(configuration: .ephemeral)
let appCheckAPIService = _GACAppCheckAPIService(urlSession: urlSession,
baseURL: nil,
apiKey: APIKey,
requestHooks: requestHooks)
let apiService = RecaptchaAPIService(
apiService: appCheckAPIService,
resourceName: resourceName
)
self.init(tokenGenerator: tokenGenerator, apiService: apiService)
}
init(tokenGenerator: RecaptchaTokenGenerator?,
apiService: RecaptchaAPIService) {
self.tokenGenerator = tokenGenerator
self.apiService = apiService
super.init()
}
@objc(getTokenWithCompletion:)
public func getToken(completion handler: @escaping (AppCheckCoreToken?, (any Error)?) -> Void) {
getToken(limitedUse: false)
.then { token in
handler(token, nil)
}.catch { error in
handler(nil, error)
}
}
@objc(getLimitedUseTokenWithCompletion:)
public func getLimitedUseToken(completion handler: @escaping (AppCheckCoreToken?, (any Error)?)
-> Void) {
getToken(limitedUse: true)
.then { token in
handler(token, nil)
}.catch { error in
handler(nil, error)
}
}
private func getToken(limitedUse: Bool) -> Promise<AppCheckCoreToken> {
guard let tokenGenerator else {
return Promise(_GACAppCheckErrorUtil.missingRecaptchaSDKError())
}
return tokenGenerator.getRecaptchaToken()
.then { recaptchaToken in
self.apiService.appCheckToken(
with: recaptchaToken,
limitedUse: limitedUse
)
}
}
}
private struct RecaptchaEnterpriseSDKLoader {
// These symbols are specified in the RecaptchaEnterprise SDK.
// See https://github.com/GoogleCloudPlatform/recaptcha-enterprise-mobile-sdk/blob/18.9.0/Sources/RecaptchaEnterprise/RecaptchaInteropBidings.swift
private static let actionClass =
NSClassFromString("RecaptchaEnterprise.RCAAction") as? RCAActionProtocol.Type
private static let recaptchaClass =
NSClassFromString("RecaptchaEnterprise.RCARecaptcha") as? RCARecaptchaProtocol.Type
static var isLinked: Bool {
return actionClass != nil && recaptchaClass != nil
}
let action: RCAActionProtocol
let recaptchaClass: RCARecaptchaProtocol.Type
init?(customAction: String) {
guard let actionClass = Self.actionClass,
let recaptchaClass = Self.recaptchaClass else {
return nil
}
action = actionClass.init(customAction: customAction)
self.recaptchaClass = recaptchaClass
}
}
@@ -0,0 +1,89 @@
// 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 SWIFT_PACKAGE
import AppCheckCore
#endif
import Foundation
import Promises
private enum Constants {
static let contentTypeKey = "Content-Type"
static let jsonContentType = "application/json"
static let recaptchaTokenField = "recaptcha_enterprise_token"
static let limitedUseField = "limited_use"
// This endpoint should never change without coordination with the backend.
static let exchangeEndpoint = "exchangeRecaptchaEnterpriseToken"
static let httpMethodPost = "POST"
}
@available(iOS 15.0, visionOS 1.0, *)
@available(macOS, unavailable)
@available(macCatalyst, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
final class RecaptchaAPIService: NSObject {
private let apiService: _GACAppCheckAPIServiceProtocol
private let resourceName: String
init(apiService: _GACAppCheckAPIServiceProtocol, resourceName: String) {
self.apiService = apiService
self.resourceName = resourceName
}
func appCheckToken(with recaptchaToken: String,
limitedUse: Bool) -> Promise<AppCheckCoreToken> {
let urlString = "\(apiService.baseURL)/\(resourceName):\(Constants.exchangeEndpoint)"
guard let url = URL(string: urlString) else {
return Promise(_GACAppCheckErrorUtil
.error(withFailureReason: "Invalid URL string: \(urlString)"))
}
let httpBody: Data
do {
httpBody = try self.httpBody(with: recaptchaToken, limitedUse: limitedUse)
} catch {
return Promise(error)
}
return Promise<_GACURLSessionDataResponse>(apiService.sendRequest(with: url,
httpMethod: Constants
.httpMethodPost,
body: httpBody,
additionalHeaders: [Constants
.contentTypeKey: Constants
.jsonContentType]))
.then { response in
Promise<AppCheckCoreToken>(self.apiService.appCheckToken(withAPIResponse: response))
}
}
private func httpBody(with recaptchaToken: String,
limitedUse: Bool) throws -> Data {
guard !recaptchaToken.isEmpty else {
throw _GACAppCheckErrorUtil.error(withFailureReason: "Recaptcha token cannot be empty")
}
let payload: [String: Any] = [
Constants.recaptchaTokenField: recaptchaToken,
Constants.limitedUseField: limitedUse,
]
do {
return try JSONSerialization.data(withJSONObject: payload, options: [])
} catch {
throw _GACAppCheckErrorUtil.jsonSerializationError(error)
}
}
}
@@ -0,0 +1,127 @@
// 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 SWIFT_PACKAGE
import AppCheckCore
#endif
import FBLPromises
import Foundation
import Promises
import RecaptchaInterop
@available(iOS 15.0, visionOS 1.0, *)
@available(macOS, unavailable)
@available(macCatalyst, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
final class RecaptchaTokenGenerator {
// Corresponds to RecaptchaErrorNetworkError. These codes are not in the interop.
// See https://docs.cloud.google.com/recaptcha/docs/reference/ios/client/api/Enums/RecaptchaErrorCode.html#recaptchaerrornetworkerror
static let networkErrorCode = 1
// Corresponds to RecaptchaErrorCodeInternalError. These codes are not in the interop.
// See https://docs.cloud.google.com/recaptcha/docs/reference/ios/client/api/Enums/RecaptchaErrorCode.html#recaptchaerrorcodeinternalerror
static let internalErrorCode = 100
private let recaptchaAction: RCAActionProtocol
private let recaptchaClient: Promise<RCARecaptchaClientProtocol>
private let backoffWrapper: _GACAppCheckBackoffWrapperProtocol
init(siteKey: String, recaptchaAction: RCAActionProtocol,
recaptchaClass: RCARecaptchaProtocol.Type,
backoffWrapper: _GACAppCheckBackoffWrapperProtocol) {
self.recaptchaAction = recaptchaAction
self.backoffWrapper = backoffWrapper
// Note: `fetchClient` is called only once and its result (including
// failure) is cached. reCAPTCHA engineers have confirmed that
// `fetchClient` handles transient errors internally and only fails on
// permanent integration errors (e.g., invalid site key). Therefore,
// retrying `fetchClient` on failure is unnecessary and not recommended.
recaptchaClient = Promise<RCARecaptchaClientProtocol> { fulfill, reject in
recaptchaClass.fetchClient(withSiteKey: siteKey) { client, error in
if let client {
fulfill(client)
} else {
reject(error ?? _GACAppCheckErrorUtil
.error(withFailureReason: "Failed to fetch Recaptcha client"))
}
}
}
}
func getRecaptchaToken() -> Promise<String> {
return recaptchaClient.then { client in
let operationProvider: GACAppCheckBackoffOperationProvider = {
let swiftPromise = Promise<AnyObject> { fulfill, reject in
client.execute(withAction: self.recaptchaAction) { token, error in
if let token {
fulfill(token as AnyObject)
} else {
reject(self.mapRecaptchaError(error))
}
}
}
return swiftPromise.asObjCPromise()
}
let errorHandler: GACAppCheckBackoffErrorHandler = { error in
let nsError = error as NSError
if nsError.domain == AppCheckCoreErrorDomain && nsError.code == AppCheckCoreErrorCode
.serverUnreachable.rawValue {
return .typeExponential
}
return .typeNone
}
let fblPromise = self.backoffWrapper.applyBackoff(
toOperation: operationProvider,
errorHandler: errorHandler
)
return Promise<AnyObject>(fblPromise).then { result in
guard let token = result as? String else {
throw _GACAppCheckErrorUtil
.error(
withFailureReason: "Unexpected result type from reCAPTCHA token exchange: \(type(of: result)). Expected String."
)
}
return token
}
}
}
private func mapRecaptchaError(_ error: Error?) -> Error {
guard let error = error as NSError? else {
return _GACAppCheckErrorUtil.error(withFailureReason: "Failed to execute Recaptcha action")
}
// Map RecaptchaErrorNetworkError and RecaptchaErrorCodeInternalError.
// See https://docs.cloud.google.com/recaptcha/docs/reference/ios/client/api/Enums/RecaptchaErrorCode.html
if error.code == Self.networkErrorCode || error.code == Self.internalErrorCode {
return _GACAppCheckErrorUtil.apiError(withNetworkError: error)
}
// Preserve underlying error for others
var userInfo: [String: Any] = [NSUnderlyingErrorKey: error]
if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] {
userInfo[NSLocalizedFailureReasonErrorKey] = reason
}
return NSError(
domain: AppCheckCoreErrorDomain,
code: AppCheckCoreErrorCode.unknown.rawValue,
userInfo: userInfo
)
}
}
@@ -0,0 +1,190 @@
// 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 XCTest
@testable import AppCheckCore
@testable import AppCheckRecaptchaProvider
import Promises
@available(iOS 15.0, visionOS 1.0, *)
@available(macOS, unavailable)
@available(macCatalyst, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
final class AppCheckRecaptchaProviderTests: XCTestCase {
private var provider: AppCheckRecaptchaProvider!
private let testSiteKey = "test-site-key"
private let testResourceName = "projects/test-project/apps/test-app"
override func setUp() {
super.setUp()
let mockCoreAPIService = MockAppCheckCoreAPIService()
let apiService = RecaptchaAPIService(
apiService: mockCoreAPIService,
resourceName: testResourceName
)
provider = AppCheckRecaptchaProvider(
tokenGenerator: nil,
apiService: apiService
)
}
override func tearDown() {
provider = nil
super.tearDown()
}
func testIsSupportedReturnsFalse() {
XCTAssertFalse(AppCheckRecaptchaProvider.isSupported())
}
func testGetTokenWithoutRecaptchaSDK() {
// When the Recaptcha SDK is not linked, the tokenGenerator will be nil.
// We should expect an unsupported attestation provider error.
let expectation = self.expectation(description: "Get token fails without SDK")
provider.getToken { token, error in
XCTAssertNil(token)
XCTAssertNotNil(error)
let nsError = error as NSError?
XCTAssertEqual(nsError?.domain, AppCheckCoreErrorDomain)
XCTAssertEqual(nsError?.code, AppCheckCoreErrorCode.unsupported.rawValue)
XCTAssertEqual(
nsError?.localizedFailureReason,
"The reCAPTCHA Enterprise SDK is not linked. See https://cloud.google.com/recaptcha/docs/instrument-ios-apps#prepare-environment"
)
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
func testGetLimitedUseTokenWithoutRecaptchaSDK() {
let expectation = self.expectation(description: "Get limited use token fails without SDK")
provider.getLimitedUseToken { token, error in
XCTAssertNil(token)
XCTAssertNotNil(error)
let nsError = error as NSError?
XCTAssertEqual(nsError?.domain, AppCheckCoreErrorDomain)
XCTAssertEqual(nsError?.code, AppCheckCoreErrorCode.unsupported.rawValue)
XCTAssertEqual(
nsError?.localizedFailureReason,
"The reCAPTCHA Enterprise SDK is not linked. See https://cloud.google.com/recaptcha/docs/instrument-ios-apps#prepare-environment"
)
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
func testInitReturnsNilWithoutRecaptchaSDK() {
// When the Recaptcha SDK is not linked, the convenience initializer should return nil.
let provider = AppCheckRecaptchaProvider(
siteKey: testSiteKey,
resourceName: testResourceName,
APIKey: "test-api-key"
)
XCTAssertNil(provider)
}
func testInitWithCustomActionNameReturnsNilWithoutRecaptchaSDK() {
// When the Recaptcha SDK is not linked, the convenience initializer should return nil
// even with a custom action name.
let provider = AppCheckRecaptchaProvider(
siteKey: testSiteKey,
resourceName: testResourceName,
APIKey: "test-api-key",
actionName: "custom_action"
)
XCTAssertNil(provider)
}
private func createProviderWithMocks(expectedToken: AppCheckCoreToken)
-> AppCheckRecaptchaProvider {
let mockClient = MockRecaptchaClient()
mockClient.mockToken = "valid-recaptcha-token"
MockRecaptcha.mockClient = mockClient
let tokenGenerator = RecaptchaTokenGenerator(
siteKey: testSiteKey,
recaptchaAction: MockRCAAction(customAction: "app_check_ios"),
recaptchaClass: MockRecaptcha.self,
backoffWrapper: MockBackoffWrapper()
)
let mockCoreAPIService = MockAppCheckCoreAPIService()
mockCoreAPIService.expectedToken = expectedToken
let apiService = RecaptchaAPIService(
apiService: mockCoreAPIService,
resourceName: testResourceName
)
return AppCheckRecaptchaProvider(
tokenGenerator: tokenGenerator,
apiService: apiService
)
}
func testGetTokenSuccess() {
// Arrange
let expectedAppCheckToken = AppCheckCoreToken(
token: "app-check-token-456",
expirationDate: Date(timeIntervalSinceNow: 3600)
)
let providerWithMocks = createProviderWithMocks(expectedToken: expectedAppCheckToken)
let expectation = self.expectation(description: "Get token succeeds")
// Act
providerWithMocks.getToken { token, error in
// Assert
XCTAssertNotNil(token)
XCTAssertNil(error)
XCTAssertEqual(token?.token, expectedAppCheckToken.token)
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
func testGetLimitedUseTokenSuccess() {
// Arrange
let expectedAppCheckToken = AppCheckCoreToken(
token: "app-check-token-456",
expirationDate: Date(timeIntervalSinceNow: 3600)
)
let providerWithMocks = createProviderWithMocks(expectedToken: expectedAppCheckToken)
let expectation = self.expectation(description: "Get limited use token succeeds")
// Act
providerWithMocks.getLimitedUseToken { token, error in
// Assert
XCTAssertNotNil(token)
XCTAssertNil(error)
XCTAssertEqual(token?.token, expectedAppCheckToken.token)
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
}
@@ -0,0 +1,166 @@
// 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.
@testable import AppCheckCore
@testable import AppCheckRecaptchaProvider
import FBLPromises
import Foundation
import Promises
import RecaptchaInterop
class MockRCAAction: NSObject, RCAActionProtocol {
var action: String { return customAction }
// The following properties are required by RCAActionProtocol but not used in these tests.
static var login: RCAActionProtocol { return MockRCAAction(customAction: "login") }
static var signup: RCAActionProtocol { return MockRCAAction(customAction: "signup") }
let customAction: String
required init(customAction: String) {
self.customAction = customAction
super.init()
}
}
final class MockRecaptcha: NSObject, RCARecaptchaProtocol {
static var mockClient: MockRecaptchaClient?
static var mockError: Error?
// This initializer bypasses the unavailable `init()` in the protocol.
// The unlabeled `Void` parameter carries no data and is purely to change the signature.
init(_: Void = ()) {
super.init()
}
static func fetchClient(withSiteKey siteKey: String,
completion: @escaping (RCARecaptchaClientProtocol?, Error?) -> Void) {
if let mockError {
completion(nil, mockError)
} else {
completion(mockClient, nil)
}
}
}
final class MockRecaptchaClient: NSObject, RCARecaptchaClientProtocol {
var mockToken: String?
var mockError: Error?
// This initializer bypasses the unavailable `init()` in the protocol.
// The unlabeled `Void` parameter carries no data and is purely to change the signature.
init(_: Void = ()) {
super.init()
}
func execute(withAction action: RCAActionProtocol,
completion: @escaping (String?, Error?) -> Void) {
if let mockError {
completion(nil, mockError)
} else {
completion(mockToken, nil)
}
}
func execute(withAction action: RCAActionProtocol, withTimeout timeout: Double,
completion: @escaping (String?, Error?) -> Void) {
execute(withAction: action, completion: completion)
}
}
class MockAppCheckCoreAPIService: NSObject, _GACAppCheckAPIServiceProtocol {
var baseURL: String = "https://test.com"
struct RequestData {
let url: URL?
let httpMethod: String?
let body: Data?
let additionalHeaders: [String: String]?
}
var lastRequest: RequestData?
var expectedResponse: _GACURLSessionDataResponse?
var expectedToken: AppCheckCoreToken?
var expectedError: Error?
func sendRequest(with url: URL, httpMethod: String, body: Data?,
additionalHeaders: [String: String]?) -> FBLPromise<_GACURLSessionDataResponse> {
lastRequest = RequestData(
url: url,
httpMethod: httpMethod,
body: body,
additionalHeaders: additionalHeaders
)
let promise = Promise<_GACURLSessionDataResponse>.pending()
if let expectedError {
promise.reject(expectedError)
} else {
let response = expectedResponse ?? _GACURLSessionDataResponse(
response: HTTPURLResponse(),
httpBody: Data()
)
promise.fulfill(response)
}
return promise.asObjCPromise()
}
func appCheckToken(withAPIResponse response: _GACURLSessionDataResponse)
-> FBLPromise<AppCheckCoreToken> {
let promise = Promise<AppCheckCoreToken>.pending()
if let expectedError {
promise.reject(expectedError)
} else {
let token = expectedToken ?? AppCheckCoreToken(
token: "placeholder_app_check_token",
expirationDate: Date()
)
promise.fulfill(token)
}
return promise.asObjCPromise()
}
}
class MockBackoffWrapper: NSObject, _GACAppCheckBackoffWrapperProtocol {
var applyBackoffCalled = false
var shouldReturnError = false
var mockError: NSError?
var mockResult: Any?
var capturedErrorHandler: GACAppCheckBackoffErrorHandler?
func applyBackoff(toOperation operationProvider: @escaping GACAppCheckBackoffOperationProvider,
errorHandler: @escaping GACAppCheckBackoffErrorHandler)
-> FBLPromise<AnyObject> {
applyBackoffCalled = true
capturedErrorHandler = errorHandler
if shouldReturnError {
let error = mockError ?? NSError(domain: "MockBackoffWrapper", code: -1, userInfo: nil)
let swiftPromise = Promise<AnyObject>(error as Error)
return swiftPromise.asObjCPromise()
}
if let mockResult {
let swiftPromise = Promise<AnyObject>(mockResult as AnyObject)
return swiftPromise.asObjCPromise()
}
return operationProvider()
}
func defaultAppCheckProviderErrorHandler() -> GACAppCheckBackoffErrorHandler {
return { error in .typeExponential }
}
}
@@ -0,0 +1,137 @@
// 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 XCTest
@testable import AppCheckCore
@testable import AppCheckRecaptchaProvider
import FBLPromises
@available(iOS 15.0, visionOS 1.0, *)
@available(macOS, unavailable)
@available(macCatalyst, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
final class RecaptchaAPIServiceTests: XCTestCase {
private var apiService: RecaptchaAPIService!
private var mockCoreAPIService: MockAppCheckCoreAPIService!
private let testResourceName = "projects/test-project/apps/test-app"
private let testRecaptchaToken = "recaptcha-token-123"
override func setUp() {
super.setUp()
mockCoreAPIService = MockAppCheckCoreAPIService()
apiService = RecaptchaAPIService(
apiService: mockCoreAPIService,
resourceName: testResourceName
)
}
override func tearDown() {
apiService = nil
mockCoreAPIService = nil
super.tearDown()
}
func testAppCheckTokenSuccess() throws {
// Arrange
let expectedAppCheckToken = AppCheckCoreToken(
token: "app-check-token-456",
expirationDate: Date(timeIntervalSinceNow: 3600)
)
mockCoreAPIService.expectedToken = expectedAppCheckToken
let expectation = self.expectation(description: "Token exchange completes successfully")
// Act
apiService.appCheckToken(with: testRecaptchaToken, limitedUse: false)
.then { token in
// Assert
XCTAssertEqual(token.token, expectedAppCheckToken.token)
XCTAssertEqual(token.expirationDate, expectedAppCheckToken.expirationDate)
// Verify request
guard let request = self.mockCoreAPIService.lastRequest else {
XCTFail("No request was sent")
return
}
XCTAssertEqual(
request.url?.absoluteString,
"https://test.com/\(self.testResourceName):exchangeRecaptchaEnterpriseToken"
)
XCTAssertEqual(request.httpMethod, "POST")
XCTAssertEqual(request.additionalHeaders?["Content-Type"], "application/json")
if let body = request.body {
let json = try? JSONSerialization.jsonObject(with: body, options: []) as? [String: Any]
XCTAssertEqual(json?["recaptcha_enterprise_token"] as? String, self.testRecaptchaToken)
XCTAssertEqual(json?["limited_use"] as? Bool, false)
} else {
XCTFail("Request body was empty")
}
expectation.fulfill()
}.catch { error in
XCTFail("Unexpected error: \(error)")
}
waitForExpectations(timeout: 1.0)
}
func testAppCheckTokenLimitedUseSuccess() throws {
// Arrange
let expectedAppCheckToken = AppCheckCoreToken(
token: "app-check-token-456",
expirationDate: Date(timeIntervalSinceNow: 3600)
)
mockCoreAPIService.expectedToken = expectedAppCheckToken
let expectation = self
.expectation(description: "Limited use token exchange completes successfully")
// Act
apiService.appCheckToken(with: testRecaptchaToken, limitedUse: true)
.then { token in
// Assert
guard let request = self.mockCoreAPIService.lastRequest, let body = request.body else {
XCTFail("No request or body")
return
}
let json = try? JSONSerialization.jsonObject(with: body, options: []) as? [String: Any]
XCTAssertEqual(json?["limited_use"] as? Bool, true)
expectation.fulfill()
}.catch { error in
XCTFail("Unexpected error: \(error)")
}
waitForExpectations(timeout: 1.0)
}
func testAppCheckTokenEmptyRecaptchaToken() {
let expectation = self.expectation(description: "Token exchange fails with empty token")
apiService.appCheckToken(with: "", limitedUse: false).then { token in
XCTFail("Should not succeed with empty token")
}.catch { error in
XCTAssertNotNil(error)
XCTAssertEqual((error as NSError).domain, AppCheckCoreErrorDomain)
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
}
@@ -0,0 +1,366 @@
// 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 XCTest
@testable import AppCheckCore
@testable import AppCheckRecaptchaProvider
import FBLPromises
import Promises
import RecaptchaInterop
@available(iOS 15.0, visionOS 1.0, *)
@available(macOS, unavailable)
@available(macCatalyst, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
final class RecaptchaTokenGeneratorTests: XCTestCase {
private let testSiteKey = "test-site-key"
private var mockAction: MockRCAAction!
override func setUp() {
super.setUp()
mockAction = MockRCAAction(customAction: "test_action")
MockRecaptcha.mockClient = nil
MockRecaptcha.mockError = nil
}
func testGetRecaptchaTokenSuccess() {
// Arrange
let mockClient = MockRecaptchaClient()
mockClient.mockToken = "valid-recaptcha-token"
MockRecaptcha.mockClient = mockClient
let generator = RecaptchaTokenGenerator(
siteKey: testSiteKey,
recaptchaAction: mockAction,
recaptchaClass: MockRecaptcha.self,
backoffWrapper: MockBackoffWrapper()
)
let expectation = self.expectation(description: "Generates token successfully")
// Act
generator.getRecaptchaToken().then { token in
// Assert
XCTAssertEqual(token, "valid-recaptcha-token")
expectation.fulfill()
}.catch { error in
XCTFail("Unexpected error: \(error)")
}
waitForExpectations(timeout: 1.0)
}
func testGetRecaptchaTokenFetchClientFailure() {
// Arrange
let expectedError = NSError(domain: "test", code: -1, userInfo: nil)
MockRecaptcha.mockError = expectedError
let generator = RecaptchaTokenGenerator(
siteKey: testSiteKey,
recaptchaAction: mockAction,
recaptchaClass: MockRecaptcha.self,
backoffWrapper: MockBackoffWrapper()
)
let expectation = self.expectation(description: "Fails when fetchClient fails")
// Act
generator.getRecaptchaToken().then { token in
XCTFail("Should not succeed when fetchClient fails")
}.catch { error in
// Assert
XCTAssertEqual((error as NSError).domain, expectedError.domain)
XCTAssertEqual((error as NSError).code, expectedError.code)
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
func testGetRecaptchaTokenExecutionFailure() {
// Arrange
let mockClient = MockRecaptchaClient()
let expectedError = NSError(domain: "test", code: -2, userInfo: nil)
mockClient.mockError = expectedError
MockRecaptcha.mockClient = mockClient
let generator = RecaptchaTokenGenerator(
siteKey: testSiteKey,
recaptchaAction: mockAction,
recaptchaClass: MockRecaptcha.self,
backoffWrapper: MockBackoffWrapper()
)
let expectation = self.expectation(description: "Fails when execute fails")
// Act
generator.getRecaptchaToken().then { token in
XCTFail("Should not succeed when execute fails")
}.catch { error in
// Assert
let nsError = error as NSError
XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain)
XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.unknown.rawValue)
let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError
XCTAssertNotNil(underlyingError)
XCTAssertEqual(underlyingError?.domain, expectedError.domain)
XCTAssertEqual(underlyingError?.code, expectedError.code)
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
func testGetRecaptchaTokenCallsBackoffWrapper() {
// Arrange
let mockClient = MockRecaptchaClient()
mockClient.mockToken = "valid-recaptcha-token"
MockRecaptcha.mockClient = mockClient
let mockBackoffWrapper = MockBackoffWrapper()
let generator = RecaptchaTokenGenerator(
siteKey: testSiteKey,
recaptchaAction: mockAction,
recaptchaClass: MockRecaptcha.self,
backoffWrapper: mockBackoffWrapper
)
let expectation = self.expectation(description: "Calls backoff wrapper")
// Act
generator.getRecaptchaToken().then { token in
// Assert
XCTAssertTrue(mockBackoffWrapper.applyBackoffCalled)
expectation.fulfill()
}.catch { error in
XCTFail("Unexpected error: \(error)")
}
waitForExpectations(timeout: 1.0)
}
func testGetRecaptchaTokenBackoffWrapperError() {
// Arrange
let mockClient = MockRecaptchaClient()
MockRecaptcha.mockClient = mockClient
let mockBackoffWrapper = MockBackoffWrapper()
mockBackoffWrapper.shouldReturnError = true
let expectedError = NSError(domain: "test", code: -3, userInfo: nil)
mockBackoffWrapper.mockError = expectedError
let generator = RecaptchaTokenGenerator(
siteKey: testSiteKey,
recaptchaAction: mockAction,
recaptchaClass: MockRecaptcha.self,
backoffWrapper: mockBackoffWrapper
)
let expectation = self.expectation(description: "Fails when backoff wrapper fails")
// Act
generator.getRecaptchaToken().then { token in
XCTFail("Should not succeed when backoff wrapper fails")
}.catch { error in
// Assert
XCTAssertEqual((error as NSError).domain, expectedError.domain)
XCTAssertEqual((error as NSError).code, expectedError.code)
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
func testGetRecaptchaTokenMapsNetworkErrorToServerUnreachable() {
// Arrange
let mockClient = MockRecaptchaClient()
let recaptchaError = NSError(
domain: "RecaptchaErrorDomain",
code: RecaptchaTokenGenerator.networkErrorCode,
userInfo: nil
)
mockClient.mockError = recaptchaError
MockRecaptcha.mockClient = mockClient
let mockBackoffWrapper = MockBackoffWrapper()
let generator = RecaptchaTokenGenerator(
siteKey: testSiteKey,
recaptchaAction: mockAction,
recaptchaClass: MockRecaptcha.self,
backoffWrapper: mockBackoffWrapper
)
let expectation = self.expectation(description: "Maps NetworkError to ServerUnreachable")
// Act
generator.getRecaptchaToken().then { token in
XCTFail("Should not succeed when execute fails")
}.catch { error in
// Assert
let nsError = error as NSError
XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain)
XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.serverUnreachable.rawValue)
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
func testGetRecaptchaTokenMapsInternalErrorToServerUnreachable() {
// Arrange
let mockClient = MockRecaptchaClient()
let recaptchaError = NSError(
domain: "RecaptchaErrorDomain",
code: RecaptchaTokenGenerator.internalErrorCode,
userInfo: nil
)
mockClient.mockError = recaptchaError
MockRecaptcha.mockClient = mockClient
let mockBackoffWrapper = MockBackoffWrapper()
let generator = RecaptchaTokenGenerator(
siteKey: testSiteKey,
recaptchaAction: mockAction,
recaptchaClass: MockRecaptcha.self,
backoffWrapper: mockBackoffWrapper
)
let expectation = self.expectation(description: "Maps InternalError to ServerUnreachable")
// Act
generator.getRecaptchaToken().then { token in
XCTFail("Should not succeed when execute fails")
}.catch { error in
// Assert
let nsError = error as NSError
XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain)
XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.serverUnreachable.rawValue)
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
func testErrorHandlerTriggersBackoffForServerUnreachable() {
// Arrange
let mockClient = MockRecaptchaClient()
mockClient.mockToken = "valid-recaptcha-token"
MockRecaptcha.mockClient = mockClient
let mockBackoffWrapper = MockBackoffWrapper()
let generator = RecaptchaTokenGenerator(
siteKey: testSiteKey,
recaptchaAction: mockAction,
recaptchaClass: MockRecaptcha.self,
backoffWrapper: mockBackoffWrapper
)
let expectation = self.expectation(description: "Calls backoff wrapper")
// Act
generator.getRecaptchaToken().then { _ in
// Assert
XCTAssertNotNil(mockBackoffWrapper.capturedErrorHandler)
if let errorHandler = mockBackoffWrapper.capturedErrorHandler {
let serverUnreachableError = NSError(
domain: AppCheckCoreErrorDomain,
code: AppCheckCoreErrorCode.serverUnreachable.rawValue,
userInfo: nil
)
let backoffType = errorHandler(serverUnreachableError)
XCTAssertEqual(backoffType, .typeExponential)
}
expectation.fulfill()
}.catch { error in
XCTFail("Unexpected error: \(error)")
}
waitForExpectations(timeout: 1.0)
}
func testErrorHandlerDoesNotTriggerBackoffForOtherErrors() {
// Arrange
let mockClient = MockRecaptchaClient()
mockClient.mockToken = "valid-recaptcha-token"
MockRecaptcha.mockClient = mockClient
let mockBackoffWrapper = MockBackoffWrapper()
let generator = RecaptchaTokenGenerator(
siteKey: testSiteKey,
recaptchaAction: mockAction,
recaptchaClass: MockRecaptcha.self,
backoffWrapper: mockBackoffWrapper
)
let expectation = self.expectation(description: "Calls backoff wrapper")
// Act
generator.getRecaptchaToken().then { _ in
// Assert
XCTAssertNotNil(mockBackoffWrapper.capturedErrorHandler)
if let errorHandler = mockBackoffWrapper.capturedErrorHandler {
let otherError = NSError(
domain: AppCheckCoreErrorDomain,
code: AppCheckCoreErrorCode.unknown.rawValue,
userInfo: nil
)
let backoffType = errorHandler(otherError)
XCTAssertEqual(backoffType, .typeNone)
}
expectation.fulfill()
}.catch { error in
XCTFail("Unexpected error: \(error)")
}
waitForExpectations(timeout: 1.0)
}
func testGetRecaptchaTokenExecutionNilNilFallback() {
// Arrange
let mockClient = MockRecaptchaClient()
MockRecaptcha.mockClient = mockClient
let generator = RecaptchaTokenGenerator(
siteKey: testSiteKey,
recaptchaAction: mockAction,
recaptchaClass: MockRecaptcha.self,
backoffWrapper: MockBackoffWrapper()
)
let expectation = self
.expectation(description: "Fails with fallback error when execute returns nil, nil")
// Act
generator.getRecaptchaToken().then { token in
XCTFail("Should not succeed when execute returns nil, nil")
}.catch { error in
// Assert
let nsError = error as NSError
XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain)
XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.unknown.rawValue)
XCTAssertEqual(nsError.localizedFailureReason, "Failed to execute Recaptcha action")
expectation.fulfill()
}
waitForExpectations(timeout: 1.0)
}
}