diff --git a/.github/workflows/app_check_core.yml b/.github/workflows/app_check_core.yml index 5db50fb0..e89b0d3d 100644 --- a/.github/workflows/app_check_core.yml +++ b/.github/workflows/app_check_core.yml @@ -32,6 +32,15 @@ jobs: run: ./scripts/setup_bundler.sh - name: Select Xcode version run: sudo xcode-select -s /Applications/${{ matrix.xcode }}.app/Contents/Developer + - name: Install simulators in case they are missing. + if: contains(matrix.target, 'macos') == false + uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3 + with: + timeout_minutes: 15 + max_attempts: 5 + retry_wait_seconds: 120 + continue_on_error: true + command: xcodebuild -downloadPlatform ${{ matrix.target }} - name: Build and test run: | scripts/third_party/travis/retry.sh scripts/pod_lib_lint.rb AppCheckCore.podspec \ diff --git a/.github/workflows/spm.yml b/.github/workflows/spm.yml index 947ce905..6818b0ea 100644 --- a/.github/workflows/spm.yml +++ b/.github/workflows/spm.yml @@ -32,4 +32,4 @@ jobs: - name: Initialize xcodebuild run: xcodebuild -list - name: iOS Unit Tests - run: scripts/third_party/travis/retry.sh scripts/build.sh AppCheck ${{ matrix.platform }} spm + run: scripts/third_party/travis/retry.sh scripts/build.sh AppCheck-Package ${{ matrix.platform }} spm diff --git a/AppCheckCore.podspec b/AppCheckCore.podspec index 3a5784ae..e2b9a786 100644 --- a/AppCheckCore.podspec +++ b/AppCheckCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'AppCheckCore' - s.version = '11.2.0' + s.version = '11.3.0' s.summary = 'App Check Core SDK.' s.description = <<-DESC @@ -37,6 +37,9 @@ Pod::Spec.new do |s| s.source_files = [ base_dir + 'Sources/**/*.[mh]', ] + s.ios.source_files = [ + 'AppCheckRecaptchaProvider/Sources/**/*.swift', + ] s.public_header_files = base_dir + 'Sources/Public/AppCheckCore/*.h' s.ios.weak_framework = 'DeviceCheck' @@ -44,8 +47,10 @@ Pod::Spec.new do |s| s.tvos.weak_framework = 'DeviceCheck' s.dependency 'PromisesObjC', '~> 2.4' + s.dependency 'PromisesSwift', '~> 2.4' s.dependency 'GoogleUtilities/Environment', '~> 8.0' s.dependency 'GoogleUtilities/UserDefaults', '~> 8.0' + s.ios.dependency 'RecaptchaInterop', '~> 101.0' s.pod_target_xcconfig = { 'GCC_C_LANGUAGE_STANDARD' => 'c99', diff --git a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.h b/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.h index 8b249721..dad0a1a4 100644 --- a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.h +++ b/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.h @@ -19,7 +19,7 @@ @class FBLPromise; @class GACAppAttestAttestationResponse; @class GACAppCheckToken; -@protocol GACAppCheckAPIServiceProtocol; +@protocol _GACAppCheckAPIServiceProtocol; NS_ASSUME_NONNULL_BEGIN @@ -55,11 +55,11 @@ NS_ASSUME_NONNULL_BEGIN /// Default initializer. /// -/// @param APIService An instance implementing `GACAppCheckAPIServiceProtocol` to be used to send +/// @param APIService An instance implementing `_GACAppCheckAPIServiceProtocol` to be used to send /// network requests to the App Check backend. /// @param resourceName The name of the resource protected by App Check; for a Firebase App this is /// "projects/{project_id}/apps/{app_id}". -- (instancetype)initWithAPIService:(id)APIService +- (instancetype)initWithAPIService:(id<_GACAppCheckAPIServiceProtocol>)APIService resourceName:(NSString *)resourceName NS_DESIGNATED_INITIALIZER; - (instancetype)init NS_UNAVAILABLE; diff --git a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.m b/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.m index fe6dfd4f..4743a82d 100644 --- a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.m +++ b/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.m @@ -23,9 +23,9 @@ #endif #import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.h" -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h" -#import "AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h" NS_ASSUME_NONNULL_BEGIN @@ -46,7 +46,7 @@ @interface GACAppAttestAPIService () -@property(nonatomic, readonly) id APIService; +@property(nonatomic, readonly) id<_GACAppCheckAPIServiceProtocol> APIService; @property(nonatomic, readonly) NSString *resourceName; @@ -54,7 +54,7 @@ @interface GACAppAttestAPIService () @implementation GACAppAttestAPIService -- (instancetype)initWithAPIService:(id)APIService +- (instancetype)initWithAPIService:(id<_GACAppCheckAPIServiceProtocol>)APIService resourceName:(NSString *)resourceName { self = [super init]; if (self) { @@ -76,13 +76,13 @@ - (instancetype)initWithAPIService:(id)APIService challenge:challenge assertion:assertion limitedUse:limitedUse] - .then(^FBLPromise *(NSData *HTTPBody) { + .then(^FBLPromise<_GACURLSessionDataResponse *> *(NSData *HTTPBody) { return [self.APIService sendRequestWithURL:URL HTTPMethod:kHTTPMethodPost body:HTTPBody additionalHeaders:@{kContentTypeKey : kJSONContentType}]; }) - .then(^id _Nullable(GACURLSessionDataResponse *_Nullable response) { + .then(^id _Nullable(_GACURLSessionDataResponse *_Nullable response) { return [self.APIService appCheckTokenWithAPIResponse:response]; }); } @@ -99,14 +99,14 @@ - (instancetype)initWithAPIService:(id)APIService body:nil additionalHeaders:nil]; }] - .then(^id _Nullable(GACURLSessionDataResponse *_Nullable response) { + .then(^id _Nullable(_GACURLSessionDataResponse *_Nullable response) { return [self randomChallengeWithAPIResponse:response]; }); } #pragma mark - Challenge response parsing -- (FBLPromise *)randomChallengeWithAPIResponse:(GACURLSessionDataResponse *)response { +- (FBLPromise *)randomChallengeWithAPIResponse:(_GACURLSessionDataResponse *)response { return [FBLPromise onQueue:[self backgroundQueue] do:^id _Nullable { NSError *error; @@ -122,7 +122,7 @@ - (instancetype)initWithAPIService:(id)APIService - (nullable NSData *)randomChallengeFromResponseBody:(NSData *)response error:(NSError **)outError { if (response.length <= 0) { GACAppCheckSetErrorToPointer( - [GACAppCheckErrorUtil errorWithFailureReason:@"Empty server response body."], outError); + [_GACAppCheckErrorUtil errorWithFailureReason:@"Empty server response body."], outError); return nil; } @@ -132,14 +132,15 @@ - (nullable NSData *)randomChallengeFromResponseBody:(NSData *)response error:(N error:&JSONError]; if (![responseDict isKindOfClass:[NSDictionary class]]) { - GACAppCheckSetErrorToPointer([GACAppCheckErrorUtil JSONSerializationError:JSONError], outError); + GACAppCheckSetErrorToPointer([_GACAppCheckErrorUtil JSONSerializationError:JSONError], + outError); return nil; } NSString *challenge = responseDict[@"challenge"]; if (![challenge isKindOfClass:[NSString class]]) { GACAppCheckSetErrorToPointer( - [GACAppCheckErrorUtil appCheckTokenResponseErrorWithMissingField:@"challenge"], outError); + [_GACAppCheckErrorUtil appCheckTokenResponseErrorWithMissingField:@"challenge"], outError); return nil; } @@ -159,14 +160,14 @@ - (nullable NSData *)randomChallengeFromResponseBody:(NSData *)response error:(N keyID:keyID challenge:challenge limitedUse:limitedUse] - .then(^FBLPromise *(NSData *HTTPBody) { + .then(^FBLPromise<_GACURLSessionDataResponse *> *(NSData *HTTPBody) { return [self.APIService sendRequestWithURL:URL HTTPMethod:kHTTPMethodPost body:HTTPBody additionalHeaders:@{kContentTypeKey : kJSONContentType}]; }) .thenOn( - [self backgroundQueue], ^id _Nullable(GACURLSessionDataResponse *_Nullable URLResponse) { + [self backgroundQueue], ^id _Nullable(_GACURLSessionDataResponse *_Nullable URLResponse) { NSError *error; __auto_type response = @@ -186,7 +187,7 @@ - (nullable NSData *)randomChallengeFromResponseBody:(NSData *)response error:(N limitedUse:(BOOL)limitedUse { if (artifact.length <= 0 || challenge.length <= 0 || assertion.length <= 0) { FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:[GACAppCheckErrorUtil + [rejectedPromise reject:[_GACAppCheckErrorUtil errorWithFailureReason:@"Missing or empty request parameter."]]; return rejectedPromise; } @@ -210,7 +211,7 @@ - (nullable NSData *)randomChallengeFromResponseBody:(NSData *)response error:(N limitedUse:(BOOL)limitedUse { if (attestation.length <= 0 || keyID.length <= 0 || challenge.length <= 0) { FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:[GACAppCheckErrorUtil + [rejectedPromise reject:[_GACAppCheckErrorUtil errorWithFailureReason:@"Missing or empty request parameter."]]; return rejectedPromise; } @@ -237,7 +238,7 @@ - (nullable NSData *)randomChallengeFromResponseBody:(NSData *)response error:(N if (payloadJSON) { [HTTPBodyPromise fulfill:payloadJSON]; } else { - [HTTPBodyPromise reject:[GACAppCheckErrorUtil JSONSerializationError:encodingError]]; + [HTTPBodyPromise reject:[_GACAppCheckErrorUtil JSONSerializationError:encodingError]]; } return HTTPBodyPromise; } diff --git a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.m b/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.m index 175e5939..906d5ae4 100644 --- a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.m +++ b/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.m @@ -17,7 +17,7 @@ #import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.h" #import "AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" static NSString *const kResponseFieldAppCheckTokenDict = @"appCheckToken"; static NSString *const kResponseFieldArtifact = @"artifact"; @@ -38,7 +38,7 @@ - (nullable instancetype)initWithResponseData:(NSData *)response error:(NSError **)outError { if (response.length <= 0) { GACAppCheckSetErrorToPointer( - [GACAppCheckErrorUtil + [_GACAppCheckErrorUtil errorWithFailureReason: @"Failed to parse the initial handshake response. Empty server response body."], outError); @@ -51,14 +51,15 @@ - (nullable instancetype)initWithResponseData:(NSData *)response error:&JSONError]; if (![responseDict isKindOfClass:[NSDictionary class]]) { - GACAppCheckSetErrorToPointer([GACAppCheckErrorUtil JSONSerializationError:JSONError], outError); + GACAppCheckSetErrorToPointer([_GACAppCheckErrorUtil JSONSerializationError:JSONError], + outError); return nil; } NSString *artifactBase64String = responseDict[kResponseFieldArtifact]; if (![artifactBase64String isKindOfClass:[NSString class]]) { GACAppCheckSetErrorToPointer( - [GACAppCheckErrorUtil + [_GACAppCheckErrorUtil appAttestAttestationResponseErrorWithMissingField:kResponseFieldArtifact], outError); return nil; @@ -67,7 +68,7 @@ - (nullable instancetype)initWithResponseData:(NSData *)response options:0]; if (artifactData == nil) { GACAppCheckSetErrorToPointer( - [GACAppCheckErrorUtil + [_GACAppCheckErrorUtil appAttestAttestationResponseErrorWithMissingField:kResponseFieldArtifact], outError); return nil; @@ -76,7 +77,7 @@ - (nullable instancetype)initWithResponseData:(NSData *)response NSDictionary *appCheckTokenDict = responseDict[kResponseFieldAppCheckTokenDict]; if (![appCheckTokenDict isKindOfClass:[NSDictionary class]]) { GACAppCheckSetErrorToPointer( - [GACAppCheckErrorUtil + [_GACAppCheckErrorUtil appAttestAttestationResponseErrorWithMissingField:kResponseFieldAppCheckTokenDict], outError); return nil; diff --git a/AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.m b/AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.m index 1d0abf82..c637546d 100644 --- a/AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.m +++ b/AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.m @@ -18,7 +18,7 @@ #import "AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" @implementation GACAppAttestRejectionError diff --git a/AppCheckCore/Sources/AppAttestProvider/GACAppAttestProvider.m b/AppCheckCore/Sources/AppAttestProvider/GACAppAttestProvider.m index 4db96470..d8a98eea 100644 --- a/AppCheckCore/Sources/AppAttestProvider/GACAppAttestProvider.m +++ b/AppCheckCore/Sources/AppAttestProvider/GACAppAttestProvider.m @@ -30,17 +30,17 @@ #import "AppCheckCore/Sources/AppAttestProvider/GACAppAttestService.h" #import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.h" #import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.h" -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h" -#import "AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.h" #import "AppCheckCore/Sources/Core/GACAppCheckLogger+Internal.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckBackoffWrapper.h" #import "AppCheckCore/Sources/Core/Utils/GACAppCheckCryptoUtils.h" #import "AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" NS_ASSUME_NONNULL_BEGIN @@ -108,7 +108,7 @@ @interface GACAppAttestProvider () @property(nonatomic, readonly) id appAttestService; @property(nonatomic, readonly) id keyIDStorage; @property(nonatomic, readonly) id artifactStorage; -@property(nonatomic, readonly) id backoffWrapper; +@property(nonatomic, readonly) id<_GACAppCheckBackoffWrapperProtocol> backoffWrapper; @property(nonatomic, nullable) FBLPromise *ongoingGetTokenOperation; @property(nonatomic, assign) BOOL ongoingGetTokenOperationLimitedUse; @@ -123,7 +123,7 @@ - (instancetype)initWithAppAttestService:(id)appAttestServi APIService:(id)APIService keyIDStorage:(id)keyIDStorage artifactStorage:(id)artifactStorage - backoffWrapper:(id)backoffWrapper { + backoffWrapper:(id<_GACAppCheckBackoffWrapperProtocol>)backoffWrapper { self = [super init]; if (self) { _appAttestService = appAttestService; @@ -151,11 +151,11 @@ - (instancetype)initWithServiceName:(NSString *)serviceName GACAppAttestKeyIDStorage *keyIDStorage = [[GACAppAttestKeyIDStorage alloc] initWithKeySuffix:storageKeySuffix]; - GACAppCheckAPIService *APIService = - [[GACAppCheckAPIService alloc] initWithURLSession:URLSession - baseURL:baseURL - APIKey:APIKey - requestHooks:requestHooks]; + _GACAppCheckAPIService *APIService = + [[_GACAppCheckAPIService alloc] initWithURLSession:URLSession + baseURL:baseURL + APIKey:APIKey + requestHooks:requestHooks]; GACAppAttestAPIService *appAttestAPIService = [[GACAppAttestAPIService alloc] initWithAPIService:APIService resourceName:resourceName]; @@ -164,7 +164,7 @@ - (instancetype)initWithServiceName:(NSString *)serviceName [[GACAppAttestArtifactStorage alloc] initWithKeySuffix:storageKeySuffix accessGroup:accessGroup]; - GACAppCheckBackoffWrapper *backoffWrapper = [[GACAppCheckBackoffWrapper alloc] init]; + _GACAppCheckBackoffWrapper *backoffWrapper = [[_GACAppCheckBackoffWrapper alloc] init]; return [self initWithAppAttestService:DCAppAttestService.sharedService APIService:appAttestAPIService @@ -341,9 +341,10 @@ - (void)getTokenWithLimitedUse:(BOOL)limitedUse completionHandler:handler]; }] .recoverOn(self.queue, ^id(NSError *error) { - return [GACAppCheckErrorUtil appAttestAttestKeyFailedWithError:error - keyId:keyID - clientDataHash:challengeHash]; + return + [_GACAppCheckErrorUtil appAttestAttestKeyFailedWithError:error + keyId:keyID + clientDataHash:challengeHash]; }); }) .thenOn(self.queue, ^FBLPromise *(NSData *attestation) { @@ -488,7 +489,7 @@ - (void)getTokenWithLimitedUse:(BOOL)limitedUse completionHandler:handler]; }] .recoverOn(self.queue, ^id(NSError *appAttestError) { - NSError *error = [GACAppCheckErrorUtil + NSError *error = [_GACAppCheckErrorUtil appAttestGenerateAssertionFailedWithError:appAttestError keyId:keyID clientDataHash:statementHash]; @@ -570,7 +571,7 @@ - (void)getTokenWithLimitedUse:(BOOL)limitedUse if (self.appAttestService.isSupported) { return [FBLPromise resolvedWith:[NSNull null]]; } else { - NSError *error = [GACAppCheckErrorUtil unsupportedAttestationProvider:@"AppAttestProvider"]; + NSError *error = [_GACAppCheckErrorUtil unsupportedAttestationProvider:@"AppAttestProvider"]; FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; [rejectedPromise reject:error]; return rejectedPromise; @@ -596,7 +597,7 @@ - (void)getTokenWithLimitedUse:(BOOL)limitedUse }] .recoverOn(self.queue, ^id(NSError *error) { - return [GACAppCheckErrorUtil appAttestGenerateKeyFailedWithError:error]; + return [_GACAppCheckErrorUtil appAttestGenerateKeyFailedWithError:error]; }) .thenOn(self.queue, ^FBLPromise *(NSString *keyID) { return [self.keyIDStorage setAppAttestKeyID:keyID]; diff --git a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.m b/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.m index e8effbc6..1f91ea97 100644 --- a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.m +++ b/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.m @@ -25,7 +25,7 @@ #import #import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" NS_ASSUME_NONNULL_BEGIN @@ -78,14 +78,14 @@ - (instancetype)initWithKeySuffix:(NSString *)keySuffix } }) .recover(^NSError *(NSError *error) { - return [GACAppCheckErrorUtil keychainErrorWithError:error]; + return [_GACAppCheckErrorUtil keychainErrorWithError:error]; }); } - (FBLPromise *)setArtifact:(nullable NSData *)artifact forKey:(nonnull NSString *)keyID { if (artifact) { return [self storeArtifact:artifact forKey:keyID].recover(^NSError *(NSError *error) { - return [GACAppCheckErrorUtil keychainErrorWithError:error]; + return [_GACAppCheckErrorUtil keychainErrorWithError:error]; }); } else { return [FBLPromise wrapErrorCompletion:^(FBLPromiseErrorCompletion _Nonnull handler) { @@ -97,7 +97,7 @@ - (instancetype)initWithKeySuffix:(NSString *)keySuffix return nil; }) .recover(^NSError *(NSError *error) { - return [GACAppCheckErrorUtil keychainErrorWithError:error]; + return [_GACAppCheckErrorUtil keychainErrorWithError:error]; }); } } diff --git a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.m b/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.m index 4f3a97cd..a87f6e07 100644 --- a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.m +++ b/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.m @@ -24,7 +24,7 @@ #import -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" /// The `GULUserDefaults` suite name for the storage location of the app attest key ID. static NSString *const kKeyIDStorageDefaultsSuiteName = @"com.firebase.GACAppAttestKeyIDStorage"; @@ -59,7 +59,7 @@ - (instancetype)initWithKeySuffix:(NSString *)keySuffix { if (appAttestKeyID) { return [FBLPromise resolvedWith:appAttestKeyID]; } else { - NSError *error = [GACAppCheckErrorUtil appAttestKeyIDNotFound]; + NSError *error = [_GACAppCheckErrorUtil appAttestKeyIDNotFound]; FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; [rejectedPromise reject:error]; return rejectedPromise; diff --git a/AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.m b/AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.m index 2fc40307..3788b3b5 100644 --- a/AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.m +++ b/AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.m @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" #if __has_include() #import @@ -22,21 +22,27 @@ #endif #import "AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h" -#import "AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.h" #import "AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Core/GACAppCheckLogger+Internal.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckLogger.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h" NS_ASSUME_NONNULL_BEGIN static NSString *const kAPIKeyHeaderKey = @"X-Goog-Api-Key"; static NSString *const kBundleIdKey = @"X-Ios-Bundle-Identifier"; -static NSString *const kDefaultBaseURL = @"https://firebaseappcheck.googleapis.com/v1"; +static NSString *const kProdBaseURL = @"https://firebaseappcheck.googleapis.com/v1"; -@interface GACAppCheckAPIService () +#if !NDEBUG +static NSString *const kStagingBaseURL = + @"https://staging-firebaseappcheck.sandbox.googleapis.com/v1"; +static NSString *const kAppCheckUseStagingEnvKey = @"_AppCheckUseStaging"; +#endif + +@interface _GACAppCheckAPIService () @property(nonatomic, readonly) NSURLSession *URLSession; @property(nonatomic, readonly, nullable) NSString *APIKey; @@ -44,7 +50,7 @@ @interface GACAppCheckAPIService () @end -@implementation GACAppCheckAPIService +@implementation _GACAppCheckAPIService // Synthesize properties declared in a protocol. @synthesize baseURL = _baseURL; @@ -58,12 +64,30 @@ - (instancetype)initWithURLSession:(NSURLSession *)session _URLSession = session; _APIKey = APIKey; _requestHooks = requestHooks ? [requestHooks copy] : @[]; - _baseURL = baseURL ?: kDefaultBaseURL; + + NSString *resolvedBaseURL = baseURL; + +#if !NDEBUG + if (resolvedBaseURL == nil) { + BOOL useStaging = + [[[NSProcessInfo processInfo] environment][kAppCheckUseStagingEnvKey] boolValue]; + if (useStaging) { + resolvedBaseURL = kStagingBaseURL; + NSString *logMessage = + [NSString stringWithFormat: + @"App Check staging environment enabled. API calls will be routed to %@.", + kStagingBaseURL]; + GACAppCheckLogInfo(GACLoggerAppCheckMessageCodeStagingModeEnabled, logMessage); + } + } +#endif + + _baseURL = resolvedBaseURL ?: kProdBaseURL; } return self; } -- (FBLPromise *) +- (FBLPromise<_GACURLSessionDataResponse *> *) sendRequestWithURL:(NSURL *)requestURL HTTPMethod:(NSString *)HTTPMethod body:(nullable NSData *)body @@ -75,7 +99,7 @@ - (instancetype)initWithURLSession:(NSURLSession *)session .then(^id _Nullable(NSURLRequest *_Nullable request) { return [self sendURLRequest:request]; }) - .then(^id _Nullable(GACURLSessionDataResponse *_Nullable response) { + .then(^id _Nullable(_GACURLSessionDataResponse *_Nullable response) { return [self validateHTTPResponseStatusCode:response]; }); } @@ -116,19 +140,19 @@ - (instancetype)initWithURLSession:(NSURLSession *)session }]; } -- (FBLPromise *)sendURLRequest:(NSURLRequest *)request { +- (FBLPromise<_GACURLSessionDataResponse *> *)sendURLRequest:(NSURLRequest *)request { return [self.URLSession gac_dataTaskPromiseWithRequest:request] .recover(^id(NSError *networkError) { // Wrap raw network error into App Check domain error. - return [GACAppCheckErrorUtil APIErrorWithNetworkError:networkError]; + return [_GACAppCheckErrorUtil APIErrorWithNetworkError:networkError]; }) - .then(^id _Nullable(GACURLSessionDataResponse *response) { + .then(^id _Nullable(_GACURLSessionDataResponse *response) { return [self validateHTTPResponseStatusCode:response]; }); } -- (FBLPromise *)validateHTTPResponseStatusCode: - (GACURLSessionDataResponse *)response { +- (FBLPromise<_GACURLSessionDataResponse *> *)validateHTTPResponseStatusCode: + (_GACURLSessionDataResponse *)response { NSInteger statusCode = response.HTTPResponse.statusCode; return [FBLPromise do:^id _Nullable { if (statusCode < 200 || statusCode >= 300) { @@ -137,15 +161,15 @@ - (instancetype)initWithURLSession:(NSURLSession *)session [[NSString alloc] initWithData:response.HTTPBody encoding:NSUTF8StringEncoding]]; GACAppCheckLogDebug(GACLoggerAppCheckMessageCodeUnexpectedHTTPCode, logMessage); - return [GACAppCheckErrorUtil APIErrorWithHTTPResponse:response.HTTPResponse - data:response.HTTPBody]; + return [_GACAppCheckErrorUtil APIErrorWithHTTPResponse:response.HTTPResponse + data:response.HTTPBody]; } return response; }]; } - (FBLPromise *)appCheckTokenWithAPIResponse: - (GACURLSessionDataResponse *)response { + (_GACURLSessionDataResponse *)response { return [FBLPromise onQueue:[self defaultQueue] do:^id _Nullable { NSError *error; diff --git a/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h b/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h index c4831a91..3298f05d 100644 --- a/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h +++ b/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h @@ -17,7 +17,7 @@ #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" @class FBLPromise; -@class GACURLSessionDataResponse; +@class _GACURLSessionDataResponse; NS_ASSUME_NONNULL_BEGIN diff --git a/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.m b/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.m index 6daf96b0..1ceba87b 100644 --- a/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.m +++ b/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.m @@ -22,7 +22,7 @@ #import "FBLPromises.h" #endif -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" static NSString *const kResponseFieldToken = @"token"; static NSString *const kResponseFieldTTL = @"ttl"; @@ -34,7 +34,7 @@ - (nullable instancetype)initWithTokenExchangeResponse:(NSData *)response error:(NSError **)outError { if (response.length <= 0) { GACAppCheckSetErrorToPointer( - [GACAppCheckErrorUtil errorWithFailureReason:@"Empty server response body."], outError); + [_GACAppCheckErrorUtil errorWithFailureReason:@"Empty server response body."], outError); return nil; } @@ -44,7 +44,8 @@ - (nullable instancetype)initWithTokenExchangeResponse:(NSData *)response error:&JSONError]; if (![responseDict isKindOfClass:[NSDictionary class]]) { - GACAppCheckSetErrorToPointer([GACAppCheckErrorUtil JSONSerializationError:JSONError], outError); + GACAppCheckSetErrorToPointer([_GACAppCheckErrorUtil JSONSerializationError:JSONError], + outError); return nil; } @@ -57,7 +58,7 @@ - (nullable instancetype)initWithResponseDict:(NSDictionary *)re NSString *token = responseDict[kResponseFieldToken]; if (![token isKindOfClass:[NSString class]]) { GACAppCheckSetErrorToPointer( - [GACAppCheckErrorUtil appCheckTokenResponseErrorWithMissingField:kResponseFieldToken], + [_GACAppCheckErrorUtil appCheckTokenResponseErrorWithMissingField:kResponseFieldToken], outError); return nil; } @@ -65,7 +66,7 @@ - (nullable instancetype)initWithResponseDict:(NSDictionary *)re NSString *timeToLiveString = responseDict[kResponseFieldTTL]; if (![token isKindOfClass:[NSString class]] || token.length <= 0) { GACAppCheckSetErrorToPointer( - [GACAppCheckErrorUtil appCheckTokenResponseErrorWithMissingField:kResponseFieldTTL], + [_GACAppCheckErrorUtil appCheckTokenResponseErrorWithMissingField:kResponseFieldTTL], outError); return nil; } @@ -77,7 +78,7 @@ - (nullable instancetype)initWithResponseDict:(NSDictionary *)re if (secondsToLive == 0) { GACAppCheckSetErrorToPointer( - [GACAppCheckErrorUtil appCheckTokenResponseErrorWithMissingField:kResponseFieldTTL], + [_GACAppCheckErrorUtil appCheckTokenResponseErrorWithMissingField:kResponseFieldTTL], outError); return nil; } diff --git a/AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.m b/AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.m index 6f050634..1842d1a5 100644 --- a/AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.m +++ b/AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.m @@ -14,9 +14,9 @@ * limitations under the License. */ -#import "AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h" -@implementation GACURLSessionDataResponse +@implementation _GACURLSessionDataResponse - (instancetype)initWithResponse:(NSHTTPURLResponse *)response HTTPBody:(NSData *)body { self = [super init]; diff --git a/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.h b/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.h index a79586c3..fb3a08f4 100644 --- a/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.h +++ b/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.h @@ -17,7 +17,7 @@ #import @class FBLPromise; -@class GACURLSessionDataResponse; +@class _GACURLSessionDataResponse; NS_ASSUME_NONNULL_BEGIN @@ -29,7 +29,7 @@ NS_ASSUME_NONNULL_BEGIN * @return A promise that is fulfilled when an HTTP response is received (with any response code), * or is rejected with the error passed to the task completion. */ -- (FBLPromise *)gac_dataTaskPromiseWithRequest: +- (FBLPromise<_GACURLSessionDataResponse *> *)gac_dataTaskPromiseWithRequest: (NSURLRequest *)URLRequest; @end diff --git a/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.m b/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.m index 831f3737..f9a79b03 100644 --- a/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.m +++ b/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.m @@ -22,11 +22,11 @@ #import "FBLPromises.h" #endif -#import "AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h" @implementation NSURLSession (GACPromises) -- (FBLPromise *)gac_dataTaskPromiseWithRequest: +- (FBLPromise<_GACURLSessionDataResponse *> *)gac_dataTaskPromiseWithRequest: (NSURLRequest *)URLRequest { return [FBLPromise async:^(FBLPromiseFulfillBlock fulfill, FBLPromiseRejectBlock reject) { [[self dataTaskWithRequest:URLRequest @@ -35,7 +35,7 @@ @implementation NSURLSession (GACPromises) if (error) { reject(error); } else { - fulfill([[GACURLSessionDataResponse alloc] + fulfill([[_GACURLSessionDataResponse alloc] initWithResponse:(NSHTTPURLResponse *)response HTTPBody:data]); } diff --git a/AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.m b/AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.m index 6a25eb8d..9241abcd 100644 --- a/AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.m +++ b/AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.m @@ -14,7 +14,7 @@ * limitations under the License. */ -#import "AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckBackoffWrapper.h" #if __has_include() #import @@ -22,8 +22,8 @@ #import "FBLPromises.h" #endif -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" NS_ASSUME_NONNULL_BEGIN @@ -99,7 +99,7 @@ + (instancetype)nextRetryFailureWithFailure: @end -@interface GACAppCheckBackoffWrapper () +@interface _GACAppCheckBackoffWrapper () /// Current date provider. Is used instead of `+[NSDate date]` for testability. @property(nonatomic, readonly) GACAppCheckDateProvider dateProvider; @@ -109,10 +109,10 @@ @interface GACAppCheckBackoffWrapper () @end -@implementation GACAppCheckBackoffWrapper +@implementation _GACAppCheckBackoffWrapper - (instancetype)init { - return [self initWithDateProvider:[GACAppCheckBackoffWrapper currentDateProvider]]; + return [self initWithDateProvider:[_GACAppCheckBackoffWrapper currentDateProvider]]; } - (instancetype)initWithDateProvider:(GACAppCheckDateProvider)dateProvider { @@ -202,7 +202,7 @@ - (FBLPromise *)promiseWithRetryDisallowedError:(NSError *)error { NSString *reason = [NSString stringWithFormat:@"Too many attempts. Underlying error: %@", error.localizedDescription ?: error.localizedFailureReason]; - NSError *retryDisallowedError = [GACAppCheckErrorUtil errorWithFailureReason:reason]; + NSError *retryDisallowedError = [_GACAppCheckErrorUtil errorWithFailureReason:reason]; FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; [rejectedPromise reject:retryDisallowedError]; return rejectedPromise; diff --git a/AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.m b/AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.m index 6983232f..6a491acc 100644 --- a/AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.m +++ b/AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.m @@ -14,7 +14,7 @@ * limitations under the License. */ -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" #import @@ -24,7 +24,11 @@ #import "AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" -@implementation GACAppCheckErrorUtil +NSString *const kGACAppCheckMissingRecaptchaSDKMessage = + @"The reCAPTCHA Enterprise SDK is not linked. See " + @"https://cloud.google.com/recaptcha/docs/instrument-ios-apps#prepare-environment"; + +@implementation _GACAppCheckErrorUtil + (NSError *)publicDomainErrorWithError:(NSError *)error { if ([error.domain isEqualToString:GACAppCheckErrorDomain]) { @@ -108,6 +112,12 @@ + (NSError *)unsupportedAttestationProvider:(NSString *)providerName { underlyingError:nil]; } ++ (NSError *)missingRecaptchaSDKError { + return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnsupported + failureReason:kGACAppCheckMissingRecaptchaSDKMessage + underlyingError:nil]; +} + + (NSError *)errorWithFailureReason:(NSString *)failureReason { return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnknown failureReason:failureReason diff --git a/AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.m b/AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.m index 4a2b20ea..3f79a408 100644 --- a/AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.m +++ b/AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.m @@ -16,8 +16,8 @@ #import "AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" @implementation GACAppCheckHTTPError diff --git a/AppCheckCore/Sources/Core/GACAppCheck.m b/AppCheckCore/Sources/Core/GACAppCheck.m index 6e1781d9..4492e327 100644 --- a/AppCheckCore/Sources/Core/GACAppCheck.m +++ b/AppCheckCore/Sources/Core/GACAppCheck.m @@ -29,11 +29,11 @@ #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckTokenDelegate.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckTokenResult.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Core/GACAppCheckLogger+Internal.h" #import "AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.h" #import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefreshResult.h" #import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefresher.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" NS_ASSUME_NONNULL_BEGIN @@ -166,19 +166,19 @@ - (void)limitedUseTokenWithCompletion:(GACAppCheckTokenHandler)handler { - (FBLPromise *)getCachedValidTokenForcingRefresh:(BOOL)forcingRefresh { if (forcingRefresh) { FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:[GACAppCheckErrorUtil cachedTokenNotFound]]; + [rejectedPromise reject:[_GACAppCheckErrorUtil cachedTokenNotFound]]; return rejectedPromise; } return [self.storage getToken].then(^id(GACAppCheckToken *_Nullable token) { if (token == nil) { - return [GACAppCheckErrorUtil cachedTokenNotFound]; + return [_GACAppCheckErrorUtil cachedTokenNotFound]; } BOOL isTokenExpiredOrExpiresSoon = [token.expirationDate timeIntervalSinceNow] < kTokenExpirationThreshold; if (isTokenExpiredOrExpiresSoon) { - return [GACAppCheckErrorUtil cachedTokenExpired]; + return [_GACAppCheckErrorUtil cachedTokenExpired]; } return token; diff --git a/AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.m b/AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.m index 588a8ea2..b26b52fb 100644 --- a/AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.m +++ b/AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.m @@ -24,8 +24,8 @@ #import -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken+GACAppCheckToken.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" NS_ASSUME_NONNULL_BEGIN @@ -75,14 +75,14 @@ - (instancetype)initWithTokenKey:(NSString *)tokenKey accessGroup:(nullable NSSt } }) .recover(^NSError *(NSError *error) { - return [GACAppCheckErrorUtil keychainErrorWithError:error]; + return [_GACAppCheckErrorUtil keychainErrorWithError:error]; }); } - (FBLPromise *)setToken:(nullable GACAppCheckToken *)token { if (token) { return [self storeToken:token].recover(^NSError *(NSError *error) { - return [GACAppCheckErrorUtil keychainErrorWithError:error]; + return [_GACAppCheckErrorUtil keychainErrorWithError:error]; }); } else { return [FBLPromise wrapErrorCompletion:^(FBLPromiseErrorCompletion _Nonnull handler) { @@ -94,7 +94,7 @@ - (instancetype)initWithTokenKey:(NSString *)tokenKey accessGroup:(nullable NSSt return token; }) .recover(^NSError *(NSError *error) { - return [GACAppCheckErrorUtil keychainErrorWithError:error]; + return [_GACAppCheckErrorUtil keychainErrorWithError:error]; }); } } diff --git a/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.h b/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.h index ac4c5e75..a75ad75c 100644 --- a/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.h +++ b/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.h @@ -18,7 +18,7 @@ @class FBLPromise; @class GACAppCheckToken; -@protocol GACAppCheckAPIServiceProtocol; +@protocol _GACAppCheckAPIServiceProtocol; NS_ASSUME_NONNULL_BEGIN @@ -33,12 +33,12 @@ NS_ASSUME_NONNULL_BEGIN : NSObject /// Default initializer. -/// @param APIService An instance implementing `GACAppCheckAPIServiceProtocol` to be used to send +/// @param APIService An instance implementing `_GACAppCheckAPIServiceProtocol` to be used to send /// network requests to the App Check backend. /// @param resourceName The name of the resource protected by App Check; for a Firebase App this is /// "projects/{project_id}/apps/{app_id}". See https://google.aip.dev/122 for more details about /// resource names. -- (instancetype)initWithAPIService:(id)APIService +- (instancetype)initWithAPIService:(id<_GACAppCheckAPIServiceProtocol>)APIService resourceName:(NSString *)resourceName; @end diff --git a/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.m b/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.m index e968b29e..3833ed66 100644 --- a/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.m +++ b/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.m @@ -22,11 +22,11 @@ #import "FBLPromises.h" #endif -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h" #import "AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Core/GACAppCheckLogger+Internal.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" NS_ASSUME_NONNULL_BEGIN @@ -37,7 +37,7 @@ @interface GACAppCheckDebugProviderAPIService () -@property(nonatomic, readonly) id APIService; +@property(nonatomic, readonly) id<_GACAppCheckAPIServiceProtocol> APIService; @property(nonatomic, readonly) NSString *resourceName; @@ -45,7 +45,7 @@ @interface GACAppCheckDebugProviderAPIService () @implementation GACAppCheckDebugProviderAPIService -- (instancetype)initWithAPIService:(id)APIService +- (instancetype)initWithAPIService:(id<_GACAppCheckAPIServiceProtocol>)APIService resourceName:(NSString *)resourceName { self = [super init]; if (self) { @@ -64,13 +64,13 @@ - (instancetype)initWithAPIService:(id)APIService NSURL *URL = [NSURL URLWithString:URLString]; return [self HTTPBodyWithDebugToken:debugToken limitedUse:limitedUse] - .then(^FBLPromise *(NSData *HTTPBody) { + .then(^FBLPromise<_GACURLSessionDataResponse *> *(NSData *HTTPBody) { return [self.APIService sendRequestWithURL:URL HTTPMethod:@"POST" body:HTTPBody additionalHeaders:@{kContentTypeKey : kJSONContentType}]; }) - .then(^id _Nullable(GACURLSessionDataResponse *_Nullable response) { + .then(^id _Nullable(_GACURLSessionDataResponse *_Nullable response) { return [self.APIService appCheckTokenWithAPIResponse:response]; }); } @@ -82,7 +82,7 @@ - (instancetype)initWithAPIService:(id)APIService if (debugToken.length <= 0) { FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; [rejectedPromise - reject:[GACAppCheckErrorUtil errorWithFailureReason:@"Debug token must not be empty."]]; + reject:[_GACAppCheckErrorUtil errorWithFailureReason:@"Debug token must not be empty."]]; return rejectedPromise; } @@ -100,7 +100,7 @@ - (instancetype)initWithAPIService:(id)APIService if (payloadJSON != nil) { return payloadJSON; } else { - return [GACAppCheckErrorUtil JSONSerializationError:encodingError]; + return [_GACAppCheckErrorUtil JSONSerializationError:encodingError]; } }]; } diff --git a/AppCheckCore/Sources/DebugProvider/GACAppCheckDebugProvider.m b/AppCheckCore/Sources/DebugProvider/GACAppCheckDebugProvider.m index b27e0868..16f3aa62 100644 --- a/AppCheckCore/Sources/DebugProvider/GACAppCheckDebugProvider.m +++ b/AppCheckCore/Sources/DebugProvider/GACAppCheckDebugProvider.m @@ -24,11 +24,11 @@ #import -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h" #import "AppCheckCore/Sources/Core/GACAppCheckLogger+Internal.h" #import "AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" NS_ASSUME_NONNULL_BEGIN @@ -60,11 +60,11 @@ - (instancetype)initWithServiceName:(NSString *)serviceName NSURLSession *URLSession = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration ephemeralSessionConfiguration]]; - GACAppCheckAPIService *APIService = - [[GACAppCheckAPIService alloc] initWithURLSession:URLSession - baseURL:baseURL - APIKey:APIKey - requestHooks:requestHooks]; + _GACAppCheckAPIService *APIService = + [[_GACAppCheckAPIService alloc] initWithURLSession:URLSession + baseURL:baseURL + APIKey:APIKey + requestHooks:requestHooks]; GACAppCheckDebugProviderAPIService *debugAPIService = [[GACAppCheckDebugProviderAPIService alloc] initWithAPIService:APIService diff --git a/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.h b/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.h index 079a208c..cbc4468e 100644 --- a/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.h +++ b/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.h @@ -18,7 +18,7 @@ @class FBLPromise; @class GACAppCheckToken; -@protocol GACAppCheckAPIServiceProtocol; +@protocol _GACAppCheckAPIServiceProtocol; NS_ASSUME_NONNULL_BEGIN @@ -32,12 +32,12 @@ NS_ASSUME_NONNULL_BEGIN @interface GACDeviceCheckAPIService : NSObject /// Default initializer. -/// @param APIService An instance implementing `GACAppCheckAPIServiceProtocol` to be used to send +/// @param APIService An instance implementing `_GACAppCheckAPIServiceProtocol` to be used to send /// network requests to the App Check backend. /// @param resourceName The name of the resource protected by App Check; for a Firebase App this is /// "projects/{project_id}/apps/{app_id}". See https://google.aip.dev/122 for more details about /// resource names. -- (instancetype)initWithAPIService:(id)APIService +- (instancetype)initWithAPIService:(id<_GACAppCheckAPIServiceProtocol>)APIService resourceName:(NSString *)resourceName; @end diff --git a/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.m b/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.m index 1fd7d22e..4ec9757e 100644 --- a/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.m +++ b/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.m @@ -22,11 +22,11 @@ #import "FBLPromises.h" #endif -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h" #import "AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Core/GACAppCheckLogger+Internal.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" NS_ASSUME_NONNULL_BEGIN @@ -37,7 +37,7 @@ @interface GACDeviceCheckAPIService () -@property(nonatomic, readonly) id APIService; +@property(nonatomic, readonly) id<_GACAppCheckAPIServiceProtocol> APIService; @property(nonatomic, readonly) NSString *resourceName; @@ -45,7 +45,7 @@ @interface GACDeviceCheckAPIService () @implementation GACDeviceCheckAPIService -- (instancetype)initWithAPIService:(id)APIService +- (instancetype)initWithAPIService:(id<_GACAppCheckAPIServiceProtocol>)APIService resourceName:(NSString *)resourceName { self = [super init]; if (self) { @@ -64,13 +64,13 @@ - (instancetype)initWithAPIService:(id)APIService NSURL *URL = [NSURL URLWithString:URLString]; return [self HTTPBodyWithDeviceToken:deviceToken limitedUse:limitedUse] - .then(^FBLPromise *(NSData *HTTPBody) { + .then(^FBLPromise<_GACURLSessionDataResponse *> *(NSData *HTTPBody) { return [self.APIService sendRequestWithURL:URL HTTPMethod:@"POST" body:HTTPBody additionalHeaders:@{kContentTypeKey : kJSONContentType}]; }) - .then(^id _Nullable(GACURLSessionDataResponse *_Nullable response) { + .then(^id _Nullable(_GACURLSessionDataResponse *_Nullable response) { return [self.APIService appCheckTokenWithAPIResponse:response]; }); } @@ -79,7 +79,7 @@ - (instancetype)initWithAPIService:(id)APIService limitedUse:(BOOL)limitedUse { if (deviceToken.length <= 0) { FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:[GACAppCheckErrorUtil + [rejectedPromise reject:[_GACAppCheckErrorUtil errorWithFailureReason:@"DeviceCheck token must not be empty."]]; return rejectedPromise; } @@ -100,7 +100,7 @@ - (instancetype)initWithAPIService:(id)APIService if (payloadJSON != nil) { return payloadJSON; } else { - return [GACAppCheckErrorUtil JSONSerializationError:encodingError]; + return [_GACAppCheckErrorUtil JSONSerializationError:encodingError]; } }]; } diff --git a/AppCheckCore/Sources/DeviceCheckProvider/GACDeviceCheckProvider.m b/AppCheckCore/Sources/DeviceCheckProvider/GACDeviceCheckProvider.m index 4888bc75..7d7fecda 100644 --- a/AppCheckCore/Sources/DeviceCheckProvider/GACDeviceCheckProvider.m +++ b/AppCheckCore/Sources/DeviceCheckProvider/GACDeviceCheckProvider.m @@ -26,24 +26,24 @@ #import "AppCheckCore/Sources/Public/AppCheckCore/GACDeviceCheckProvider.h" -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h" -#import "AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Core/GACAppCheckLogger+Internal.h" #import "AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.h" #import "AppCheckCore/Sources/DeviceCheckProvider/DCDevice+GACDeviceCheckTokenGenerator.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckBackoffWrapper.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" NS_ASSUME_NONNULL_BEGIN @interface GACDeviceCheckProvider () @property(nonatomic, readonly) id APIService; @property(nonatomic, readonly) id deviceTokenGenerator; -@property(nonatomic, readonly) id backoffWrapper; +@property(nonatomic, readonly) id<_GACAppCheckBackoffWrapperProtocol> backoffWrapper; - (instancetype)initWithAPIService:(id)APIService deviceTokenGenerator:(id)deviceTokenGenerator - backoffWrapper:(id)backoffWrapper + backoffWrapper:(id<_GACAppCheckBackoffWrapperProtocol>)backoffWrapper NS_DESIGNATED_INITIALIZER; @end @@ -52,7 +52,7 @@ @implementation GACDeviceCheckProvider - (instancetype)initWithAPIService:(id)APIService deviceTokenGenerator:(id)deviceTokenGenerator - backoffWrapper:(id)backoffWrapper { + backoffWrapper:(id<_GACAppCheckBackoffWrapperProtocol>)backoffWrapper { self = [super init]; if (self) { _APIService = APIService; @@ -63,7 +63,7 @@ - (instancetype)initWithAPIService:(id)APIServ } - (instancetype)initWithAPIService:(id)APIService { - GACAppCheckBackoffWrapper *backoffWrapper = [[GACAppCheckBackoffWrapper alloc] init]; + _GACAppCheckBackoffWrapper *backoffWrapper = [[_GACAppCheckBackoffWrapper alloc] init]; return [self initWithAPIService:APIService deviceTokenGenerator:[DCDevice currentDevice] backoffWrapper:backoffWrapper]; @@ -76,11 +76,11 @@ - (instancetype)initWithServiceName:(NSString *)serviceName NSURLSession *URLSession = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration ephemeralSessionConfiguration]]; - GACAppCheckAPIService *APIService = - [[GACAppCheckAPIService alloc] initWithURLSession:URLSession - baseURL:nil - APIKey:APIKey - requestHooks:requestHooks]; + _GACAppCheckAPIService *APIService = + [[_GACAppCheckAPIService alloc] initWithURLSession:URLSession + baseURL:nil + APIKey:APIKey + requestHooks:requestHooks]; GACDeviceCheckAPIService *deviceCheckAPIService = [[GACDeviceCheckAPIService alloc] initWithAPIService:APIService resourceName:resourceName]; @@ -146,7 +146,7 @@ - (void)getTokenWithLimitedUse:(BOOL)limitedUse if (self.deviceTokenGenerator.isSupported) { return [FBLPromise resolvedWith:[NSNull null]]; } else { - NSError *error = [GACAppCheckErrorUtil unsupportedAttestationProvider:@"DeviceCheckProvider"]; + NSError *error = [_GACAppCheckErrorUtil unsupportedAttestationProvider:@"DeviceCheckProvider"]; FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; [rejectedPromise reject:error]; return rejectedPromise; diff --git a/AppCheckCore/Sources/Public/AppCheckCore/AppCheckCore.h b/AppCheckCore/Sources/Public/AppCheckCore/AppCheckCore.h index a0752153..3c8dd2fb 100644 --- a/AppCheckCore/Sources/Public/AppCheckCore/AppCheckCore.h +++ b/AppCheckCore/Sources/Public/AppCheckCore/AppCheckCore.h @@ -31,3 +31,9 @@ // App Attest provider. #import "GACAppAttestProvider.h" + +// Internal headers exposed for interop with the Swift implementation. +#import "_GACAppCheckAPIService.h" +#import "_GACAppCheckBackoffWrapper.h" +#import "_GACAppCheckErrorUtil.h" +#import "_GACURLSessionDataResponse.h" diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h index 44b14b15..6a2e9e96 100644 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h +++ b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h @@ -45,6 +45,7 @@ typedef NS_ENUM(NSInteger, GACAppCheckMessageCode) { // App Check GACLoggerAppCheckMessageCodeProviderIsMissing = 2002, + GACLoggerAppCheckMessageCodeStagingModeEnabled = 2003, GACLoggerAppCheckMessageCodeUnexpectedHTTPCode = 3001, // Debug Provider diff --git a/AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h b/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h similarity index 76% rename from AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h rename to AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h index fdca400a..5ff0f27f 100644 --- a/AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h +++ b/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h @@ -16,30 +16,33 @@ #import -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckProvider.h" +#import "GACAppCheckProvider.h" @class FBLPromise; -@class GACURLSessionDataResponse; +@class _GACURLSessionDataResponse; @class GACAppCheckToken; NS_ASSUME_NONNULL_BEGIN -@protocol GACAppCheckAPIServiceProtocol +// This header is for internal use within Google SDKs (Firebase, Google Sign-In). +// It is not intended for use by external developers and may change without notice. + +@protocol _GACAppCheckAPIServiceProtocol @property(nonatomic, readonly) NSString *baseURL; -- (FBLPromise *) +- (FBLPromise<_GACURLSessionDataResponse *> *) sendRequestWithURL:(NSURL *)requestURL HTTPMethod:(NSString *)HTTPMethod body:(nullable NSData *)body additionalHeaders:(nullable NSDictionary *)additionalHeaders; - (FBLPromise *)appCheckTokenWithAPIResponse: - (GACURLSessionDataResponse *)response; + (_GACURLSessionDataResponse *)response; @end -@interface GACAppCheckAPIService : NSObject +@interface _GACAppCheckAPIService : NSObject <_GACAppCheckAPIServiceProtocol> /** * The default initializer. @@ -57,6 +60,9 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)init NS_UNAVAILABLE; +- (FBLPromise *)appCheckTokenWithAPIResponse: + (_GACURLSessionDataResponse *)response; + @end NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.h b/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckBackoffWrapper.h similarity index 92% rename from AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.h rename to AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckBackoffWrapper.h index 8b34e298..4fc5c0e8 100644 --- a/AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.h +++ b/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckBackoffWrapper.h @@ -20,6 +20,9 @@ NS_ASSUME_NONNULL_BEGIN +// This header is for internal use within Google SDKs (Firebase, Google Sign-In). +// It is not intended for use by external developers and may change without notice. + /// Backoff type. Backoff interval calculation depends on the type. typedef NS_ENUM(NSUInteger, GACAppCheckBackoffType) { /// No backoff. Another retry is allowed straight away. @@ -44,7 +47,7 @@ typedef NSDate *_Nonnull (^GACAppCheckDateProvider)(void); /// Defines API for an object that conditionally applies backoff to a given operation based on the /// history of previous operation failures. -@protocol GACAppCheckBackoffWrapperProtocol +@protocol _GACAppCheckBackoffWrapperProtocol /// Conditionally applies backoff to the given operation. /// @param operationProvider A block that returns a new promise. The block will be called only when @@ -71,7 +74,7 @@ typedef NSDate *_Nonnull (^GACAppCheckDateProvider)(void); /// Provides a backoff implementation. Keeps track of the operation successes and failures to either /// create and perform the operation promise or fails with a backoff error when the backoff is /// needed. -@interface GACAppCheckBackoffWrapper : NSObject +@interface _GACAppCheckBackoffWrapper : NSObject <_GACAppCheckBackoffWrapperProtocol> /// Initializes the wrapper with `+[GACAppCheckBackoffWrapper currentDateProvider]`. - (instancetype)init; diff --git a/AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h b/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h similarity index 85% rename from AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h rename to AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h index 5df000f7..811cf0ed 100644 --- a/AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h +++ b/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h @@ -20,9 +20,14 @@ NS_ASSUME_NONNULL_BEGIN +// This header is for internal use within Google SDKs (Firebase, Google Sign-In). +// It is not intended for use by external developers and may change without notice. + +extern NSString *const kGACAppCheckMissingRecaptchaSDKMessage NS_SWIFT_NAME(missingRecaptchaSDKMessage); + void GACAppCheckSetErrorToPointer(NSError *error, NSError **pointer); -@interface GACAppCheckErrorUtil : NSObject +@interface _GACAppCheckErrorUtil : NSObject + (NSError *)publicDomainErrorWithError:(NSError *)error; @@ -49,6 +54,8 @@ void GACAppCheckSetErrorToPointer(NSError *error, NSError **pointer); + (NSError *)unsupportedAttestationProvider:(NSString *)providerName; ++ (NSError *)missingRecaptchaSDKError; + // MARK: - App Attest Errors + (NSError *)appAttestKeyIDNotFound; diff --git a/AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.h b/AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h similarity index 81% rename from AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.h rename to AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h index e5482d45..07aeb335 100644 --- a/AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.h +++ b/AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h @@ -18,8 +18,11 @@ NS_ASSUME_NONNULL_BEGIN +// This header is for internal use within Google SDKs (Firebase, Google Sign-In). +// It is not intended for use by external developers and may change without notice. + /** The class represents HTTP response received from `NSURLSession`. */ -@interface GACURLSessionDataResponse : NSObject +@interface _GACURLSessionDataResponse : NSObject @property(nonatomic, readonly) NSHTTPURLResponse *HTTPResponse; @property(nonatomic, nullable, readonly) NSData *HTTPBody; diff --git a/AppCheckCore/Tests/Integration/GACDeviceCheckAPIServiceE2ETests.m b/AppCheckCore/Tests/Integration/GACDeviceCheckAPIServiceE2ETests.m index 0724ffec..83db330b 100644 --- a/AppCheckCore/Tests/Integration/GACDeviceCheckAPIServiceE2ETests.m +++ b/AppCheckCore/Tests/Integration/GACDeviceCheckAPIServiceE2ETests.m @@ -29,16 +29,16 @@ #import "FBLPromise+Testing.h" -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h" #import "AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" // TODO: Replace with real resource name to run on CI static NSString *const kResourceName = @"projects/test-project-id/google-app-id"; @interface GACDeviceCheckAPIServiceE2ETests : XCTestCase @property(nonatomic) GACDeviceCheckAPIService *deviceCheckAPIService; -@property(nonatomic) GACAppCheckAPIService *APIService; +@property(nonatomic) _GACAppCheckAPIService *APIService; @property(nonatomic) NSURLSession *URLSession; @end @@ -50,10 +50,10 @@ - (void)setUp { self.URLSession = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; - self.APIService = [[GACAppCheckAPIService alloc] initWithURLSession:self.URLSession - baseURL:nil - APIKey:nil - requestHooks:nil]; + self.APIService = [[_GACAppCheckAPIService alloc] initWithURLSession:self.URLSession + baseURL:nil + APIKey:nil + requestHooks:nil]; self.deviceCheckAPIService = [[GACDeviceCheckAPIService alloc] initWithAPIService:self.APIService resourceName:kResourceName]; } diff --git a/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestAPIServiceTests.m b/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestAPIServiceTests.m index ad5ea665..5212adb1 100644 --- a/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestAPIServiceTests.m +++ b/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestAPIServiceTests.m @@ -21,12 +21,12 @@ #import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.h" #import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.h" -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h" -#import "AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h" #import "AppCheckCore/Tests/Unit/Utils/GACFixtureLoader.h" #import "AppCheckCore/Tests/Utils/Date/GACDateTestUtils.h" @@ -48,7 +48,7 @@ @implementation GACAppAttestAPIServiceTests - (void)setUp { [super setUp]; - self.mockAPIService = OCMProtocolMock(@protocol(GACAppCheckAPIServiceProtocol)); + self.mockAPIService = OCMProtocolMock(@protocol(_GACAppCheckAPIServiceProtocol)); OCMStub([self.mockAPIService baseURL]).andReturn(kBaseURL); self.appAttestAPIService = [[GACAppAttestAPIService alloc] initWithAPIService:self.mockAPIService @@ -68,8 +68,8 @@ - (void)tearDown { - (void)testGetRandomChallengeWhenAPIResponseValid { // 1. Prepare API response. NSData *responseBody = [GACFixtureLoader loadFixtureNamed:@"AppAttestResponseSuccess.json"]; - GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; + _GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 + responseBody:responseBody]; // 2. Stub API Service Request to return prepared API response. [self stubMockAPIServiceRequestForChallengeRequestWithResponse:validAPIResponse]; @@ -93,11 +93,11 @@ - (void)testGetRandomChallengeWhenAPIError { // 1. Prepare API response. NSString *responseBodyString = @"Generate challenge failed with invalid format."; NSData *responseBody = [responseBodyString dataUsingEncoding:NSUTF8StringEncoding]; - GACURLSessionDataResponse *invalidAPIResponse = [self APIResponseWithCode:300 - responseBody:responseBody]; + _GACURLSessionDataResponse *invalidAPIResponse = [self APIResponseWithCode:300 + responseBody:responseBody]; GACAppCheckHTTPError *APIError = - [GACAppCheckErrorUtil APIErrorWithHTTPResponse:invalidAPIResponse.HTTPResponse - data:invalidAPIResponse.HTTPBody]; + [_GACAppCheckErrorUtil APIErrorWithHTTPResponse:invalidAPIResponse.HTTPResponse + data:invalidAPIResponse.HTTPBody]; // 2. Stub API Service Request to return prepared API response. [self stubMockAPIServiceRequestForChallengeRequestWithResponse:APIError]; @@ -123,8 +123,8 @@ - (void)testGetRandomChallengeWhenAPIError { - (void)testGetRandomChallengeWhenAPIResponseEmpty { // 1. Prepare API response. NSData *responseBody = [NSData data]; - GACURLSessionDataResponse *emptyAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; + _GACURLSessionDataResponse *emptyAPIResponse = [self APIResponseWithCode:200 + responseBody:responseBody]; // 2. Stub API Service Request to return prepared API response. [self stubMockAPIServiceRequestForChallengeRequestWithResponse:emptyAPIResponse]; @@ -146,8 +146,8 @@ - (void)testGetRandomChallengeWhenAPIResponseInvalidFormat { // 1. Prepare API response. NSString *responseBodyString = @"Generate challenge failed with invalid format."; NSData *responseBody = [responseBodyString dataUsingEncoding:NSUTF8StringEncoding]; - GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; + _GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 + responseBody:responseBody]; // 2. Stub API Service Request to return prepared API response. [self stubMockAPIServiceRequestForChallengeRequestWithResponse:validAPIResponse]; @@ -174,8 +174,8 @@ - (void)assertMissingFieldErrorWithFixture:(NSString *)fixtureName missingField:(NSString *)fieldName { // 1. Prepare API response. NSData *missingFieldBody = [GACFixtureLoader loadFixtureNamed:fixtureName]; - GACURLSessionDataResponse *incompleteAPIResponse = [self APIResponseWithCode:200 - responseBody:missingFieldBody]; + _GACURLSessionDataResponse *incompleteAPIResponse = [self APIResponseWithCode:200 + responseBody:missingFieldBody]; // 2. Stub API Service Request to return prepared API response. [self stubMockAPIServiceRequestForChallengeRequestWithResponse:incompleteAPIResponse]; @@ -216,8 +216,8 @@ - (void)testGetAppCheckTokenSuccessWithLimitedUse:(BOOL)limitedUse { // 1. Prepare response. NSData *responseBody = [GACFixtureLoader loadFixtureNamed:@"FACTokenExchangeResponseSuccess.json"]; - GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; + _GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 + responseBody:responseBody]; // 2. Stub API Service // 2.1. Return prepared response. @@ -260,8 +260,8 @@ - (void)testGetAppCheckTokenNetworkError { // 1. Prepare response. NSData *responseBody = [GACFixtureLoader loadFixtureNamed:@"FACTokenExchangeResponseSuccess.json"]; - GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; + _GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 + responseBody:responseBody]; // 2. Stub API Service // 2.1. Return prepared response. @@ -296,8 +296,8 @@ - (void)testGetAppCheckTokenUnexpectedResponse { // 1. Prepare response. NSData *responseBody = [GACFixtureLoader loadFixtureNamed:@"DeviceCheckResponseMissingToken.json"]; - GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; + _GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 + responseBody:responseBody]; // 2. Stub API Service // 2.1. Return prepared response. @@ -343,8 +343,8 @@ - (void)testAttestKeySuccessWithLimitedUse:(BOOL)limitedUse { // 1. Prepare response. NSData *responseBody = [GACFixtureLoader loadFixtureNamed:@"AppAttestAttestationResponseSuccess.json"]; - GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; + _GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 + responseBody:responseBody]; // 2. Stub API Service // 2.1. Return prepared response. @@ -418,8 +418,8 @@ - (void)testAttestKeyUnexpectedResponse { // 1. Prepare unexpected response. NSData *responseBody = [GACFixtureLoader loadFixtureNamed:@"FACTokenExchangeResponseSuccess.json"]; - GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; + _GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 + responseBody:responseBody]; // 2. Stub API Service // 2.1. Return prepared response. @@ -448,12 +448,12 @@ - (void)testAttestKeyUnexpectedResponse { #pragma mark - Helpers -- (GACURLSessionDataResponse *)APIResponseWithCode:(NSInteger)code - responseBody:(NSData *)responseBody { +- (_GACURLSessionDataResponse *)APIResponseWithCode:(NSInteger)code + responseBody:(NSData *)responseBody { XCTAssertNotNil(responseBody); NSHTTPURLResponse *HTTPResponse = [GACURLSessionOCMockStub HTTPResponseWithCode:code]; - GACURLSessionDataResponse *APIResponse = - [[GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; + _GACURLSessionDataResponse *APIResponse = + [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; return APIResponse; } @@ -493,7 +493,7 @@ - (void)expectTokenAPIRequestWithArtifact:(NSData *)attestation challenge:(NSData *)challenge assertion:(NSData *)assertion limitedUse:(BOOL)limitedUse - response:(nullable GACURLSessionDataResponse *)response + response:(nullable _GACURLSessionDataResponse *)response error:(nullable NSError *)error { id URLValidationArg = [self URLValidationArgumentWithCustomMethod:@"exchangeAppAttestAssertion"]; @@ -549,7 +549,7 @@ - (void)expectTokenAPIRequestWithArtifact:(NSData *)attestation .andReturn(responsePromise); } -- (void)expectTokenWithAPIReponse:(nonnull GACURLSessionDataResponse *)response +- (void)expectTokenWithAPIReponse:(nonnull _GACURLSessionDataResponse *)response toReturnToken:(nullable GACAppCheckToken *)token { FBLPromise *tokenPromise = [FBLPromise pendingPromise]; if (token) { @@ -565,7 +565,7 @@ - (void)expectAttestAPIRequestWithAttestation:(NSData *)attestation keyID:(NSString *)keyID challenge:(NSData *)challenge limitedUse:(BOOL)limitedUse - response:(nullable GACURLSessionDataResponse *)response + response:(nullable _GACURLSessionDataResponse *)response error:(nullable NSError *)error { id URLValidationArg = [self URLValidationArgumentWithCustomMethod:@"exchangeAppAttestAttestation"]; diff --git a/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestProviderTests.m b/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestProviderTests.m index 946367ac..377ad606 100644 --- a/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestProviderTests.m +++ b/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestProviderTests.m @@ -31,8 +31,8 @@ #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" #import "AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" #import "AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.h" @@ -42,7 +42,7 @@ - (instancetype)initWithAppAttestService:(id)appAttestServi APIService:(id)APIService keyIDStorage:(id)keyIDStorage artifactStorage:(id)artifactStorage - backoffWrapper:(id)backoffWrapper; + backoffWrapper:(id<_GACAppCheckBackoffWrapperProtocol>)backoffWrapper; @end GAC_APP_ATTEST_PROVIDER_AVAILABILITY @@ -101,7 +101,7 @@ - (void)tearDown { - (void)testGetTokenWhenAppAttestIsNotSupported { NSError *expectedError = - [GACAppCheckErrorUtil unsupportedAttestationProvider:@"AppAttestProvider"]; + [_GACAppCheckErrorUtil unsupportedAttestationProvider:@"AppAttestProvider"]; // 0.1. Expect backoff wrapper to be used. self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; @@ -317,9 +317,9 @@ - (void)testGetToken_WhenUnregisteredKeyAndKeyAttestationError { code:0 userInfo:nil]; NSError *expectedError = - [GACAppCheckErrorUtil appAttestAttestKeyFailedWithError:attestationError - keyId:existingKeyID - clientDataHash:self.randomChallengeHash]; + [_GACAppCheckErrorUtil appAttestAttestKeyFailedWithError:attestationError + keyId:existingKeyID + clientDataHash:self.randomChallengeHash]; id attestCompletionArg = [OCMArg invokeBlockWithArgs:[NSNull null], attestationError, nil]; OCMExpect([self.mockAppAttestService attestKey:existingKeyID clientDataHash:self.randomChallengeHash @@ -651,9 +651,9 @@ - (void)testGetToken_WhenKeyRegisteredAndGenerateAssertionError { [statementForAssertion appendData:self.randomChallenge]; NSData *clientDataHash = [GACAppCheckCryptoUtils sha256HashFromData:[statementForAssertion copy]]; NSError *expectedError = - [GACAppCheckErrorUtil appAttestGenerateAssertionFailedWithError:generateAssertionError - keyId:existingKeyID - clientDataHash:clientDataHash]; + [_GACAppCheckErrorUtil appAttestGenerateAssertionFailedWithError:generateAssertionError + keyId:existingKeyID + clientDataHash:clientDataHash]; id completionBlockArg = [OCMArg invokeBlockWithArgs:[NSNull null], generateAssertionError, nil]; OCMExpect([self.mockAppAttestService generateAssertion:existingKeyID diff --git a/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestArtifactStorageTests.m b/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestArtifactStorageTests.m index f2308e28..c755b555 100644 --- a/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestArtifactStorageTests.m +++ b/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestArtifactStorageTests.m @@ -34,7 +34,7 @@ #import "FBLPromise+Testing.h" #import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" static NSString *const kAppName = @"GACAppAttestArtifactStorageTests"; static NSString *const kAppID = @"1:100000000000:ios:aaaaaaaaaaaaaaaaaaaaaaaa"; @@ -152,7 +152,7 @@ - (void)testGetArtifact_KeychainError { XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); XCTAssertNotNil(getPromise.error); XCTAssertEqualObjects(getPromise.error, - [GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); + [_GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); // 4. Verify storage mock. OCMVerifyAll(mockKeychainStorage); @@ -179,7 +179,7 @@ - (void)testSetArtifact_KeychainError { XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); XCTAssertNotNil(setPromise.error); XCTAssertEqualObjects(setPromise.error, - [GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); + [_GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); // 4. Verify storage mock. OCMVerifyAll(mockKeychainStorage); @@ -205,7 +205,7 @@ - (void)testRemoveArtifact_KeychainError { XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); XCTAssertNotNil(setPromise.error); XCTAssertEqualObjects(setPromise.error, - [GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); + [_GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); // 4. Verify storage mock. OCMVerifyAll(mockKeychainStorage); diff --git a/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestKeyIDStorageTests.m b/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestKeyIDStorageTests.m index c404c4b2..17cd5c2e 100644 --- a/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestKeyIDStorageTests.m +++ b/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestKeyIDStorageTests.m @@ -20,7 +20,7 @@ #import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" static NSString *const kAppName = @"GACAppAttestKeyIDStorageTestsApp"; static NSString *const kAppID = @"app_id"; @@ -77,7 +77,7 @@ - (void)testGetAppAttestKeyID_WhenAppAttestKeyIDNotFoundError { __auto_type getPromise = [self.storage getAppAttestKeyID]; XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); XCTAssertNotNil(getPromise.error); - XCTAssertEqualObjects(getPromise.error, [GACAppCheckErrorUtil appAttestKeyIDNotFound]); + XCTAssertEqualObjects(getPromise.error, [_GACAppCheckErrorUtil appAttestKeyIDNotFound]); } - (void)testSetGetAppAttestKeyIDPerApp { diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckAPIServiceTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckAPIServiceTests.m index b8de7a7a..f5aca01c 100644 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckAPIServiceTests.m +++ b/AppCheckCore/Tests/Unit/Core/GACAppCheckAPIServiceTests.m @@ -19,12 +19,12 @@ #import #import "FBLPromise+Testing.h" -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h" -#import "AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.h" #import "AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h" #import "AppCheckCore/Tests/Unit/Utils/GACFixtureLoader.h" #import "AppCheckCore/Tests/Utils/Date/GACDateTestUtils.h" @@ -36,11 +36,11 @@ static NSString *const kTestHeaderKey = @"X-test-header"; static NSString *const kTestHeaderValue = @"TEST_HEADER_VALUE"; -#pragma mark - GACAppCheckAPIServiceTests +#pragma mark - _GACAppCheckAPIServiceTests -@interface GACAppCheckAPIServiceTests : XCTestCase +@interface _GACAppCheckAPIServiceTests : XCTestCase -@property(nonatomic) GACAppCheckAPIService *APIService; +@property(nonatomic) _GACAppCheckAPIService *APIService; @property(nonatomic) id mockURLSession; @@ -48,7 +48,7 @@ @interface GACAppCheckAPIServiceTests : XCTestCase @end -@implementation GACAppCheckAPIServiceTests +@implementation _GACAppCheckAPIServiceTests - (void)setUp { [super setUp]; @@ -58,10 +58,10 @@ - (void)setUp { self.expectedHTTPHeaderFields = [NSMutableDictionary dictionaryWithDictionary:@{kBundleIDHeaderKey : [[NSBundle mainBundle] bundleIdentifier]}]; - self.APIService = [[GACAppCheckAPIService alloc] initWithURLSession:self.mockURLSession - baseURL:nil - APIKey:nil - requestHooks:nil]; + self.APIService = [[_GACAppCheckAPIService alloc] initWithURLSession:self.mockURLSession + baseURL:nil + APIKey:nil + requestHooks:nil]; } - (void)tearDown { @@ -75,11 +75,11 @@ - (void)tearDown { #pragma mark - Init - (void)testInitDefaultBaseURL { - GACAppCheckAPIService *APIService = - [[GACAppCheckAPIService alloc] initWithURLSession:self.mockURLSession - baseURL:nil - APIKey:nil - requestHooks:nil]; + _GACAppCheckAPIService *APIService = + [[_GACAppCheckAPIService alloc] initWithURLSession:self.mockURLSession + baseURL:nil + APIKey:nil + requestHooks:nil]; XCTAssertNotNil(APIService); XCTAssertEqualObjects(APIService.baseURL, @"https://firebaseappcheck.googleapis.com/v1"); @@ -88,16 +88,54 @@ - (void)testInitDefaultBaseURL { - (void)testInitCustomBaseURL { NSString *customBaseURL = @"https://custom.example.com/v1beta"; - GACAppCheckAPIService *APIService = - [[GACAppCheckAPIService alloc] initWithURLSession:self.mockURLSession - baseURL:customBaseURL - APIKey:nil - requestHooks:nil]; + _GACAppCheckAPIService *APIService = + [[_GACAppCheckAPIService alloc] initWithURLSession:self.mockURLSession + baseURL:customBaseURL + APIKey:nil + requestHooks:nil]; XCTAssertNotNil(APIService); XCTAssertEqualObjects(APIService.baseURL, customBaseURL); } +- (void)testInitBaseURLStagingTriggeredByEnvVar { + NSString *stagingBaseURL = @"https://staging-firebaseappcheck.sandbox.googleapis.com/v1"; + + id processInfoMock = OCMPartialMock([NSProcessInfo processInfo]); + OCMExpect([processInfoMock processInfo]).andReturn(processInfoMock); + OCMExpect([processInfoMock environment]).andReturn(@{@"_AppCheckUseStaging" : @"YES"}); + + _GACAppCheckAPIService *APIService = + [[_GACAppCheckAPIService alloc] initWithURLSession:self.mockURLSession + baseURL:nil + APIKey:nil + requestHooks:nil]; + + XCTAssertNotNil(APIService); + XCTAssertEqualObjects(APIService.baseURL, stagingBaseURL); + + [processInfoMock stopMocking]; +} + +- (void)testInitBaseURLStagingNotTriggeredWhenEnvVarIsNo { + NSString *prodBaseURL = @"https://firebaseappcheck.googleapis.com/v1"; + + id processInfoMock = OCMPartialMock([NSProcessInfo processInfo]); + OCMExpect([processInfoMock processInfo]).andReturn(processInfoMock); + OCMExpect([processInfoMock environment]).andReturn(@{@"_AppCheckUseStaging" : @"NO"}); + + _GACAppCheckAPIService *APIService = + [[_GACAppCheckAPIService alloc] initWithURLSession:self.mockURLSession + baseURL:nil + APIKey:nil + requestHooks:nil]; + + XCTAssertNotNil(APIService); + XCTAssertEqualObjects(APIService.baseURL, prodBaseURL); + + [processInfoMock stopMocking]; +} + #pragma mark - Send Requests - (void)testDataRequestNetworkError { @@ -187,7 +225,7 @@ - (void)testDataRequestWithRequestHooks { request.allowsCellularAccess = NO; }; - self.APIService = [[GACAppCheckAPIService alloc] + self.APIService = [[_GACAppCheckAPIService alloc] initWithURLSession:self.mockURLSession baseURL:nil APIKey:nil @@ -282,10 +320,10 @@ - (void)testDataRequestWithAPIKey { NSData *requestBody = [@"Request body" dataUsingEncoding:NSUTF8StringEncoding]; [self.expectedHTTPHeaderFields setObject:kAPIKeyHeaderValue forKey:kAPIKeyHeaderKey]; - self.APIService = [[GACAppCheckAPIService alloc] initWithURLSession:self.mockURLSession - baseURL:nil - APIKey:kAPIKeyHeaderValue - requestHooks:nil]; + self.APIService = [[_GACAppCheckAPIService alloc] initWithURLSession:self.mockURLSession + baseURL:nil + APIKey:kAPIKeyHeaderValue + requestHooks:nil]; // 1. Stub URL session. FIRRequestValidationBlock requestValidation = ^BOOL(NSURLRequest *request) { @@ -332,8 +370,8 @@ - (void)testAppCheckTokenWithAPIResponseValidResponse { [GACFixtureLoader loadFixtureNamed:@"FACTokenExchangeResponseSuccess.json"]; XCTAssertNotNil(responseBody); NSHTTPURLResponse *HTTPResponse = [GACURLSessionOCMockStub HTTPResponseWithCode:200]; - GACURLSessionDataResponse *APIResponse = - [[GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; + _GACURLSessionDataResponse *APIResponse = + [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; // 2. Expected result. NSString *expectedFACToken = @"valid_app_check_token"; @@ -358,8 +396,8 @@ - (void)testAppCheckTokenWithAPIResponseInvalidFormat { NSString *responseBodyString = @"Token verification failed."; NSData *responseBody = [responseBodyString dataUsingEncoding:NSUTF8StringEncoding]; NSHTTPURLResponse *HTTPResponse = [GACURLSessionOCMockStub HTTPResponseWithCode:200]; - GACURLSessionDataResponse *APIResponse = - [[GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; + _GACURLSessionDataResponse *APIResponse = + [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; // 2. Parse API response. __auto_type tokenPromise = [self.APIService appCheckTokenWithAPIResponse:APIResponse]; @@ -393,8 +431,8 @@ - (void)assertMissingFieldErrorWithFixture:(NSString *)fixtureName XCTAssertNotNil(missingFiledBody); NSHTTPURLResponse *HTTPResponse = [GACURLSessionOCMockStub HTTPResponseWithCode:200]; - GACURLSessionDataResponse *APIResponse = - [[GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:missingFiledBody]; + _GACURLSessionDataResponse *APIResponse = + [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:missingFiledBody]; // 2. Parse API response. __auto_type tokenPromise = [self.APIService appCheckTokenWithAPIResponse:APIResponse]; @@ -434,10 +472,10 @@ - (void)stubURLSessionDataTaskPromiseWithResponse:(NSHTTPURLResponse *)HTTPRespo id URLRequestValidationArg = [OCMArg checkWithBlock:nonOptionalRequestValidationBlock]; // Result promise. - FBLPromise *result = [FBLPromise pendingPromise]; + FBLPromise<_GACURLSessionDataResponse *> *result = [FBLPromise pendingPromise]; if (error == nil) { - GACURLSessionDataResponse *response = - [[GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:body]; + _GACURLSessionDataResponse *response = + [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:body]; [result fulfill:response]; } else { [result reject:error]; diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckBackoffWrapperTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckBackoffWrapperTests.m index 7194285e..51b1f01b 100644 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckBackoffWrapperTests.m +++ b/AppCheckCore/Tests/Unit/Core/GACAppCheckBackoffWrapperTests.m @@ -23,13 +23,13 @@ #import "FBLPromises.h" #endif -#import "AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.h" +#import #import "AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h" -@interface GACAppCheckBackoffWrapperTests : XCTestCase +@interface _GACAppCheckBackoffWrapperTests : XCTestCase -@property(nonatomic, nullable) GACAppCheckBackoffWrapper *backoffWrapper; +@property(nonatomic, nullable) _GACAppCheckBackoffWrapper *backoffWrapper; @property(nonatomic) NSDate *currentDate; @@ -50,13 +50,13 @@ @interface GACAppCheckBackoffWrapperTests : XCTestCase @end -@implementation GACAppCheckBackoffWrapperTests +@implementation _GACAppCheckBackoffWrapperTests - (void)setUp { [super setUp]; __auto_type __weak weakSelf = self; - self.backoffWrapper = [[GACAppCheckBackoffWrapper alloc] initWithDateProvider:^NSDate *_Nonnull { + self.backoffWrapper = [[_GACAppCheckBackoffWrapper alloc] initWithDateProvider:^NSDate *_Nonnull { return weakSelf.currentDate ?: [NSDate date]; }]; } diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckStorageTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckStorageTests.m index e77130cc..f34892ab 100644 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckStorageTests.m +++ b/AppCheckCore/Tests/Unit/Core/GACAppCheckStorageTests.m @@ -35,8 +35,8 @@ #import "AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" static NSString *const kAppName = @"GACAppCheckStorageTestsApp"; static NSString *const kGoogleAppID = @"1:100000000000:ios:aaaaaaaaaaaaaaaaaaaaaaaa"; @@ -109,7 +109,7 @@ - (void)testGetToken_KeychainError { XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); XCTAssertNotNil(getPromise.error); XCTAssertEqualObjects(getPromise.error, - [GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); + [_GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); // 4. Verify storage mock. OCMVerifyAll(mockKeychainStorage); @@ -138,7 +138,7 @@ - (void)testSetToken_KeychainError { XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); XCTAssertNotNil(getPromise.error); XCTAssertEqualObjects(getPromise.error, - [GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); + [_GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); // 4. Verify storage mock. OCMVerifyAll(mockKeychainStorage); @@ -162,7 +162,7 @@ - (void)testRemoveToken_KeychainError { XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); XCTAssertNotNil(getPromise.error); XCTAssertEqualObjects(getPromise.error, - [GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); + [_GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); // 4. Verify storage mock. OCMVerifyAll(mockKeychainStorage); diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckTests.m index b17071d7..7710b059 100644 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckTests.m +++ b/AppCheckCore/Tests/Unit/Core/GACAppCheckTests.m @@ -25,13 +25,13 @@ #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckProvider.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckSettings.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.h" #import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefreshResult.h" #import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefresher.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckTokenDelegate.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckTokenResult.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" /// The placeholder token value returned when an error occurs: `{"error":"UNKNOWN_ERROR"}` encoded /// as base64 @@ -359,7 +359,7 @@ - (void)testLimitedUseToken_WhenTokenGenerationErrors { OCMReject([self.mockStorage getToken]); // 2. Expect error when requesting token from app check provider. - NSError *providerError = [GACAppCheckErrorUtil keychainErrorWithError:[self internalError]]; + NSError *providerError = [_GACAppCheckErrorUtil keychainErrorWithError:[self internalError]]; id completionArg = [OCMArg invokeBlockWithArgs:[NSNull null], providerError, nil]; OCMExpect([self.mockAppCheckProvider getLimitedUseTokenWithCompletion:completionArg]); diff --git a/AppCheckCore/Tests/Unit/DebugProvider/GACAppCheckDebugProviderAPIServiceTests.m b/AppCheckCore/Tests/Unit/DebugProvider/GACAppCheckDebugProviderAPIServiceTests.m index 1c9f619f..02b4b846 100644 --- a/AppCheckCore/Tests/Unit/DebugProvider/GACAppCheckDebugProviderAPIServiceTests.m +++ b/AppCheckCore/Tests/Unit/DebugProvider/GACAppCheckDebugProviderAPIServiceTests.m @@ -22,10 +22,10 @@ #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheck.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h" -#import "AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h" #import "AppCheckCore/Tests/Utils/URLSession/GACURLSessionOCMockStub.h" @@ -42,7 +42,7 @@ @implementation GACAppCheckDebugProviderAPIServiceTests - (void)setUp { [super setUp]; - self.mockAPIService = OCMProtocolMock(@protocol(GACAppCheckAPIServiceProtocol)); + self.mockAPIService = OCMProtocolMock(@protocol(_GACAppCheckAPIServiceProtocol)); OCMStub([self.mockAPIService baseURL]).andReturn(@"https://test.appcheck.url.com/alpha"); self.debugAPIService = @@ -84,8 +84,8 @@ - (void)testAppCheckTokenSuccessWithLimitedUse:(BOOL)limitedUse { limitedUse:limitedUse]; NSData *fakeResponseData = [@"fake response" dataUsingEncoding:NSUTF8StringEncoding]; NSHTTPURLResponse *HTTPResponse = [GACURLSessionOCMockStub HTTPResponseWithCode:200]; - GACURLSessionDataResponse *APIResponse = - [[GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:fakeResponseData]; + _GACURLSessionDataResponse *APIResponse = + [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:fakeResponseData]; OCMExpect([self.mockAPIService sendRequestWithURL:URLValidationArg HTTPMethod:@"POST" @@ -132,8 +132,8 @@ - (void)testAppCheckTokenResponseParsingError { id HTTPBodyValidationArg = [self HTTPBodyValidationArgWithDebugToken:debugToken limitedUse:NO]; NSData *fakeResponseData = [@"fake response" dataUsingEncoding:NSUTF8StringEncoding]; NSHTTPURLResponse *HTTPResponse = [GACURLSessionOCMockStub HTTPResponseWithCode:200]; - GACURLSessionDataResponse *APIResponse = - [[GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:fakeResponseData]; + _GACURLSessionDataResponse *APIResponse = + [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:fakeResponseData]; OCMExpect([self.mockAPIService sendRequestWithURL:URLValidationArg HTTPMethod:@"POST" diff --git a/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckAPIServiceTests.m b/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckAPIServiceTests.m index 887676a8..3694aeb5 100644 --- a/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckAPIServiceTests.m +++ b/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckAPIServiceTests.m @@ -19,12 +19,12 @@ #import #import "FBLPromise+Testing.h" -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.h" -#import "AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h" #import "AppCheckCore/Tests/Unit/Utils/GACFixtureLoader.h" #import "AppCheckCore/Tests/Utils/URLSession/GACURLSessionOCMockStub.h" @@ -45,7 +45,7 @@ @implementation GACDeviceCheckAPIServiceTests - (void)setUp { [super setUp]; - self.mockAPIService = OCMProtocolMock(@protocol(GACAppCheckAPIServiceProtocol)); + self.mockAPIService = OCMProtocolMock(@protocol(_GACAppCheckAPIServiceProtocol)); OCMStub([self.mockAPIService baseURL]).andReturn(@"https://test.appcheck.url.com/alpha"); self.APIService = [[GACDeviceCheckAPIService alloc] initWithAPIService:self.mockAPIService @@ -91,8 +91,8 @@ - (void)testAppCheckTokenSuccessWithLimitedUse:(BOOL)limitedUse { XCTAssertNotNil(responseBody); NSHTTPURLResponse *HTTPResponse = [GACURLSessionOCMockStub HTTPResponseWithCode:200]; - GACURLSessionDataResponse *APIResponse = - [[GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; + _GACURLSessionDataResponse *APIResponse = + [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; OCMExpect([self.mockAPIService sendRequestWithURL:URLValidationArg HTTPMethod:@"POST" @@ -144,8 +144,8 @@ - (void)testAppCheckTokenResponseParsingError { XCTAssertNotNil(responseBody); NSHTTPURLResponse *HTTPResponse = [GACURLSessionOCMockStub HTTPResponseWithCode:200]; - GACURLSessionDataResponse *APIResponse = - [[GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; + _GACURLSessionDataResponse *APIResponse = + [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; OCMExpect([self.mockAPIService sendRequestWithURL:URLValidationArg HTTPMethod:@"POST" diff --git a/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckProviderTests.m b/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckProviderTests.m index 12eca4ef..d8210b9d 100644 --- a/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckProviderTests.m +++ b/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckProviderTests.m @@ -19,11 +19,11 @@ #import #import "FBLPromise+Testing.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.h" #import "AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.h" #import "AppCheckCore/Sources/DeviceCheckProvider/GACDeviceCheckTokenGenerator.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" #import "AppCheckCore/Sources/Public/AppCheckCore/GACDeviceCheckProvider.h" +#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" #import "AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.h" @@ -32,7 +32,7 @@ @interface GACDeviceCheckProvider (Tests) - (instancetype)initWithAPIService:(id)APIService deviceTokenGenerator:(id)deviceTokenGenerator - backoffWrapper:(id)backoffWrapper; + backoffWrapper:(id<_GACAppCheckBackoffWrapperProtocol>)backoffWrapper; @end @@ -120,7 +120,7 @@ - (void)testGetTokenSuccess { - (void)testGetTokenWhenDeviceCheckIsNotSupported { NSError *expectedError = - [GACAppCheckErrorUtil unsupportedAttestationProvider:@"DeviceCheckProvider"]; + [_GACAppCheckErrorUtil unsupportedAttestationProvider:@"DeviceCheckProvider"]; // 0.1. Expect backoff wrapper to be used. self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; diff --git a/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift b/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift index 06205be5..a36fab5f 100644 --- a/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift +++ b/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift @@ -229,6 +229,7 @@ final class AppCheckAPITests { switch code! { case .loggerAppCheckMessageCodeUnknown: break case .loggerAppCheckMessageCodeProviderIsMissing: break + case .loggerAppCheckMessageCodeStagingModeEnabled: break case .loggerAppCheckMessageCodeUnexpectedHTTPCode: break case .loggerAppCheckMessageLocalDebugToken: break case .loggerAppCheckMessageEnvironmentVariableDebugToken: break diff --git a/AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.h b/AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.h index 30c89360..069a3876 100644 --- a/AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.h +++ b/AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.h @@ -18,11 +18,17 @@ #import -#import "AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.h" +#if __has_include() +#import +#else +#import "FBLPromises.h" +#endif + +#import NS_ASSUME_NONNULL_BEGIN -@interface GACAppCheckBackoffWrapperFake : NSObject +@interface GACAppCheckBackoffWrapperFake : NSObject <_GACAppCheckBackoffWrapperProtocol> /// If `YES` then the next operation passed to `[backoff:errorHandler:]` method will be performed. /// If `NO` then it will fail with a backoff error. diff --git a/AppCheckRecaptchaProvider/Sources/Public/AppCheckRecaptchaProvider.swift b/AppCheckRecaptchaProvider/Sources/Public/AppCheckRecaptchaProvider.swift new file mode 100644 index 00000000..ccc66f41 --- /dev/null +++ b/AppCheckRecaptchaProvider/Sources/Public/AppCheckRecaptchaProvider.swift @@ -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 { + 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 + } +} diff --git a/AppCheckRecaptchaProvider/Sources/RecaptchaAPIService.swift b/AppCheckRecaptchaProvider/Sources/RecaptchaAPIService.swift new file mode 100644 index 00000000..1e5d501a --- /dev/null +++ b/AppCheckRecaptchaProvider/Sources/RecaptchaAPIService.swift @@ -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 { + 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(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) + } + } +} diff --git a/AppCheckRecaptchaProvider/Sources/RecaptchaTokenGenerator.swift b/AppCheckRecaptchaProvider/Sources/RecaptchaTokenGenerator.swift new file mode 100644 index 00000000..9783157a --- /dev/null +++ b/AppCheckRecaptchaProvider/Sources/RecaptchaTokenGenerator.swift @@ -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 + + 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 { 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 { + return recaptchaClient.then { client in + let operationProvider: GACAppCheckBackoffOperationProvider = { + let swiftPromise = Promise { 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(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 + ) + } +} diff --git a/AppCheckRecaptchaProvider/Tests/AppCheckRecaptchaProviderTests.swift b/AppCheckRecaptchaProvider/Tests/AppCheckRecaptchaProviderTests.swift new file mode 100644 index 00000000..1b2d15a6 --- /dev/null +++ b/AppCheckRecaptchaProvider/Tests/AppCheckRecaptchaProviderTests.swift @@ -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) + } +} diff --git a/AppCheckRecaptchaProvider/Tests/MockRecaptchaSupport.swift b/AppCheckRecaptchaProvider/Tests/MockRecaptchaSupport.swift new file mode 100644 index 00000000..e62162a6 --- /dev/null +++ b/AppCheckRecaptchaProvider/Tests/MockRecaptchaSupport.swift @@ -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 { + let promise = Promise.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 { + applyBackoffCalled = true + capturedErrorHandler = errorHandler + if shouldReturnError { + let error = mockError ?? NSError(domain: "MockBackoffWrapper", code: -1, userInfo: nil) + let swiftPromise = Promise(error as Error) + return swiftPromise.asObjCPromise() + } + if let mockResult { + let swiftPromise = Promise(mockResult as AnyObject) + return swiftPromise.asObjCPromise() + } + return operationProvider() + } + + func defaultAppCheckProviderErrorHandler() -> GACAppCheckBackoffErrorHandler { + return { error in .typeExponential } + } +} diff --git a/AppCheckRecaptchaProvider/Tests/RecaptchaAPIServiceTests.swift b/AppCheckRecaptchaProvider/Tests/RecaptchaAPIServiceTests.swift new file mode 100644 index 00000000..cd62549b --- /dev/null +++ b/AppCheckRecaptchaProvider/Tests/RecaptchaAPIServiceTests.swift @@ -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) + } +} diff --git a/AppCheckRecaptchaProvider/Tests/RecaptchaTokenGeneratorTests.swift b/AppCheckRecaptchaProvider/Tests/RecaptchaTokenGeneratorTests.swift new file mode 100644 index 00000000..2363dccb --- /dev/null +++ b/AppCheckRecaptchaProvider/Tests/RecaptchaTokenGeneratorTests.swift @@ -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) + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index d97d8a21..0fd105fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# 11.3.0 +- [changed] Added Recaptcha Enterprise attestation provider. + # 11.2.0 - [changed] To prevent reusing expired artifacts, skip local cache when making network requests. diff --git a/Package.swift b/Package.swift index 0135c03e..8254c4af 100644 --- a/Package.swift +++ b/Package.swift @@ -25,6 +25,12 @@ let package = Package( name: "AppCheckCore", targets: ["AppCheckCore"] ), + + .library( + name: "AppCheckRecaptchaProvider", + targets: ["AppCheckRecaptchaProvider"] + ), + ], dependencies: [ .package( @@ -39,6 +45,10 @@ let package = Package( url: "https://github.com/erikdoe/ocmock.git", revision: "2c0bfd373289f4a7716db5d6db471640f91a6507" ), + .package( + url: "https://github.com/google/interop-ios-for-google-sdks.git", + "101.0.0" ..< "102.0.0" + ), ], targets: [ .target(name: "AppCheckCore", @@ -58,6 +68,13 @@ let package = Package( .when(platforms: [.iOS, .macCatalyst, .macOS, .tvOS, .appCheckVisionOS]) ), ]), + .target(name: "AppCheckRecaptchaProvider", + dependencies: [ + "AppCheckCore", + .product(name: "RecaptchaInterop", package: "interop-ios-for-google-sdks"), + .product(name: "Promises", package: "Promises"), + ], + path: "AppCheckRecaptchaProvider/Sources"), .testTarget( name: "AppCheckCoreUnit", dependencies: [ @@ -85,6 +102,13 @@ let package = Package( .headerSearchPath("../.."), ] ), + .testTarget( + name: "AppCheckRecaptchaProviderUnit", + dependencies: [ + "AppCheckRecaptchaProvider", + ], + path: "AppCheckRecaptchaProvider/Tests" + ), ] ) diff --git a/agents.md b/agents.md new file mode 100644 index 00000000..9cce38e8 --- /dev/null +++ b/agents.md @@ -0,0 +1,212 @@ +# App Check - Agent Workflow Instructions + +The goal of this document is to ensure high-quality, reproducible, and +verifiable contributions in a fully autonomous loop for the App Check +repository. + +--- + +## 📥 Input Requirements + +Before starting any work, the agent must require or acquire: +1. **Feature Specification**: A detailed description of the feature, bug, or + task. +2. **Project Configuration**: Access to necessary credentials or configurations + if applicable. +3. **External Scripts**: Access to the `firebase-ios-sdk` scripts. If not using + the cloned scripts via `./setup-scripts.sh`, ensure they are available in a + local clone of `firebase-ios-sdk` (commonly at + `/scripts` but path may vary). If the path is not + found, ask the human for it. + +## 📤 Output Requirements + +A successful task completion MUST produce: +1. **Code Changes**: The implemented feature or fix and corresponding tests. +2. **Unit & Integration Tests**: Demonstrating success and handling edge cases. +3. **Implementation Plan** (For complex tasks only): A scannable proposal + before starting work. +4. **Walkthrough Artifact**: A summary containing verification results and + reproduction snippets. + +--- + +## 💬 Communication Guidelines + +When reporting back to the user, prioritize scannability and clarity: +1. **Use Categorized Bullet Points**: Group findings and results into clear + categories (e.g., "Build & Test Results", "Code Changes"). +2. **Use Indicators**: Prefix status updates with checkmarks (✅) or caution + symbols (⚠️) for immediate visual parsing. +3. **Be Concise**: Avoid conversational filler. Get straight to the results + and next steps. +4. **Final Report**: Conclude the task with a concise summary of work and a + recommended conventional commit message. + +--- + +## 🔄 The Agentic Loop: Step-by-Step + +### Step 0: Workflow Selection & Planning (Hybrid Approach) +- **Prerequisite**: Verify that external scripts are accessible or that + `./setup-scripts.sh` has been run to link them. If you cannot find them, ask + the human for the path to the `firebase-ios-sdk` repository. +- **Action**: Assess the complexity of the task. + - **Simple Task**: Proceed directly to **Step 1: TDD**. + - **Complex Task**: Create a highly scannable **Implementation Plan** and + get human approval. +- **Plan Requirements (Highly Scannable)**: + - Keep it brief and hit key points. + - Use bullet points for readability. + - Focus on *what* changes and *why*, avoiding detailed *how*. + - Highlight any open questions or design decisions requiring human input. + +### Step 1: Test-Driven Development (TDD) +- **Constraint**: You MUST write tests before writing implementation code. +- **Action**: + 1. Create or identify the correct test target in `Package.swift`. Keep + Swift and Obj-C test targets separate. + 2. Write a failing unit or integration test asserting the new behavior. + 3. Verify it fails by running the appropriate test command (e.g., `swift + test --filter `). + +### Step 2: Implementation +- Implement the feature or fix. +- Follow project conventions and guidelines if available. + +### Step 3: Verification +- **Action**: Run tests using the cloned scripts or by referencing the external + ones (e.g., in ``). +- **Iteration Workflow**: To get into a faster iterative loop, use the external + scripts directly if possible. Set an environment variable like + `FIREBASE_IOS_SDK_PATH` if your path differs from the default + ``. + - To bypass the CI secret check in `check_secrets.sh` when running external + scripts in a trusted environment, export `FIREBASECI_IS_TRUSTED_ENV="true"`. +- **Commands**: + - **Primary (Fast Iteration)**: For SPM testing (which uses `xcodebuild` + under the hood): + `${FIREBASE_IOS_SDK_PATH:-}/scripts/build.sh AppCheck spm` + (where `` is `iOS`, `tvOS`, `macOS`, or `catalyst`). + - For CocoaPods linting: + `${FIREBASE_IOS_SDK_PATH:-}/scripts/pod_lib_lint.rb AppCheckCore.podspec --platforms=ios` + (or other platforms: `tvos`, `macos --skip-tests`, `watchos`). + - Alternatively, run `./setup-scripts.sh` to clone scripts locally and use + `scripts/pod_lib_lint.rb`. + - For Catalyst testing: + `${FIREBASE_IOS_SDK_PATH:-}/scripts/test_catalyst.sh AppCheckCore test`. +- **xcodebuild Iteration**: For direct `xcodebuild` invocations, follow the + order: `build`, `build-for-testing`, then `test`. This allows for faster + iteration. + +### Step 4: Public API Visibility +- **Requirement**: Identify and report any new public APIs created. +- **Method**: Check for changes in public headers or symbols. + +### Step 5: Style Application +- **Action**: You MUST run `/scripts/style.sh` to + maintain consistency. +- **Constraint**: Since style changes are non-functional, you do NOT need to + re-run tests after applying style fixes. + +### Step 6: Documentation Formatting +- **Requirement**: Wrap all documentation files (like `agents.md`) to be 80 + characters or less (excluding code blocks). Remove all trailing whitespace. + +--- + +## 🏆 Quality Gates & Best Practices + +- **Error Handling**: Test edge cases and error paths. +- **No Hardcoded Secrets**: Ensure no secrets are committed. +- **Code Reuse & Refactoring**: Prioritize understanding existing structures to + reuse or extend them with minor refactors rather than adding redundant code. + +--- + +## ✅ Pre-Commit Checklist +- [ ] **Unit Tests**: Passed all unit tests. +- [ ] **Integration Tests**: Passed all integration tests. +- [ ] **Style Applied**: Verified code style if applicable. +- [ ] **Concurrency**: Verified that the changes do not introduce potential + race conditions or deadlocks. +- [ ] **Memory Management**: Ensured no retain cycles or memory leaks are + introduced. + +--- + +## 📦 Git & Commits + +- **Commit Often**: Pause and commit work frequently. +- **Scope**: Optimize for smaller commits that represent a complete piece of work + or a specific milestone within a larger task. +- **Convention**: Follow conventional commit practices (e.g. `feat:`, `fix:`, + `refactor:`). + +--- + +## 🛠️ Environment & Troubleshooting + +When operating in a restricted or sandboxed environment (like the Jetski IDE), +you may encounter the following blockers. Use these workarounds: + +- **Terminal Sandbox (SPM `sandbox-exec` errors)**: `swift build` may fail if + run inside a sandbox. Disable the terminal sandbox in the IDE settings + (`enableTerminalSandbox: false`) or use `swift build --disable-sandbox`. +- **Missing `python` Command**: Modern macOS lacks `python` (Python 2). If + external scripts fail, create a local wrapper script that forwards to + `python3` and add it to the `PATH`: + `mkdir -p tmp/bin && echo '#!/bin/sh\nexec python3 "$@"' > tmp/bin/python && chmod +x tmp/bin/python && export PATH="$PWD/tmp/bin:$PATH"` +- **Ruby Version Conflicts**: External scripts (like `pod_lib_lint.rb`) may + fail if `rbenv` tries to use the external repo's `.ruby-version`. Force the + local Ruby version by prefixing the command with `RBENV_VERSION=2.7.5`. +- **Quality Gates**: Do not skip `style.sh` and `pod_lib_lint.rb`. They are + critical for verification. +- **Fixture Loading in Tests**: When running tests via `swift test` on macOS, + `GACFixtureLoader` may fail to find JSON fixtures due to bundle resolution + issues, causing tests to fail with `nil URL argument` exceptions. This is + often an environment-specific issue with SPM resource bundles on macOS. + +### Swift / Objective-C Interoperability Pitfalls + +When working on mixed-language targets (e.g., Swift tests for Objective-C core +code), you will encounter strict compiler bridging issues, particularly with +generic classes like `FBLPromise`. To avoid build loop failures: + +- **FBLPromise Instantiation**: `FBLPromise.init()` is `NS_UNAVAILABLE`. The + standard Objective-C factory methods (`resolvedWith:` and `promiseWithError:`) + bridge to Swift as **unlabeled** static methods that lose type inference. +- **The Fix**: Do NOT attempt to specify generics on the receiver (e.g., + `FBLPromise.resolved(...)` will fail). Instead, call the base method and + force-cast the result: + ```swift + // Success + return FBLPromise.resolved(response) as! FBLPromise + + // Failure (Must explicitly cast to NSError) + return FBLPromise.resolved(error as NSError) as! FBLPromise + ``` +- **PromisesSwift Interoperability**: If you need to return an `FBLPromise` from + Swift (e.g., in test mocks), prefer creating a Swift `Promise` and converting + it using `asObjCPromise()` rather than using reflection or dynamic dispatch: + ```swift + let promise = Promise.pending() + // ... fulfill or reject ... + return promise.asObjCPromise() + ``` + +--- + +## 📝 Final Walkthrough Structure +The task is not done until a `walkthrough.md` artifact is created containing: +1. **Summary of Changes**: High-level overview. +2. **Public API Diff**: Any new public APIs. +3. **Verification Results**: Snippets showing successful test runs. + +--- + +## 🧠 Post Change: Continuous Improvement +Perform self-reflection after completing the task. You MUST update this file +(`agents.md`) with any new learnings, context, or troubleshooting steps that +were needed and will be needed again to refine the process for future agents. +Alternatively, create or update a Knowledge Item.