diff --git a/AppCheckCore.podspec b/AppCheckCore.podspec index 3ceef020..fd24f334 100644 --- a/AppCheckCore.podspec +++ b/AppCheckCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'AppCheckCore' - s.version = '11.3.1' + s.version = '12.0.0' s.summary = 'App Check Core SDK.' s.description = <<-DESC @@ -17,7 +17,7 @@ Pod::Spec.new do |s| } s.social_media_url = 'https://twitter.com/Firebase' - ios_deployment_target = '12.0' + ios_deployment_target = '13.0' osx_deployment_target = '10.15' tvos_deployment_target = '13.0' watchos_deployment_target = '7.0' @@ -35,7 +35,7 @@ Pod::Spec.new do |s| base_dir = "AppCheckCore/" s.source_files = [ - base_dir + 'Sources/**/*.[mh]', + base_dir + 'Sources/**/*.{h,m,swift}', ] s.ios.source_files = [ 'AppCheckRecaptchaProvider/Sources/**/*.swift', @@ -45,9 +45,6 @@ Pod::Spec.new do |s| s.ios.weak_framework = 'DeviceCheck' s.osx.weak_framework = 'DeviceCheck' 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' @@ -64,28 +61,13 @@ Pod::Spec.new do |s| :tvos => tvos_deployment_target } unit_tests.source_files = [ - base_dir + 'Tests/Unit/**/*.[mh]', - base_dir + 'Tests/Utils/**/*.[mh]', + base_dir + 'Tests/Unit/**/*.swift', ] unit_tests.resources = base_dir + 'Tests/Fixture/**/*' unit_tests.requires_app_host = true end - s.test_spec 'integration' do |integration_tests| - integration_tests.platforms = { - :ios => ios_deployment_target, - :osx => osx_deployment_target, - :tvos => tvos_deployment_target - } - integration_tests.source_files = [ - base_dir + 'Tests/Integration/**/*.[mh]', - base_dir + 'Tests/Integration/**/*.[mh]', - ] - integration_tests.resources = base_dir + 'Tests/Fixture/**/*' - integration_tests.requires_app_host = true - end - s.test_spec 'swift-unit' do |swift_unit_tests| swift_unit_tests.platforms = { :ios => ios_deployment_target, diff --git a/AppCheckCore/Sources/AppAttestProvider/API/AppCheckCoreAppAttestAPIService.swift b/AppCheckCore/Sources/AppAttestProvider/API/AppCheckCoreAppAttestAPIService.swift new file mode 100644 index 00000000..5ea569ba --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/API/AppCheckCoreAppAttestAPIService.swift @@ -0,0 +1,201 @@ +// 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 Foundation + +private let kGenerateAppAttestChallengeEndpoint = "generateAppAttestChallenge" +private let kExchangeAppAttestAttestationEndpoint = "exchangeAppAttestAttestation" +private let kExchangeAppAttestAssertionEndpoint = "exchangeAppAttestAssertion" + +private let kRequestFieldArtifact = "artifact" +private let kRequestFieldAssertion = "assertion" +private let kRequestFieldChallenge = "challenge" +private let kRequestFieldKeyID = "key_id" +private let kRequestFieldAttestation = "attestation_statement" +private let kRequestFieldLimitedUse = "limited_use" +private let kContentTypeKey = "Content-Type" +private let kJSONContentType = "application/json" +private let kHTTPMethodPost = "POST" + +@objc(GACAppAttestAPIServiceProtocol) +public protocol AppCheckCoreAppAttestAPIServiceProtocol: NSObjectProtocol { + @objc func getRandomChallenge() async throws -> Data + + @objc + func attestKey(withAttestation attestation: Data, keyID: String, challenge: Data, + limitedUse: Bool) async throws -> AppCheckCoreAppAttestAttestationResponse + + @objc + func getAppCheckToken(withArtifact artifact: Data, challenge: Data, assertion: Data, + limitedUse: Bool) async throws -> AppCheckCoreToken +} + +@objc(GACAppAttestAPIService) +public class AppCheckCoreAppAttestAPIService: NSObject, AppCheckCoreAppAttestAPIServiceProtocol { + private let apiService: AppCheckCoreAPIServiceProtocol + private let resourceName: String + + @objc(initWithAPIService:resourceName:) + public init(apiService: AppCheckCoreAPIServiceProtocol, resourceName: String) { + self.apiService = apiService + self.resourceName = resourceName + super.init() + } + + // MARK: - API Calls + + @objc + public func getRandomChallenge() async throws -> Data { + let url = urlForEndpoint(kGenerateAppAttestChallengeEndpoint) + let response = try await apiService.sendRequest( + withURL: url, + httpMethod: kHTTPMethodPost, + body: nil, + additionalHeaders: nil + ) + return try randomChallengeWithAPIResponse(response) + } + + @objc + public func attestKey(withAttestation attestation: Data, keyID: String, challenge: Data, + limitedUse: Bool) async throws -> AppCheckCoreAppAttestAttestationResponse { + let url = urlForEndpoint(kExchangeAppAttestAttestationEndpoint) + let body = try httpBody( + withAttestation: attestation, + keyID: keyID, + challenge: challenge, + limitedUse: limitedUse + ) + + let urlResponse = try await apiService.sendRequest( + withURL: url, + httpMethod: kHTTPMethodPost, + body: body, + additionalHeaders: [kContentTypeKey: kJSONContentType] + ) + + guard let responseData = urlResponse.httpBody else { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Invalid or missing response data.") + } + let response = try AppCheckCoreAppAttestAttestationResponse( + responseData: responseData, + requestDate: Date() + ) + + return response + } + + @objc + public func getAppCheckToken(withArtifact artifact: Data, challenge: Data, assertion: Data, + limitedUse: Bool) async throws -> AppCheckCoreToken { + let url = urlForEndpoint(kExchangeAppAttestAssertionEndpoint) + let body = try httpBody( + withArtifact: artifact, + challenge: challenge, + assertion: assertion, + limitedUse: limitedUse + ) + + let urlResponse = try await apiService.sendRequest( + withURL: url, + httpMethod: kHTTPMethodPost, + body: body, + additionalHeaders: [kContentTypeKey: kJSONContentType] + ) + + let token = try await apiService.appCheckToken(withAPIResponse: urlResponse) + return token + } + + // MARK: - Challenge parsing + + private func randomChallengeWithAPIResponse(_ response: AppCheckCoreURLSessionDataResponse) throws + -> Data { + guard let responseData = response.httpBody else { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Empty server response body.") + } + + if responseData.isEmpty { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Empty server response body.") + } + + guard let responseDict = try? JSONSerialization + .jsonObject(with: responseData, options: []) as? [String: Any] else { + throw AppCheckCoreErrorUtil.jsonSerializationError(NSError( + domain: NSCocoaErrorDomain, + code: 0, + userInfo: nil + )) + } + + guard let challengeBase64 = responseDict["challenge"] as? String else { + throw AppCheckCoreErrorUtil.appCheckTokenResponseError(withMissingField: "challenge") + } + + guard let challenge = Data(base64Encoded: challengeBase64) else { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Invalid base64 string for challenge.") + } + + return challenge + } + + // MARK: - Body Builders + + private func httpBody(withAttestation attestation: Data, keyID: String, challenge: Data, + limitedUse: Bool) throws -> Data { + if attestation.isEmpty || keyID.isEmpty || challenge.isEmpty { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Missing or empty request parameter.") + } + + let jsonObject: [String: Any] = [ + kRequestFieldKeyID: keyID, + kRequestFieldAttestation: attestation.base64EncodedString(), + kRequestFieldChallenge: challenge.base64EncodedString(), + kRequestFieldLimitedUse: limitedUse, + ] + + return try httpBody(withJSONObject: jsonObject) + } + + private func httpBody(withArtifact artifact: Data, challenge: Data, assertion: Data, + limitedUse: Bool) throws -> Data { + if artifact.isEmpty || challenge.isEmpty || assertion.isEmpty { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Missing or empty request parameter.") + } + + let jsonObject: [String: Any] = [ + kRequestFieldArtifact: artifact.base64EncodedString(), + kRequestFieldChallenge: challenge.base64EncodedString(), + kRequestFieldAssertion: assertion.base64EncodedString(), + kRequestFieldLimitedUse: limitedUse, + ] + + return try httpBody(withJSONObject: jsonObject) + } + + private func httpBody(withJSONObject jsonObject: Any) throws -> Data { + do { + return try JSONSerialization.data(withJSONObject: jsonObject, options: []) + } catch { + throw AppCheckCoreErrorUtil.jsonSerializationError(error as NSError) + } + } + + // MARK: - URL Helpers + + private func urlForEndpoint(_ endpoint: String) -> URL { + let urlString = "\(apiService.baseURL)/\(resourceName):\(endpoint)" + return URL(string: urlString)! + } +} diff --git a/AppCheckCore/Sources/AppAttestProvider/API/AppCheckCoreAppAttestAttestationResponse.swift b/AppCheckCore/Sources/AppAttestProvider/API/AppCheckCoreAppAttestAttestationResponse.swift new file mode 100644 index 00000000..1469d850 --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/API/AppCheckCoreAppAttestAttestationResponse.swift @@ -0,0 +1,73 @@ +// 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 Foundation + +private let kResponseFieldAppCheckTokenDict = "appCheckToken" +private let kResponseFieldArtifact = "artifact" + +@objc(GACAppAttestAttestationResponse) +public class AppCheckCoreAppAttestAttestationResponse: NSObject { + @objc public let artifact: Data + @objc public let token: AppCheckCoreToken + + @objc(initWithArtifact:token:) + public init(artifact: Data, token: AppCheckCoreToken) { + self.artifact = artifact + self.token = token + super.init() + } + + @objc(initWithResponseData:requestDate:error:) + public init(responseData: Data, requestDate: Date) throws { + if responseData.isEmpty { + throw AppCheckCoreErrorUtil + .error( + withFailureReason: "Failed to parse the initial handshake response. Empty server response body." + ) + } + + let responseDict = try JSONSerialization + .jsonObject(with: responseData, options: []) as? [String: Any] + + guard let responseDict = responseDict else { + throw AppCheckCoreErrorUtil.jsonSerializationError(NSError( + domain: NSCocoaErrorDomain, + code: 0, + userInfo: nil + )) + } + + guard let artifactBase64String = responseDict[kResponseFieldArtifact] as? String, + let artifactData = Data(base64Encoded: artifactBase64String) else { + throw AppCheckCoreErrorUtil + .appAttestAttestationResponseError(withMissingField: kResponseFieldArtifact) + } + + guard let appCheckTokenDict = responseDict[kResponseFieldAppCheckTokenDict] as? [String: Any] + else { + throw AppCheckCoreErrorUtil + .appAttestAttestationResponseError(withMissingField: kResponseFieldAppCheckTokenDict) + } + + let appCheckToken = try AppCheckCoreToken( + responseDict: appCheckTokenDict, + requestDate: requestDate + ) + + artifact = artifactData + token = appCheckToken + super.init() + } +} diff --git a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.h b/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.h deleted file mode 100644 index dad0a1a4..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.h +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2021 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 - -@class FBLPromise; -@class GACAppAttestAttestationResponse; -@class GACAppCheckToken; -@protocol _GACAppCheckAPIServiceProtocol; - -NS_ASSUME_NONNULL_BEGIN - -/// Methods to send API requests required for App Attest based attestation sequence. -@protocol GACAppAttestAPIServiceProtocol - -/// Request a random challenge from server. -- (FBLPromise *)getRandomChallenge; - -/// Sends attestation data to the App Check backend for validation. -/// @param attestation The App Attest key attestation data obtained from the method -/// `-[DCAppAttestService attestKey:clientDataHash:completionHandler:]` using the random challenge -/// received from App Check backend. -/// @param keyID The key ID used to generate the attestation. -/// @param challenge The challenge used to generate the attestation. -/// @return A promise that is fulfilled with a response object with an encrypted attestation -/// artifact and an App Check token or rejected with an error. -- (FBLPromise *)attestKeyWithAttestation:(NSData *)attestation - keyID:(NSString *)keyID - challenge:(NSData *)challenge - limitedUse:(BOOL)limitedUse; - -/// Exchanges attestation data (artifact & assertion) and a challenge for a FAC token. -- (FBLPromise *)getAppCheckTokenWithArtifact:(NSData *)artifact - challenge:(NSData *)challenge - assertion:(NSData *)assertion - limitedUse:(BOOL)limitedUse; - -@end - -/// A default implementation of `GACAppAttestAPIServiceProtocol`. -@interface GACAppAttestAPIService : NSObject - -/// Default initializer. -/// -/// @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<_GACAppCheckAPIServiceProtocol>)APIService - resourceName:(NSString *)resourceName NS_DESIGNATED_INITIALIZER; - -- (instancetype)init NS_UNAVAILABLE; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.m b/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.m deleted file mode 100644 index 4743a82d..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.m +++ /dev/null @@ -1,268 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.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 - -static NSString *const kRequestFieldArtifact = @"artifact"; -static NSString *const kRequestFieldAssertion = @"assertion"; -static NSString *const kRequestFieldAttestation = @"attestation_statement"; -static NSString *const kRequestFieldChallenge = @"challenge"; -static NSString *const kRequestFieldKeyID = @"key_id"; -static NSString *const kRequestFieldLimitedUse = @"limited_use"; - -static NSString *const kExchangeAppAttestAssertionEndpoint = @"exchangeAppAttestAssertion"; -static NSString *const kExchangeAppAttestAttestationEndpoint = @"exchangeAppAttestAttestation"; -static NSString *const kGenerateAppAttestChallengeEndpoint = @"generateAppAttestChallenge"; - -static NSString *const kContentTypeKey = @"Content-Type"; -static NSString *const kJSONContentType = @"application/json"; -static NSString *const kHTTPMethodPost = @"POST"; - -@interface GACAppAttestAPIService () - -@property(nonatomic, readonly) id<_GACAppCheckAPIServiceProtocol> APIService; - -@property(nonatomic, readonly) NSString *resourceName; - -@end - -@implementation GACAppAttestAPIService - -- (instancetype)initWithAPIService:(id<_GACAppCheckAPIServiceProtocol>)APIService - resourceName:(NSString *)resourceName { - self = [super init]; - if (self) { - _APIService = APIService; - _resourceName = [resourceName copy]; - } - return self; -} - -#pragma mark - Assertion request - -- (FBLPromise *)getAppCheckTokenWithArtifact:(NSData *)artifact - challenge:(NSData *)challenge - assertion:(NSData *)assertion - limitedUse:(BOOL)limitedUse { - NSURL *URL = [self URLForEndpoint:kExchangeAppAttestAssertionEndpoint]; - - return [self HTTPBodyWithArtifact:artifact - challenge:challenge - assertion:assertion - limitedUse:limitedUse] - .then(^FBLPromise<_GACURLSessionDataResponse *> *(NSData *HTTPBody) { - return [self.APIService sendRequestWithURL:URL - HTTPMethod:kHTTPMethodPost - body:HTTPBody - additionalHeaders:@{kContentTypeKey : kJSONContentType}]; - }) - .then(^id _Nullable(_GACURLSessionDataResponse *_Nullable response) { - return [self.APIService appCheckTokenWithAPIResponse:response]; - }); -} - -#pragma mark - Random Challenge - -- (nonnull FBLPromise *)getRandomChallenge { - NSURL *URL = [self URLForEndpoint:kGenerateAppAttestChallengeEndpoint]; - - return [FBLPromise onQueue:[self backgroundQueue] - do:^id _Nullable { - return [self.APIService sendRequestWithURL:URL - HTTPMethod:kHTTPMethodPost - body:nil - additionalHeaders:nil]; - }] - .then(^id _Nullable(_GACURLSessionDataResponse *_Nullable response) { - return [self randomChallengeWithAPIResponse:response]; - }); -} - -#pragma mark - Challenge response parsing - -- (FBLPromise *)randomChallengeWithAPIResponse:(_GACURLSessionDataResponse *)response { - return [FBLPromise onQueue:[self backgroundQueue] - do:^id _Nullable { - NSError *error; - - NSData *randomChallenge = - [self randomChallengeFromResponseBody:response.HTTPBody - error:&error]; - - return randomChallenge ?: error; - }]; -} - -- (nullable NSData *)randomChallengeFromResponseBody:(NSData *)response error:(NSError **)outError { - if (response.length <= 0) { - GACAppCheckSetErrorToPointer( - [_GACAppCheckErrorUtil errorWithFailureReason:@"Empty server response body."], outError); - return nil; - } - - NSError *JSONError; - NSDictionary *responseDict = [NSJSONSerialization JSONObjectWithData:response - options:0 - error:&JSONError]; - - if (![responseDict isKindOfClass:[NSDictionary class]]) { - GACAppCheckSetErrorToPointer([_GACAppCheckErrorUtil JSONSerializationError:JSONError], - outError); - return nil; - } - - NSString *challenge = responseDict[@"challenge"]; - if (![challenge isKindOfClass:[NSString class]]) { - GACAppCheckSetErrorToPointer( - [_GACAppCheckErrorUtil appCheckTokenResponseErrorWithMissingField:@"challenge"], outError); - return nil; - } - - NSData *randomChallenge = [[NSData alloc] initWithBase64EncodedString:challenge options:0]; - return randomChallenge; -} - -#pragma mark - Attestation request - -- (FBLPromise *)attestKeyWithAttestation:(NSData *)attestation - keyID:(NSString *)keyID - challenge:(NSData *)challenge - limitedUse:(BOOL)limitedUse { - NSURL *URL = [self URLForEndpoint:kExchangeAppAttestAttestationEndpoint]; - - return [self HTTPBodyWithAttestation:attestation - keyID:keyID - challenge:challenge - limitedUse:limitedUse] - .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) { - NSError *error; - - __auto_type response = - [[GACAppAttestAttestationResponse alloc] initWithResponseData:URLResponse.HTTPBody - requestDate:[NSDate date] - error:&error]; - - return response ?: error; - }); -} - -#pragma mark - Request HTTP Body - -- (FBLPromise *)HTTPBodyWithArtifact:(NSData *)artifact - challenge:(NSData *)challenge - assertion:(NSData *)assertion - limitedUse:(BOOL)limitedUse { - if (artifact.length <= 0 || challenge.length <= 0 || assertion.length <= 0) { - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:[_GACAppCheckErrorUtil - errorWithFailureReason:@"Missing or empty request parameter."]]; - return rejectedPromise; - } - - return [FBLPromise onQueue:[self backgroundQueue] - do:^id { - id JSONObject = @{ - kRequestFieldArtifact : [self base64StringWithData:artifact], - kRequestFieldChallenge : [self base64StringWithData:challenge], - kRequestFieldAssertion : [self base64StringWithData:assertion], - kRequestFieldLimitedUse : @(limitedUse) - }; - - return [self HTTPBodyWithJSONObject:JSONObject]; - }]; -} - -- (FBLPromise *)HTTPBodyWithAttestation:(NSData *)attestation - keyID:(NSString *)keyID - challenge:(NSData *)challenge - limitedUse:(BOOL)limitedUse { - if (attestation.length <= 0 || keyID.length <= 0 || challenge.length <= 0) { - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:[_GACAppCheckErrorUtil - errorWithFailureReason:@"Missing or empty request parameter."]]; - return rejectedPromise; - } - - return [FBLPromise onQueue:[self backgroundQueue] - do:^id { - id JSONObject = @{ - kRequestFieldKeyID : keyID, - kRequestFieldAttestation : [self base64StringWithData:attestation], - kRequestFieldChallenge : [self base64StringWithData:challenge], - kRequestFieldLimitedUse : @(limitedUse) - }; - - return [self HTTPBodyWithJSONObject:JSONObject]; - }]; -} - -- (FBLPromise *)HTTPBodyWithJSONObject:(nonnull id)JSONObject { - NSError *encodingError; - NSData *payloadJSON = [NSJSONSerialization dataWithJSONObject:JSONObject - options:0 - error:&encodingError]; - FBLPromise *HTTPBodyPromise = [FBLPromise pendingPromise]; - if (payloadJSON) { - [HTTPBodyPromise fulfill:payloadJSON]; - } else { - [HTTPBodyPromise reject:[_GACAppCheckErrorUtil JSONSerializationError:encodingError]]; - } - return HTTPBodyPromise; -} - -#pragma mark - Helpers - -- (NSString *)base64StringWithData:(NSData *)data { - return [data base64EncodedStringWithOptions:0]; -} - -- (NSURL *)URLForEndpoint:(NSString *)endpoint { - NSString *URL = [[self class] URLWithBaseURL:self.APIService.baseURL - resourceName:self.resourceName]; - return [NSURL URLWithString:[NSString stringWithFormat:@"%@:%@", URL, endpoint]]; -} - -+ (NSString *)URLWithBaseURL:(NSString *)baseURL resourceName:(NSString *)resourceName { - return [NSString stringWithFormat:@"%@/%@", baseURL, resourceName]; -} - -- (dispatch_queue_t)backgroundQueue { - return dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.h b/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.h deleted file mode 100644 index 50bbf77d..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.h +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2021 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 - -@class GACAppCheckToken; - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppAttestAttestationResponse : NSObject - -/// App Attest attestation artifact required to refresh Firebase App Check token. -@property(nonatomic, readonly) NSData *artifact; - -/// Firebase App Check token. -@property(nonatomic, readonly) GACAppCheckToken *token; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithArtifact:(NSData *)artifact - token:(GACAppCheckToken *)token NS_DESIGNATED_INITIALIZER; - -/// Init with the server response. -- (nullable instancetype)initWithResponseData:(NSData *)response - requestDate:(NSDate *)requestDate - error:(NSError **)outError; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.m b/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.m deleted file mode 100644 index 906d5ae4..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.m +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.h" - -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -static NSString *const kResponseFieldAppCheckTokenDict = @"appCheckToken"; -static NSString *const kResponseFieldArtifact = @"artifact"; - -@implementation GACAppAttestAttestationResponse - -- (instancetype)initWithArtifact:(NSData *)artifact token:(GACAppCheckToken *)token { - self = [super init]; - if (self) { - _artifact = artifact; - _token = token; - } - return self; -} - -- (nullable instancetype)initWithResponseData:(NSData *)response - requestDate:(NSDate *)requestDate - error:(NSError **)outError { - if (response.length <= 0) { - GACAppCheckSetErrorToPointer( - [_GACAppCheckErrorUtil - errorWithFailureReason: - @"Failed to parse the initial handshake response. Empty server response body."], - outError); - return nil; - } - - NSError *JSONError; - NSDictionary *responseDict = [NSJSONSerialization JSONObjectWithData:response - options:0 - error:&JSONError]; - - if (![responseDict isKindOfClass:[NSDictionary class]]) { - GACAppCheckSetErrorToPointer([_GACAppCheckErrorUtil JSONSerializationError:JSONError], - outError); - return nil; - } - - NSString *artifactBase64String = responseDict[kResponseFieldArtifact]; - if (![artifactBase64String isKindOfClass:[NSString class]]) { - GACAppCheckSetErrorToPointer( - [_GACAppCheckErrorUtil - appAttestAttestationResponseErrorWithMissingField:kResponseFieldArtifact], - outError); - return nil; - } - NSData *artifactData = [[NSData alloc] initWithBase64EncodedString:artifactBase64String - options:0]; - if (artifactData == nil) { - GACAppCheckSetErrorToPointer( - [_GACAppCheckErrorUtil - appAttestAttestationResponseErrorWithMissingField:kResponseFieldArtifact], - outError); - return nil; - } - - NSDictionary *appCheckTokenDict = responseDict[kResponseFieldAppCheckTokenDict]; - if (![appCheckTokenDict isKindOfClass:[NSDictionary class]]) { - GACAppCheckSetErrorToPointer( - [_GACAppCheckErrorUtil - appAttestAttestationResponseErrorWithMissingField:kResponseFieldAppCheckTokenDict], - outError); - return nil; - } - - GACAppCheckToken *appCheckToken = [[GACAppCheckToken alloc] initWithResponseDict:appCheckTokenDict - requestDate:requestDate - error:outError]; - - if (appCheckToken == nil) { - return nil; - } - - return [self initWithArtifact:artifactData token:appCheckToken]; -} - -@end diff --git a/AppCheckCore/Sources/AppAttestProvider/AppCheckCoreAppAttestProvider.swift b/AppCheckCore/Sources/AppAttestProvider/AppCheckCoreAppAttestProvider.swift new file mode 100644 index 00000000..9b315596 --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/AppCheckCoreAppAttestProvider.swift @@ -0,0 +1,524 @@ +// 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 DeviceCheck +import Foundation + +@available(iOS 14.0, macOS 11.0, tvOS 15.0, watchOS 9.0, *) +@objc(GACAppAttestProvider) +@objcMembers +public class AppCheckCoreAppAttestProvider: NSObject, AppCheckCoreProvider { + // MARK: - Internal Properties + + private let apiService: AppCheckCoreAppAttestAPIServiceProtocol + private let appAttestService: AppCheckCoreAppAttestService + private let keyIDStorage: AppCheckCoreAppAttestKeyIDStorageProtocol + private let artifactStorage: AppCheckCoreAppAttestArtifactStorageProtocol + private let backoffWrapper: AppCheckBackoffWrapperProtocol + + private var ongoingGetTokenOperationTask: Task? + private var ongoingGetTokenOperationLimitedUse: Bool = false + private let lock = NSLock() + + // MARK: - Initializers + + @available(*, unavailable) + override public init() { + fatalError("init() is unavailable") + } + + init(appAttestService: AppCheckCoreAppAttestService, + apiService: AppCheckCoreAppAttestAPIServiceProtocol, + keyIDStorage: AppCheckCoreAppAttestKeyIDStorageProtocol, + artifactStorage: AppCheckCoreAppAttestArtifactStorageProtocol, + backoffWrapper: AppCheckBackoffWrapperProtocol) { + self.appAttestService = appAttestService + self.apiService = apiService + self.keyIDStorage = keyIDStorage + self.artifactStorage = artifactStorage + self.backoffWrapper = backoffWrapper + super.init() + } + + @objc(initWithServiceName:resourceName:baseURL:APIKey:keychainAccessGroup:requestHooks:) + public convenience init(serviceName: String, + resourceName: String, + baseURL: String?, + apiKey: String?, + keychainAccessGroup accessGroup: String?, + requestHooks: [Any]?) { + let urlSession = URLSession(configuration: .ephemeral) + let storageKeySuffix = AppCheckCoreAppAttestProvider.storageKeySuffix( + serviceName: serviceName, + resourceName: resourceName + ) + + let keyIDStorage = AppCheckCoreAppAttestKeyIDStorage(keySuffix: storageKeySuffix) + let coreAPIService = AppCheckCoreAPIService( + urlSession: urlSession, + baseURL: baseURL, + apiKey: apiKey, + requestHooks: requestHooks + ) + let appAttestAPIService = AppCheckCoreAppAttestAPIService( + apiService: coreAPIService, + resourceName: resourceName + ) + let artifactStorage = AppCheckCoreAppAttestArtifactStorage( + keySuffix: storageKeySuffix, + accessGroup: accessGroup + ) + let backoffWrapper = AppCheckCoreBackoffWrapper() + + self.init( + appAttestService: DCAppAttestService.shared, + apiService: appAttestAPIService, + keyIDStorage: keyIDStorage, + artifactStorage: artifactStorage, + backoffWrapper: backoffWrapper + ) + } + + // MARK: - AppCheckCoreProvider + + public func getToken(completion handler: @escaping (AppCheckCoreToken?, Error?) -> Void) { + getToken(limitedUse: false, completion: handler) + } + + public func getLimitedUseToken(completion handler: @escaping (AppCheckCoreToken?, Error?) + -> Void) { + getToken(limitedUse: true, completion: handler) + } + + public func getToken() async throws -> AppCheckCoreToken { + return try await getToken(limitedUse: false) + } + + public func getLimitedUseToken() async throws -> AppCheckCoreToken { + return try await getToken(limitedUse: true) + } + + // MARK: - Internal + + private func getToken(limitedUse: Bool, + completion handler: @escaping (AppCheckCoreToken?, Error?) -> Void) { + Task { + do { + let token = try await getToken(limitedUse: limitedUse) + handler(token, nil) + } catch { + handler(nil, error) + } + } + } + + private enum GetTokenAction { + case retry(Task) + case wait(Task) + case run(Task) + } + + private func getToken(limitedUse: Bool) async throws -> AppCheckCoreToken { + let action: GetTokenAction = lock.execute { + if let ongoingTask = ongoingGetTokenOperationTask { + if limitedUse || ongoingGetTokenOperationLimitedUse != limitedUse { + return .retry(ongoingTask) + } + return .wait(ongoingTask) + } + + ongoingGetTokenOperationLimitedUse = limitedUse + let newTask = Task { + try await createGetTokenSequenceWithBackoff(limitedUse: limitedUse) + } + ongoingGetTokenOperationTask = newTask + return .run(newTask) + } + + switch action { + case let .retry(ongoingTask): + _ = try? await ongoingTask.value + return try await getToken(limitedUse: limitedUse) + case let .wait(ongoingTask): + return try await ongoingTask.value + case let .run(newTask): + defer { + lock.execute { + ongoingGetTokenOperationTask = nil + } + } + return try await newTask.value + } + } + + private func createGetTokenSequenceWithBackoff(limitedUse: Bool) async throws + -> AppCheckCoreToken { + let result = try await backoffWrapper.applyBackoffToOperation({ + try await self.createGetTokenSequence(limitedUse: limitedUse) + }, errorHandler: backoffWrapper.defaultAppCheckProviderErrorHandler()) + return result as! AppCheckCoreToken + } + + private func createGetTokenSequence(limitedUse: Bool) async throws -> AppCheckCoreToken { + var attempts = 0 + while attempts < 2 { + do { + let attestState = try await attestationState() + + switch attestState.state { + case .unsupported: + AppCheckCoreLogger.log( + code: .appAttestNotSupported, + logLevel: .debug, + message: "App Attest is not supported." + ) + if let error = attestState.appAttestUnsupportedError { + if let rejectionError = error as? AppCheckCoreAppAttestRejectionError { + throw rejectionError.underlyingError ?? rejectionError + } + throw error + } + throw AppCheckCoreErrorUtil.unsupportedAttestationProvider("AppAttestProvider") + case .supportedInitial, .keyGenerated: + return try await initialHandshake( + keyID: attestState.appAttestKeyID, + limitedUse: limitedUse + ) + case .keyRegistered: + guard let keyID = attestState.appAttestKeyID, + let artifact = attestState.attestationArtifact else { + throw AppCheckCoreErrorUtil.unsupportedAttestationProvider("AppAttestProvider") + } + return try await refreshToken(keyID: keyID, artifact: artifact, limitedUse: limitedUse) + @unknown default: + throw AppCheckCoreErrorUtil.unsupportedAttestationProvider("AppAttestProvider") + } + } catch { + if error is AppCheckCoreAppAttestRejectionError, attempts == 0 { + attempts += 1 + continue + } + if let rejectionError = error as? AppCheckCoreAppAttestRejectionError { + throw rejectionError.underlyingError ?? rejectionError + } + throw error + } + } + throw AppCheckCoreErrorUtil.unsupportedAttestationProvider("AppAttestProvider") + } + + // MARK: - Initial handshake sequence (attestation) + + private func initialHandshake(keyID: String?, + limitedUse: Bool) async throws -> AppCheckCoreToken { + let (attestedKeyID, _, firebaseResponse) = try await attestKeyGenerateIfNeeded( + keyID: keyID, + limitedUse: limitedUse + ) + return try await saveArtifactAndGetAppCheckToken( + response: firebaseResponse, + keyID: attestedKeyID + ) + } + + private func saveArtifactAndGetAppCheckToken(response: AppCheckCoreAppAttestAttestationResponse, + keyID: String) async throws -> AppCheckCoreToken { + _ = try await artifactStorage.setArtifact(response.artifact, forKey: keyID) + return response.token + } + + private func attestKey(keyID: String, + challenge: Data) async throws + -> AppCheckCoreAppAttestKeyAttestationResult { + let challengeHash = AppCheckCoreCryptoUtils.sha256Hash(from: challenge) + do { + let attestation = + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + Data, + Error + >) in + appAttestService.attestKey(keyID, clientDataHash: challengeHash) { data, error in + if let error = error { + continuation.resume(throwing: error) + } else if let data = data { + continuation.resume(returning: data) + } else { + let unknownError = NSError(domain: "AppCheckCore", code: 0, userInfo: nil) + continuation.resume(throwing: unknownError) + } + } + } + return AppCheckCoreAppAttestKeyAttestationResult( + keyID: keyID, + challenge: challenge, + attestation: attestation + ) + } catch { + throw AppCheckCoreErrorUtil.appAttestAttestKeyFailed( + with: error, + keyId: keyID, + clientDataHash: challengeHash + ) + } + } + + private func attestKeyGenerateIfNeeded(keyID: String?, + limitedUse: Bool) async throws -> ( + String, + Data, + AppCheckCoreAppAttestAttestationResponse + ) { + let challenge: Data + let generatedKeyID: String + + do { + async let fetchChallenge = apiService.getRandomChallenge() + async let fetchKeyID = generateAppAttestKeyIDIfNeeded(storedKeyID: keyID) + challenge = try await fetchChallenge + generatedKeyID = try await fetchKeyID + } catch { + if let rejectionError = error as? AppCheckCoreAppAttestRejectionError { + throw rejectionError.underlyingError ?? rejectionError + } + throw error + } + + let attestationResult: AppCheckCoreAppAttestKeyAttestationResult + do { + attestationResult = try await attestKey(keyID: generatedKeyID, challenge: challenge) + } catch { + let nsError = error as NSError + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, + underlyingError.domain == DCErrorDomain, + underlyingError.code == DCError.invalidKey.rawValue || underlyingError.code == DCError + .invalidInput.rawValue { + AppCheckCoreLogger.log( + code: .attestationRejected, + logLevel: .debug, + message: "App Attest invalid key/input; the existing attestation will be reset. DC Error Code: \(underlyingError.code)." + ) + try await resetAttestation() + throw AppCheckCoreAppAttestRejectionError(underlyingError: error) + } + if let rejectionError = error as? AppCheckCoreAppAttestRejectionError { + throw rejectionError.underlyingError ?? rejectionError + } + throw error + } + + do { + let response = try await apiService.attestKey( + withAttestation: attestationResult.attestation, + keyID: attestationResult.keyID, + challenge: attestationResult.challenge, + limitedUse: limitedUse + ) + return (attestationResult.keyID, attestationResult.attestation, response) + } catch let httpError as AppCheckCoreHTTPError where httpError.httpResponse.statusCode == 403 { + AppCheckCoreLogger.log( + code: .attestationRejected, + logLevel: .debug, + message: "App Attest attestation was rejected by backend. The existing attestation will be reset." + ) + try await resetAttestation() + throw AppCheckCoreAppAttestRejectionError(underlyingError: httpError) + } catch { + if let rejectionError = error as? AppCheckCoreAppAttestRejectionError { + throw rejectionError.underlyingError ?? rejectionError + } + throw error + } + } + + private func resetAttestation() async throws { + _ = try await keyIDStorage.setAppAttestKeyID(nil) + _ = try await artifactStorage.setArtifact(nil, forKey: "") + } + + // MARK: - Token refresh sequence (assertion) + + private func refreshToken(keyID: String, artifact: Data, + limitedUse: Bool) async throws -> AppCheckCoreToken { + let challenge = try await apiService.getRandomChallenge() + let assertion = try await generateAssertion( + keyID: keyID, + artifact: artifact, + challenge: challenge + ) + let token = try await apiService.getAppCheckToken( + withArtifact: assertion.artifact, + challenge: assertion.challenge, + assertion: assertion.assertion, + limitedUse: limitedUse + ) + return token + } + + private func generateAssertion(keyID: String, artifact: Data, + challenge: Data) async throws + -> AppCheckCoreAppAttestAssertionData { + var statementForAssertion = artifact + statementForAssertion.append(challenge) + + let statementHash = AppCheckCoreCryptoUtils.sha256Hash(from: statementForAssertion) + + do { + let assertion = + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + Data, + Error + >) in + appAttestService.generateAssertion(keyID, clientDataHash: statementHash) { data, error in + if let error = error { + continuation.resume(throwing: error) + } else if let data = data { + continuation.resume(returning: data) + } else { + let unknownError = NSError(domain: "AppCheckCore", code: 0, userInfo: nil) + continuation.resume(throwing: unknownError) + } + } + } + return AppCheckCoreAppAttestAssertionData( + challenge: challenge, + artifact: artifact, + assertion: assertion + ) + } catch { + let wrappedError = AppCheckCoreErrorUtil.appAttestGenerateAssertionFailed( + with: error, + keyId: keyID, + clientDataHash: statementHash + ) + + let nsError = wrappedError as NSError + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, + underlyingError.domain == DCErrorDomain, + underlyingError.code == DCError.invalidKey.rawValue || + underlyingError.code == DCError.invalidInput.rawValue || + underlyingError.code == DCError.unknownSystemFailure.rawValue { + AppCheckCoreLogger.log( + code: .assertionRejected, + logLevel: .debug, + message: "App Attest invalid key/input/system failure; the existing attestation will be reset. DC Error Code: \(underlyingError.code)." + ) + try await resetAttestation() + throw AppCheckCoreAppAttestRejectionError(underlyingError: wrappedError) + } + throw wrappedError + } + } + + // MARK: - State handling + + private func attestationState() async throws -> AppCheckCoreAppAttestProviderState { + do { + try await isAppAttestSupported() + } catch { + return AppCheckCoreAppAttestProviderState(unsupportedWithError: error) + } + + let appAttestKeyID = try await keyIDStorage.getAppAttestKeyID() + guard let keyID = appAttestKeyID else { + return AppCheckCoreAppAttestProviderState(supportedInitialState: ()) + } + + let attestationArtifact = try await artifactStorage.getArtifact(forKey: keyID) + guard let artifact = attestationArtifact else { + return AppCheckCoreAppAttestProviderState(generatedKeyID: keyID) + } + + return AppCheckCoreAppAttestProviderState(registeredKeyID: keyID, artifact: artifact) + } + + // MARK: - Helpers + + private func isAppAttestSupported() async throws { + if appAttestService.isSupported { + return + } else { + throw AppCheckCoreErrorUtil.unsupportedAttestationProvider("AppAttestProvider") + } + } + + private func generateAppAttestKeyIDIfNeeded(storedKeyID: String?) async throws -> String { + if let storedKeyID = storedKeyID { + return storedKeyID + } else { + return try await generateAppAttestKey() + } + } + + private func generateAppAttestKey() async throws -> String { + do { + let keyID = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + String, + Error + >) in + appAttestService.generateKey { key, error in + if let error = error { + continuation.resume(throwing: error) + } else if let key = key { + continuation.resume(returning: key) + } else { + let unknownError = NSError(domain: "AppCheckCore", code: 0, userInfo: nil) + continuation.resume(throwing: unknownError) + } + } + } + _ = try await keyIDStorage.setAppAttestKeyID(keyID) + return keyID + } catch { + throw AppCheckCoreErrorUtil.appAttestGenerateKeyFailed(with: error) + } + } + + static func storageKeySuffix(serviceName: String, resourceName: String) -> String { + return "\(serviceName).\(resourceName)" + } +} + +// MARK: - Data Objects + +private class AppCheckCoreAppAttestKeyAttestationResult { + let keyID: String + let challenge: Data + let attestation: Data + + init(keyID: String, challenge: Data, attestation: Data) { + self.keyID = keyID + self.challenge = challenge + self.attestation = attestation + } +} + +private class AppCheckCoreAppAttestAssertionData { + let challenge: Data + let artifact: Data + let assertion: Data + + init(challenge: Data, artifact: Data, assertion: Data) { + self.challenge = challenge + self.artifact = artifact + self.assertion = assertion + } +} + +extension NSLock { + func execute(_ block: () -> T) -> T { + lock() + defer { self.unlock() } + return block() + } +} diff --git a/AppCheckCore/Sources/AppAttestProvider/AppCheckCoreAppAttestProviderState.swift b/AppCheckCore/Sources/AppAttestProvider/AppCheckCoreAppAttestProviderState.swift new file mode 100644 index 00000000..36bcb5ee --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/AppCheckCoreAppAttestProviderState.swift @@ -0,0 +1,68 @@ +// 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 Foundation + +@objc(GACAppAttestAttestationState) +public enum AppCheckCoreAppAttestAttestationState: Int { + case unsupported + case supportedInitial + case keyGenerated + case keyRegistered +} + +@objc(GACAppAttestProviderState) +@objcMembers +public class AppCheckCoreAppAttestProviderState: NSObject { + public let state: AppCheckCoreAppAttestAttestationState + public let appAttestUnsupportedError: Error? + public let appAttestKeyID: String? + public let attestationArtifact: Data? + + @objc(initUnsupportedWithError:) + public init(unsupportedWithError error: Error) { + state = .unsupported + appAttestUnsupportedError = error + appAttestKeyID = nil + attestationArtifact = nil + super.init() + } + + @objc(initWithSupportedInitialState) + public init(supportedInitialState: Void = ()) { + state = .supportedInitial + appAttestUnsupportedError = nil + appAttestKeyID = nil + attestationArtifact = nil + super.init() + } + + @objc(initWithGeneratedKeyID:) + public init(generatedKeyID keyID: String) { + state = .keyGenerated + appAttestKeyID = keyID + appAttestUnsupportedError = nil + attestationArtifact = nil + super.init() + } + + @objc(initWithRegisteredKeyID:artifact:) + public init(registeredKeyID keyID: String, artifact: Data) { + state = .keyRegistered + appAttestKeyID = keyID + attestationArtifact = artifact + appAttestUnsupportedError = nil + super.init() + } +} diff --git a/AppCheckCore/Sources/AppAttestProvider/AppCheckCoreAppAttestService.swift b/AppCheckCore/Sources/AppAttestProvider/AppCheckCoreAppAttestService.swift new file mode 100644 index 00000000..e4e3489f --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/AppCheckCoreAppAttestService.swift @@ -0,0 +1,35 @@ +// 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 DeviceCheck +import Foundation + +@objc(GACAppAttestService) +public protocol AppCheckCoreAppAttestService: NSObjectProtocol { + @objc var isSupported: Bool { get } + + @objc(generateKeyWithCompletionHandler:) + func generateKey(completionHandler: @escaping @Sendable (String?, Error?) -> Void) + + @objc(attestKey:clientDataHash:completionHandler:) + func attestKey(_ keyId: String, clientDataHash: Data, + completionHandler: @escaping @Sendable (Data?, Error?) -> Void) + + @objc(generateAssertion:clientDataHash:completionHandler:) + func generateAssertion(_ keyId: String, clientDataHash: Data, + completionHandler: @escaping @Sendable (Data?, Error?) -> Void) +} + +@available(iOS 14.0, macOS 11.0, tvOS 15.0, watchOS 9.0, *) +extension DCAppAttestService: AppCheckCoreAppAttestService {} diff --git a/AppCheckCore/Sources/AppAttestProvider/DCAppAttestService+GACAppAttestService.h b/AppCheckCore/Sources/AppAttestProvider/DCAppAttestService+GACAppAttestService.h deleted file mode 100644 index ccb4a5e7..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/DCAppAttestService+GACAppAttestService.h +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckAvailability.h" - -#import - -#import "AppCheckCore/Sources/AppAttestProvider/GACAppAttestService.h" - -NS_ASSUME_NONNULL_BEGIN - -GAC_APP_ATTEST_PROVIDER_AVAILABILITY -@interface DCAppAttestService (GACAppAttestService) - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/AppAttestProvider/DCAppAttestService+GACAppAttestService.m b/AppCheckCore/Sources/AppAttestProvider/DCAppAttestService+GACAppAttestService.m deleted file mode 100644 index a858672d..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/DCAppAttestService+GACAppAttestService.m +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/AppAttestProvider/DCAppAttestService+GACAppAttestService.h" - -@implementation DCAppAttestService (GACAppAttestService) - -@end diff --git a/AppCheckCore/Sources/AppAttestProvider/Errors/AppCheckCoreAppAttestRejectionError.swift b/AppCheckCore/Sources/AppAttestProvider/Errors/AppCheckCoreAppAttestRejectionError.swift new file mode 100644 index 00000000..1046fbde --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/Errors/AppCheckCoreAppAttestRejectionError.swift @@ -0,0 +1,37 @@ +// 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 Foundation + +@objc(GACAppAttestRejectionError) +public class AppCheckCoreAppAttestRejectionError: NSError, @unchecked Sendable { + @objc + public var underlyingError: Error? { + return userInfo[NSUnderlyingErrorKey] as? Error + } + + @objc(initWithUnderlyingError:) + public init(underlyingError: Error) { + super.init( + domain: "AppCheckCoreErrorDomain", + code: 0, // AppCheckCoreErrorCodeUnknown is typically 0 + userInfo: [NSUnderlyingErrorKey: underlyingError] + ) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.h b/AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.h deleted file mode 100644 index 30c6fcc5..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.h +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2021 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 - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppAttestRejectionError : NSError - -@property(nonatomic, readonly) NSError *underlyingError; - -- (instancetype)initWithUnderlyingError:(NSError *)underlyingError; - -- (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithDomain:(NSErrorDomain)domain - code:(NSInteger)code - userInfo:(nullable NSDictionary *)dict NS_UNAVAILABLE; -- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.m b/AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.m deleted file mode 100644 index c637546d..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.m +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" - -#import "AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.h" - -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -@implementation GACAppAttestRejectionError - -- (NSError *)underlyingError { - return self.userInfo[NSUnderlyingErrorKey]; -} - -- (instancetype)initWithUnderlyingError:(NSError *)underlyingError { - return [self initWithDomain:GACAppCheckErrorDomain - code:GACAppCheckErrorCodeUnknown - userInfo:@{NSUnderlyingErrorKey : underlyingError}]; -} - -@end diff --git a/AppCheckCore/Sources/AppAttestProvider/GACAppAttestProvider.m b/AppCheckCore/Sources/AppAttestProvider/GACAppAttestProvider.m deleted file mode 100644 index f887f2d6..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/GACAppAttestProvider.m +++ /dev/null @@ -1,617 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppAttestProvider.h" - -#import "AppCheckCore/Sources/AppAttestProvider/DCAppAttestService+GACAppAttestService.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.h" -#import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.h" -#import "AppCheckCore/Sources/AppAttestProvider/GACAppAttestProviderState.h" -#import "AppCheckCore/Sources/AppAttestProvider/GACAppAttestService.h" -#import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.h" -#import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.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/GACAppCheckHTTPError.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -NS_ASSUME_NONNULL_BEGIN - -/// A data object that contains all key attest data required for FAC token exchange. -@interface GACAppAttestKeyAttestationResult : NSObject - -@property(nonatomic, readonly) NSString *keyID; -@property(nonatomic, readonly) NSData *challenge; -@property(nonatomic, readonly) NSData *attestation; - -- (instancetype)initWithKeyID:(NSString *)keyID - challenge:(NSData *)challenge - attestation:(NSData *)attestation; - -@end - -@implementation GACAppAttestKeyAttestationResult - -- (instancetype)initWithKeyID:(NSString *)keyID - challenge:(NSData *)challenge - attestation:(NSData *)attestation { - self = [super init]; - if (self) { - _keyID = keyID; - _challenge = challenge; - _attestation = attestation; - } - return self; -} - -@end - -/// A data object that contains information required for assertion request. -@interface GACAppAttestAssertionData : NSObject - -@property(nonatomic, readonly) NSData *challenge; -@property(nonatomic, readonly) NSData *artifact; -@property(nonatomic, readonly) NSData *assertion; - -- (instancetype)initWithChallenge:(NSData *)challenge - artifact:(NSData *)artifact - assertion:(NSData *)assertion; - -@end - -@implementation GACAppAttestAssertionData - -- (instancetype)initWithChallenge:(NSData *)challenge - artifact:(NSData *)artifact - assertion:(NSData *)assertion { - self = [super init]; - if (self) { - _challenge = challenge; - _artifact = artifact; - _assertion = assertion; - } - return self; -} - -@end - -@interface GACAppAttestProvider () - -@property(nonatomic, readonly) id APIService; -@property(nonatomic, readonly) id appAttestService; -@property(nonatomic, readonly) id keyIDStorage; -@property(nonatomic, readonly) id artifactStorage; -@property(nonatomic, readonly) id<_GACAppCheckBackoffWrapperProtocol> backoffWrapper; - -@property(nonatomic, nullable) FBLPromise *ongoingGetTokenOperation; -@property(nonatomic, assign) BOOL ongoingGetTokenOperationLimitedUse; - -@property(nonatomic, readonly) dispatch_queue_t queue; - -@end - -@implementation GACAppAttestProvider - -- (instancetype)initWithAppAttestService:(id)appAttestService - APIService:(id)APIService - keyIDStorage:(id)keyIDStorage - artifactStorage:(id)artifactStorage - backoffWrapper:(id<_GACAppCheckBackoffWrapperProtocol>)backoffWrapper { - self = [super init]; - if (self) { - _appAttestService = appAttestService; - _APIService = APIService; - _keyIDStorage = keyIDStorage; - _artifactStorage = artifactStorage; - _backoffWrapper = backoffWrapper; - _queue = dispatch_queue_create("com.google.GACAppAttestProvider", DISPATCH_QUEUE_SERIAL); - } - return self; -} - -- (instancetype)initWithServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName - baseURL:(nullable NSString *)baseURL - APIKey:(nullable NSString *)APIKey - keychainAccessGroup:(nullable NSString *)accessGroup - requestHooks:(nullable NSArray *)requestHooks { - NSURLSession *URLSession = [NSURLSession - sessionWithConfiguration:[NSURLSessionConfiguration ephemeralSessionConfiguration]]; - - NSString *storageKeySuffix = [GACAppAttestProvider storageKeySuffixWithServiceName:serviceName - resourceName:resourceName]; - - GACAppAttestKeyIDStorage *keyIDStorage = - [[GACAppAttestKeyIDStorage alloc] initWithKeySuffix:storageKeySuffix]; - - _GACAppCheckAPIService *APIService = - [[_GACAppCheckAPIService alloc] initWithURLSession:URLSession - baseURL:baseURL - APIKey:APIKey - requestHooks:requestHooks]; - - GACAppAttestAPIService *appAttestAPIService = - [[GACAppAttestAPIService alloc] initWithAPIService:APIService resourceName:resourceName]; - - GACAppAttestArtifactStorage *artifactStorage = - [[GACAppAttestArtifactStorage alloc] initWithKeySuffix:storageKeySuffix - accessGroup:accessGroup]; - - _GACAppCheckBackoffWrapper *backoffWrapper = [[_GACAppCheckBackoffWrapper alloc] init]; - - return [self initWithAppAttestService:DCAppAttestService.sharedService - APIService:appAttestAPIService - keyIDStorage:keyIDStorage - artifactStorage:artifactStorage - backoffWrapper:backoffWrapper]; -} - -#pragma mark - GACAppCheckProvider - -- (void)getTokenWithCompletion:(void (^)(GACAppCheckToken *_Nullable, NSError *_Nullable))handler { - [self getTokenWithLimitedUse:NO completion:handler]; -} - -- (void)getLimitedUseTokenWithCompletion:(void (^)(GACAppCheckToken *_Nullable, - NSError *_Nullable))handler { - [self getTokenWithLimitedUse:YES completion:handler]; -} - -#pragma mark - Internal - -- (void)getTokenWithLimitedUse:(BOOL)limitedUse - completion:(void (^)(GACAppCheckToken *_Nullable, NSError *_Nullable))handler { - [self getTokenWithLimitedUse:limitedUse] - // Call the handler with the result. - .then(^FBLPromise *(GACAppCheckToken *token) { - handler(token, nil); - return nil; - }) - .catch(^(NSError *error) { - handler(nil, error); - }); -} - -- (FBLPromise *)getTokenWithLimitedUse:(BOOL)limitedUse { - return [FBLPromise - onQueue:self.queue - do:^id _Nullable { - // If a get token operation is already in progress. - if (self.ongoingGetTokenOperation) { - // If a limited-use token is requested, or if the existing get token operation is for - // a limited-use token and a standard token is requested, wait until after the - // ongoing handshake has completed to kick off a new handshake; this is done to avoid - // race conditions and to avoid returning the same limited-use token multiple times. - if (limitedUse || self.ongoingGetTokenOperationLimitedUse != limitedUse) { - return self.ongoingGetTokenOperation.thenOn( - self.queue, ^id _Nullable(GACAppCheckToken *_Nullable value) { - return [self getTokenWithLimitedUse:limitedUse]; - }); - } - - // Re-use the existing - return self.ongoingGetTokenOperation; - } - - // Else if there are no get token operations in progress. - self.ongoingGetTokenOperationLimitedUse = limitedUse; - self.ongoingGetTokenOperation = - [self createGetTokenSequenceWithBackoffPromiseWithLimitedUse:limitedUse] - // Release the ongoing operation promise on completion. - .thenOn(self.queue, - ^GACAppCheckToken *(GACAppCheckToken *token) { - self.ongoingGetTokenOperation = nil; - return token; - }) - .recoverOn(self.queue, ^NSError *(NSError *error) { - self.ongoingGetTokenOperation = nil; - return error; - }); - - return self.ongoingGetTokenOperation; - }]; -} - -- (FBLPromise *)createGetTokenSequenceWithBackoffPromiseWithLimitedUse: - (BOOL)limitedUse { - return [self.backoffWrapper - applyBackoffToOperation:^FBLPromise *_Nonnull { - return [self createGetTokenSequencePromiseWithLimitedUse:limitedUse]; - } - errorHandler:[self.backoffWrapper defaultAppCheckProviderErrorHandler]]; -} - -- (FBLPromise *)createGetTokenSequencePromiseWithLimitedUse:(BOOL)limitedUse { - // Check attestation state to decide on the next steps. - return [FBLPromise onQueue:self.queue - attempts:1 - delay:0 - condition:^BOOL(NSInteger attempts, NSError *_Nonnull error) { - return [error isKindOfClass:[GACAppAttestRejectionError class]]; - } - retry:^id { - return [self attestationState].thenOn( - self.queue, ^id(GACAppAttestProviderState *attestState) { - switch (attestState.state) { - case GACAppAttestAttestationStateUnsupported: - GACAppCheckLogDebug(GACLoggerAppCheckMessageCodeAppAttestNotSupported, - @"App Attest is not supported."); - return attestState.appAttestUnsupportedError; - break; - - case GACAppAttestAttestationStateSupportedInitial: - case GACAppAttestAttestationStateKeyGenerated: - // Initial handshake is required for both the "initial" and the "key - // generated" states. - return [self initialHandshakeWithKeyID:attestState.appAttestKeyID - limitedUse:limitedUse]; - break; - - case GACAppAttestAttestationStateKeyRegistered: - // Refresh FAC token using the existing registered App Attest key pair. - return [self refreshTokenWithKeyID:attestState.appAttestKeyID - artifact:attestState.attestationArtifact - limitedUse:limitedUse]; - break; - } - }); - }] - .recoverOn(self.queue, ^id(NSError *error) { - if ([error isKindOfClass:[GACAppAttestRejectionError class]]) { - // The error was wrapped to indicate that it should be retried. The - // retry failed so throw the wrapped error. - return [(GACAppAttestRejectionError *)error underlyingError]; - } else { - // Otherwise just re-throw the error. - return error; - } - }); - ; -} - -#pragma mark - Initial handshake sequence (attestation) - -- (FBLPromise *)initialHandshakeWithKeyID:(nullable NSString *)keyID - limitedUse:(BOOL)limitedUse { - // Attest the device. Attestation rejection errors will be bubbled up to this - // method's caller and retried. - return [self attestKeyGenerateIfNeededWithID:keyID limitedUse:limitedUse].thenOn( - self.queue, - ^FBLPromise *(NSArray * /*[keyID, attestArtifact]*/ attestationResults) { - // Save the artifact and return the received FAC token. - - GACAppAttestKeyAttestationResult *attestation = attestationResults.firstObject; - GACAppAttestAttestationResponse *firebaseAttestationResponse = - attestationResults.lastObject; - - return [self saveArtifactAndGetAppCheckTokenFromResponse:firebaseAttestationResponse - keyID:attestation.keyID]; - }); -} - -- (FBLPromise *)saveArtifactAndGetAppCheckTokenFromResponse: - (GACAppAttestAttestationResponse *)response - keyID:(NSString *)keyID { - return [self.artifactStorage setArtifact:response.artifact forKey:keyID].thenOn( - self.queue, ^GACAppCheckToken *(id result) { - return response.token; - }); -} - -- (FBLPromise *)attestKey:(NSString *)keyID - challenge:(NSData *)challenge { - return [FBLPromise onQueue:self.queue - do:^NSData *_Nullable { - return [GACAppCheckCryptoUtils sha256HashFromData:challenge]; - }] - .thenOn(self.queue, - ^FBLPromise *(NSData *challengeHash) { - return [FBLPromise onQueue:self.queue - wrapObjectOrErrorCompletion:^( - FBLPromiseObjectOrErrorCompletion _Nonnull handler) { - [self.appAttestService attestKey:keyID - clientDataHash:challengeHash - completionHandler:handler]; - }] - .recoverOn(self.queue, ^id(NSError *error) { - return - [_GACAppCheckErrorUtil appAttestAttestKeyFailedWithError:error - keyId:keyID - clientDataHash:challengeHash]; - }); - }) - .thenOn(self.queue, ^FBLPromise *(NSData *attestation) { - GACAppAttestKeyAttestationResult *result = - [[GACAppAttestKeyAttestationResult alloc] initWithKeyID:keyID - challenge:challenge - attestation:attestation]; - return [FBLPromise resolvedWith:result]; - }); -} - -- (FBLPromise *) - attestKeyGenerateIfNeededWithID:(nullable NSString *)keyID - limitedUse:(BOOL)limitedUse { - // 1. Request a random challenge and get App Attest key ID concurrently. - return [FBLPromise onQueue:self.queue - all:@[ - // 1.1. Request random challenge. - [self.APIService getRandomChallenge], - // 1.2. Get App Attest key ID. - [self generateAppAttestKeyIDIfNeeded:keyID] - ]] - .thenOn(self.queue, - ^FBLPromise *(NSArray *challengeAndKeyID) { - // 2. Attest the key. - NSData *challenge = challengeAndKeyID.firstObject; - NSString *keyID = challengeAndKeyID.lastObject; - - return [self attestKey:keyID challenge:challenge]; - }) - .recoverOn(self.queue, - ^id(NSError *error) { - // If Apple rejected the key (DCErrorInvalidKey or - // DCErrorInvalidInput) then reset the attestation and - // throw a specific error to signal retry (GACAppAttestRejectionError). - NSError *underlyingError = error.userInfo[NSUnderlyingErrorKey]; - if (underlyingError && [underlyingError.domain isEqualToString:DCErrorDomain] && - (underlyingError.code == DCErrorInvalidKey || - underlyingError.code == DCErrorInvalidInput)) { - NSString *logMessage = [NSString - stringWithFormat:@"App Attest invalid key/input; the existing attestation " - @"will be reset. DC Error Code: %@.", - @(underlyingError.code)]; - GACAppCheckLog(GACLoggerAppCheckMessageCodeAttestationRejected, - GACAppCheckLogLevelDebug, logMessage); - // Reset the attestation. - return [self resetAttestation].thenOn(self.queue, ^NSError *(id result) { - // Throw the rejection error. - return [[GACAppAttestRejectionError alloc] initWithUnderlyingError:error]; - }); - } - - // Otherwise just re-throw the error. - return error; - }) - .thenOn(self.queue, - ^FBLPromise *(GACAppAttestKeyAttestationResult *result) { - // 3. Exchange the attestation to FAC token and pass the results to the next step. - NSArray *attestationResults = @[ - // 3.1. Just pass the attestation result to the next step. - [FBLPromise resolvedWith:result], - // 3.2. Exchange the attestation to FAC token. - [self.APIService attestKeyWithAttestation:result.attestation - keyID:result.keyID - challenge:result.challenge - limitedUse:limitedUse] - ]; - - return [FBLPromise onQueue:self.queue all:attestationResults]; - }) - .recoverOn(self.queue, ^id(NSError *error) { - // If App Attest attestation was rejected then reset the attestation and throw a specific - // error. - GACAppCheckHTTPError *HTTPError = (GACAppCheckHTTPError *)error; - if ([HTTPError isKindOfClass:[GACAppCheckHTTPError class]] && - HTTPError.HTTPResponse.statusCode == 403) { - GACAppCheckLogDebug(GACLoggerAppCheckMessageCodeAttestationRejected, - @"App Attest attestation was rejected by backend. The existing " - @"attestation will be reset."); - // Reset the attestation. - return [self resetAttestation].thenOn(self.queue, ^NSError *(id result) { - // Throw the rejection error. - return [[GACAppAttestRejectionError alloc] initWithUnderlyingError:error]; - }); - } - - // Otherwise just re-throw the error. - return error; - }); -} - -/// Resets stored key ID and attestation artifact. -- (FBLPromise *)resetAttestation { - return [self.keyIDStorage setAppAttestKeyID:nil].thenOn(self.queue, ^id(id result) { - return [self.artifactStorage setArtifact:nil forKey:@""]; - }); -} - -#pragma mark - Token refresh sequence (assertion) - -- (FBLPromise *)refreshTokenWithKeyID:(NSString *)keyID - artifact:(NSData *)artifact - limitedUse:(BOOL)limitedUse { - return [self.APIService getRandomChallenge] - .thenOn(self.queue, - ^FBLPromise *(NSData *challenge) { - return [self generateAssertionWithKeyID:keyID - artifact:artifact - challenge:challenge]; - }) - .thenOn(self.queue, ^id(GACAppAttestAssertionData *assertion) { - return [self.APIService getAppCheckTokenWithArtifact:assertion.artifact - challenge:assertion.challenge - assertion:assertion.assertion - limitedUse:limitedUse]; - }); -} - -- (FBLPromise *)generateAssertionWithKeyID:(NSString *)keyID - artifact:(NSData *)artifact - challenge:(NSData *)challenge { - // 1. Calculate the statement and its hash for assertion. - return [FBLPromise - onQueue:self.queue - do:^NSData *_Nullable { - // 1.1. Compose statement to generate assertion for. - NSMutableData *statementForAssertion = [artifact mutableCopy]; - [statementForAssertion appendData:challenge]; - - // 1.2. Get the statement SHA256 hash. - return [GACAppCheckCryptoUtils sha256HashFromData:[statementForAssertion copy]]; - }] - .thenOn(self.queue, - ^FBLPromise *(NSData *statementHash) { - // 2. Generate App Attest assertion. - return [FBLPromise onQueue:self.queue - wrapObjectOrErrorCompletion:^( - FBLPromiseObjectOrErrorCompletion _Nonnull handler) { - [self.appAttestService generateAssertion:keyID - clientDataHash:statementHash - completionHandler:handler]; - }] - .recoverOn(self.queue, ^id(NSError *appAttestError) { - NSError *error = [_GACAppCheckErrorUtil - appAttestGenerateAssertionFailedWithError:appAttestError - keyId:keyID - clientDataHash:statementHash]; - - // If Apple rejected the key (DCErrorInvalidKey, - // DCErrorInvalidInput or DCErrorUnknownSystemFailure) then reset the - // attestation and throw a specific error to signal retry - // (GACAppAttestRejectionError). - NSError *underlyingError = error.userInfo[NSUnderlyingErrorKey]; - if (underlyingError && - [underlyingError.domain isEqualToString:DCErrorDomain] && - (underlyingError.code == DCErrorInvalidKey || - underlyingError.code == DCErrorInvalidInput || - underlyingError.code == DCErrorUnknownSystemFailure)) { - NSString *logMessage = - [NSString stringWithFormat:@"App Attest invalid key/input/system " - @"failure; the existing attestation " - @"will be reset. DC Error Code: %@.", - @(underlyingError.code)]; - GACAppCheckLog(GACLoggerAppCheckMessageCodeAssertionRejected, - GACAppCheckLogLevelDebug, logMessage); - // Reset the attestation. - return [self resetAttestation].thenOn(self.queue, ^NSError *(id result) { - // Throw the rejection error. - return [[GACAppAttestRejectionError alloc] initWithUnderlyingError:error]; - }); - } - - // Otherwise just re-throw the error. - return error; - }); - }) - // 3. Compose the result object. - .thenOn(self.queue, ^GACAppAttestAssertionData *(NSData *assertion) { - return [[GACAppAttestAssertionData alloc] initWithChallenge:challenge - artifact:artifact - assertion:assertion]; - }); -} - -#pragma mark - State handling - -- (FBLPromise *)attestationState { - dispatch_queue_t stateQueue = - dispatch_queue_create("GACAppAttestProvider.state", DISPATCH_QUEUE_SERIAL); - - return [FBLPromise - onQueue:stateQueue - do:^id _Nullable { - NSError *error; - - // 1. Check if App Attest is supported. - id isSupportedResult = FBLPromiseAwait([self isAppAttestSupported], &error); - if (isSupportedResult == nil) { - return [[GACAppAttestProviderState alloc] initUnsupportedWithError:error]; - } - - // 2. Check for stored key ID of the generated App Attest key pair. - NSString *appAttestKeyID = - FBLPromiseAwait([self.keyIDStorage getAppAttestKeyID], &error); - if (appAttestKeyID == nil) { - return [[GACAppAttestProviderState alloc] initWithSupportedInitialState]; - } - - // 3. Check for stored attestation artifact received from Firebase backend. - NSData *attestationArtifact = - FBLPromiseAwait([self.artifactStorage getArtifactForKey:appAttestKeyID], &error); - if (attestationArtifact == nil) { - return [[GACAppAttestProviderState alloc] initWithGeneratedKeyID:appAttestKeyID]; - } - - // 4. A valid App Attest key pair was generated and registered with Firebase - // backend. Return the corresponding state. - return [[GACAppAttestProviderState alloc] initWithRegisteredKeyID:appAttestKeyID - artifact:attestationArtifact]; - }]; -} - -#pragma mark - Helpers - -/// Returns a resolved promise if App Attest is supported and a rejected promise if it is not. -- (FBLPromise *)isAppAttestSupported { - if (self.appAttestService.isSupported) { - return [FBLPromise resolvedWith:[NSNull null]]; - } else { - NSError *error = [_GACAppCheckErrorUtil unsupportedAttestationProvider:@"AppAttestProvider"]; - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:error]; - return rejectedPromise; - } -} - -/// Generates a new App Attest key associated with the Firebase app if `storedKeyID == nil`. -- (FBLPromise *)generateAppAttestKeyIDIfNeeded:(nullable NSString *)storedKeyID { - if (storedKeyID) { - // The key ID has been fetched already, just return it. - return [FBLPromise resolvedWith:storedKeyID]; - } else { - // Generate and save a new key otherwise. - return [self generateAppAttestKey]; - } -} - -/// Generates and stores App Attest key associated with the Firebase app. -- (FBLPromise *)generateAppAttestKey { - return [FBLPromise onQueue:self.queue - wrapObjectOrErrorCompletion:^(FBLPromiseObjectOrErrorCompletion _Nonnull handler) { - [self.appAttestService generateKeyWithCompletionHandler:handler]; - }] - .recoverOn(self.queue, - ^id(NSError *error) { - return [_GACAppCheckErrorUtil appAttestGenerateKeyFailedWithError:error]; - }) - .thenOn(self.queue, ^FBLPromise *(NSString *keyID) { - return [self.keyIDStorage setAppAttestKeyID:keyID]; - }); -} - -+ (NSString *)storageKeySuffixWithServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName { - return [NSString stringWithFormat:@"%@.%@", serviceName, resourceName]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/AppAttestProvider/GACAppAttestProviderState.h b/AppCheckCore/Sources/AppAttestProvider/GACAppAttestProviderState.h deleted file mode 100644 index 4d255a34..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/GACAppAttestProviderState.h +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright 2021 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 - -NS_ASSUME_NONNULL_BEGIN - -/// Represents different stages of App Attest attestation. -typedef NS_ENUM(NSInteger, GACAppAttestAttestationState) { - /// App Attest is not supported on the current device. - GACAppAttestAttestationStateUnsupported, - - /// App Attest is supported, the App Attest key pair has been generated. - GACAppAttestAttestationStateSupportedInitial, - - /// App Attest key pair has been generated but has not been attested and registered with Firebase - /// backend. - GACAppAttestAttestationStateKeyGenerated, - - /// App Attest key has been generated, attested with Apple backend and registered with Firebase - /// backend. An encrypted artifact required to refresh FAC token is stored on the device. - GACAppAttestAttestationStateKeyRegistered, -}; - -/// Represents attestation stages of App Attest. The class is designed to be used exclusively by -/// `GACAppAttestProvider`. -@interface GACAppAttestProviderState : NSObject - -/// App Attest attestation state. -@property(nonatomic, readonly) GACAppAttestAttestationState state; - -/// An error object when state is GACAppAttestAttestationStateUnsupported. -@property(nonatomic, nullable, readonly) NSError *appAttestUnsupportedError; - -/// An App Attest key ID when state is GACAppAttestAttestationStateKeyGenerated or -/// GACAppAttestAttestationStateKeyRegistered. -@property(nonatomic, nullable, readonly) NSString *appAttestKeyID; - -/// An attestation artifact received from Firebase backend when state is -/// GACAppAttestAttestationStateKeyRegistered. -@property(nonatomic, nullable, readonly) NSData *attestationArtifact; - -- (instancetype)init NS_UNAVAILABLE; - -/// Init with GACAppAttestAttestationStateUnsupported and an error describing issue. -- (instancetype)initUnsupportedWithError:(NSError *)error; - -/// Init with GACAppAttestAttestationStateSupportedInitial. -- (instancetype)initWithSupportedInitialState; - -/// Init with GACAppAttestAttestationStateKeyGenerated and the key ID. -- (instancetype)initWithGeneratedKeyID:(NSString *)keyID; - -/// Init with GACAppAttestAttestationStateKeyRegistered, the key ID and the attestation artifact -/// received from Firebase backend. -- (instancetype)initWithRegisteredKeyID:(NSString *)keyID artifact:(NSData *)artifact; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/AppAttestProvider/GACAppAttestProviderState.m b/AppCheckCore/Sources/AppAttestProvider/GACAppAttestProviderState.m deleted file mode 100644 index 0681ba57..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/GACAppAttestProviderState.m +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/AppAttestProvider/GACAppAttestProviderState.h" - -@implementation GACAppAttestProviderState - -- (instancetype)initUnsupportedWithError:(NSError *)error { - self = [super init]; - if (self) { - _state = GACAppAttestAttestationStateUnsupported; - _appAttestUnsupportedError = error; - } - return self; -} - -- (instancetype)initWithSupportedInitialState { - self = [super init]; - if (self) { - _state = GACAppAttestAttestationStateSupportedInitial; - } - return self; -} - -- (instancetype)initWithGeneratedKeyID:(NSString *)keyID { - self = [super init]; - if (self) { - _state = GACAppAttestAttestationStateKeyGenerated; - _appAttestKeyID = keyID; - } - return self; -} - -- (instancetype)initWithRegisteredKeyID:(NSString *)keyID artifact:(NSData *)artifact { - self = [super init]; - if (self) { - _state = GACAppAttestAttestationStateKeyRegistered; - _appAttestKeyID = keyID; - _attestationArtifact = artifact; - } - return self; -} - -@end diff --git a/AppCheckCore/Sources/AppAttestProvider/GACAppAttestService.h b/AppCheckCore/Sources/AppAttestProvider/GACAppAttestService.h deleted file mode 100644 index a2d3d2c0..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/GACAppAttestService.h +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright 2021 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 - -@class FBLPromise; - -NS_ASSUME_NONNULL_BEGIN - -/// See `DCAppAttestService` -/// https://developer.apple.com/documentation/devicecheck/dcappattestservice?language=objc -@protocol GACAppAttestService - -@property(getter=isSupported, readonly) BOOL supported; - -- (void)generateKeyWithCompletionHandler:(void (^)(NSString *keyId, - NSError *error))completionHandler; - -- (void)attestKey:(NSString *)keyId - clientDataHash:(NSData *)clientDataHash - completionHandler:(void (^)(NSData *attestationObject, NSError *error))completionHandler; - -- (void)generateAssertion:(NSString *)keyId - clientDataHash:(NSData *)clientDataHash - completionHandler:(void (^)(NSData *assertionObject, NSError *error))completionHandler; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/AppAttestProvider/Storage/AppCheckCoreAppAttestArtifactStorage.swift b/AppCheckCore/Sources/AppAttestProvider/Storage/AppCheckCoreAppAttestArtifactStorage.swift new file mode 100644 index 00000000..030a887a --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/Storage/AppCheckCoreAppAttestArtifactStorage.swift @@ -0,0 +1,135 @@ +// 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 Foundation +#if COCOAPODS + import GoogleUtilities +#else + import GoogleUtilities_Environment +#endif + +@objc(GACAppAttestArtifactStorageProtocol) +public protocol AppCheckCoreAppAttestArtifactStorageProtocol: NSObjectProtocol { + @objc func setArtifact(_ artifact: Data?, forKey keyID: String) async throws -> Data? + @objc func getArtifact(forKey keyID: String) async throws -> Data? +} + +private let kKeychainService = "com.firebase.app_check.app_attest_artifact_storage" + +@objc(GACAppAttestArtifactStorage) +public class AppCheckCoreAppAttestArtifactStorage: NSObject, + AppCheckCoreAppAttestArtifactStorageProtocol { + private let keySuffix: String + private let keychainStorage: GULKeychainStorage + private let accessGroup: String? + + @objc(initWithKeySuffix:keychainStorage:accessGroup:) + public init(keySuffix: String, keychainStorage: GULKeychainStorage, accessGroup: String?) { + self.keySuffix = keySuffix + self.keychainStorage = keychainStorage + self.accessGroup = accessGroup + super.init() + } + + @objc(initWithKeySuffix:accessGroup:) + public convenience init(keySuffix: String, accessGroup: String?) { + let keychainStorage = GULKeychainStorage(service: kKeychainService) + self.init(keySuffix: keySuffix, keychainStorage: keychainStorage, accessGroup: accessGroup) + } + + @objc + public func getArtifact(forKey keyID: String) async throws -> Data? { + do { + let storedArtifact = + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + NSSecureCoding?, + Error + >) in + keychainStorage.getObjectForKey( + artifactKey, + objectClass: AppCheckCoreAppAttestStoredArtifact.self, + accessGroup: accessGroup + ) { result, error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: result) + } + } + } + + if let artifact = storedArtifact as? AppCheckCoreAppAttestStoredArtifact, + artifact.keyID == keyID { + return artifact.artifact + } else { + return nil + } + } catch { + throw AppCheckCoreErrorUtil.keychainError(with: error) + } + } + + @objc + public func setArtifact(_ artifact: Data?, forKey keyID: String) async throws -> Data? { + if let artifact = artifact { + return try await storeArtifact(artifact, forKey: keyID) + } else { + do { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + Void, + Error + >) in + keychainStorage.removeObject(forKey: artifactKey, accessGroup: accessGroup) { error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + return nil + } catch { + throw AppCheckCoreErrorUtil.keychainError(with: error) + } + } + } + + private func storeArtifact(_ artifact: Data, forKey keyID: String) async throws -> Data { + let storedArtifact = AppCheckCoreAppAttestStoredArtifact(keyID: keyID, artifact: artifact) + + do { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + Void, + Error + >) in + keychainStorage + .setObject(storedArtifact, forKey: artifactKey, + accessGroup: accessGroup) { result, error in + if let error = error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + } + } + return artifact + } catch { + throw AppCheckCoreErrorUtil.keychainError(with: error) + } + } + + private var artifactKey: String { + return "app_check_app_attest_artifact.\(keySuffix)" + } +} diff --git a/AppCheckCore/Sources/AppAttestProvider/Storage/AppCheckCoreAppAttestKeyIDStorage.swift b/AppCheckCore/Sources/AppAttestProvider/Storage/AppCheckCoreAppAttestKeyIDStorage.swift new file mode 100644 index 00000000..3045f3d7 --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/Storage/AppCheckCoreAppAttestKeyIDStorage.swift @@ -0,0 +1,65 @@ +// 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 Foundation +#if COCOAPODS + import GoogleUtilities +#else + import GoogleUtilities_UserDefaults +#endif + +@objc(GACAppAttestKeyIDStorageProtocol) +public protocol AppCheckCoreAppAttestKeyIDStorageProtocol: NSObjectProtocol { + @objc func setAppAttestKeyID(_ keyID: String?) async throws -> String? + @objc func getAppAttestKeyID() async throws -> String? +} + +private let kKeyIDStorageDefaultsSuiteName = "com.firebase.AppCheckCoreAppAttestKeyIDStorage" + +@objc(GACAppAttestKeyIDStorage) +public class AppCheckCoreAppAttestKeyIDStorage: NSObject, + AppCheckCoreAppAttestKeyIDStorageProtocol { + private let keySuffix: String + private let userDefaults: GULUserDefaults + + @objc(initWithKeySuffix:) + public init(keySuffix: String) { + self.keySuffix = keySuffix + userDefaults = GULUserDefaults(suiteName: kKeyIDStorageDefaultsSuiteName) + super.init() + } + + @objc + public func setAppAttestKeyID(_ keyID: String?) async throws -> String? { + if let keyID = keyID { + userDefaults.setObject(keyID, forKey: keyIDStorageKey) + } else { + userDefaults.removeObject(forKey: keyIDStorageKey) + } + return keyID + } + + @objc + public func getAppAttestKeyID() async throws -> String? { + if let appAttestKeyID = userDefaults.object(forKey: keyIDStorageKey) as? String { + return appAttestKeyID + } else { + throw AppCheckCoreErrorUtil.appAttestKeyIDNotFound() + } + } + + private var keyIDStorageKey: String { + return "app_attest_keyID.\(keySuffix)" + } +} diff --git a/AppCheckCore/Sources/AppAttestProvider/Storage/AppCheckCoreAppAttestStoredArtifact.swift b/AppCheckCore/Sources/AppAttestProvider/Storage/AppCheckCoreAppAttestStoredArtifact.swift new file mode 100644 index 00000000..d24a3808 --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/Storage/AppCheckCoreAppAttestStoredArtifact.swift @@ -0,0 +1,70 @@ +// 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 Foundation + +private let kKeyIDKey = "keyID" +private let kArtifactKey = "artifact" +private let kStorageVersionKey = "storageVersion" + +private let kStorageVersion = 1 + +@objc(GACAppAttestStoredArtifact) +public class AppCheckCoreAppAttestStoredArtifact: NSObject, NSSecureCoding { + @objc public let keyID: String + @objc public let artifact: Data + + @objc public var storageVersion: Int { + return kStorageVersion + } + + @objc(initWithKeyID:artifact:) + public init(keyID: String, artifact: Data) { + self.keyID = keyID + self.artifact = artifact + super.init() + } + + public static var supportsSecureCoding: Bool { + return true + } + + public func encode(with coder: NSCoder) { + coder.encode(keyID, forKey: kKeyIDKey) + coder.encode(artifact, forKey: kArtifactKey) + coder.encode(storageVersion, forKey: kStorageVersionKey) + } + + public required init?(coder: NSCoder) { + let storageVersion = coder.decodeInteger(forKey: kStorageVersionKey) + + if storageVersion < kStorageVersion { + // Handle migration here when new versions are added + } + + guard let decodedKeyID = coder.decodeObject(of: NSString.self, forKey: kKeyIDKey) as String?, + !decodedKeyID.isEmpty else { + return nil + } + + guard let decodedArtifact = coder.decodeObject(of: NSData.self, forKey: kArtifactKey) as Data?, + !decodedArtifact.isEmpty else { + return nil + } + + keyID = decodedKeyID + artifact = decodedArtifact + super.init() + } +} diff --git a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.h b/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.h deleted file mode 100644 index ee01e74e..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.h +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2021 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 - -@class FBLPromise; -@protocol GACKeychainStorageProtocol; - -NS_ASSUME_NONNULL_BEGIN - -/// Defines API of a storage capable to store an encrypted artifact required to refresh Firebase App -/// Check token obtained with App Attest provider. -@protocol GACAppAttestArtifactStorageProtocol - -/// Set the artifact. An artifact previously set for *any* key ID will be replaced by the new one -/// with the new key ID. The storage always stores a single artifact. -/// @param artifact The artifact data to store. Pass `nil` to remove the stored artifact. -/// @param keyID The App Attest key ID used to generate the artifact. -/// @return An artifact that is resolved with the artifact data passed into the method in case of -/// success or is rejected with an error. -- (FBLPromise *)setArtifact:(nullable NSData *)artifact forKey:(NSString *)keyID; - -/// Get the artifact. -/// @param keyID The App Attest key ID used to generate the artifact. -/// @return A promise that is resolved with the artifact data if artifact exists, is resolved with -/// `nil` if no artifact found (or the existing artifact was set for a different key ID) or is -/// rejected with an error. -- (FBLPromise *)getArtifactForKey:(NSString *)keyID; - -@end - -/// An implementation of GACAppAttestArtifactStorageProtocol. -@interface GACAppAttestArtifactStorage : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -/// Default convenience initializer. -/// @param keySuffix A unique suffix that will be used as a part of the key to store the token for -/// the storage instance. -/// @param accessGroup The Keychain Access Group. -- (instancetype)initWithKeySuffix:(NSString *)keySuffix - accessGroup:(nullable NSString *)accessGroup; - -/// Designated initializer. -/// @param keySuffix A unique suffix that will be used as a part of the key to store the token for -/// the storage instance. -/// @param keychainStorage An instance of `GACKeychainStorageProtocol` used as an underlying secure -/// storage. -/// @param accessGroup The Keychain Access Group. -- (instancetype)initWithKeySuffix:(NSString *)keySuffix - keychainStorage:(id)keychainStorage - accessGroup:(nullable NSString *)accessGroup NS_DESIGNATED_INITIALIZER; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.m b/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.m deleted file mode 100644 index d6f52bb0..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.m +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import - -#import "AppCheckCore/Sources/Core/Storage/GACKeychainStorageProtocol.h" - -#import "AppCheckCore/Sources/Core/Storage/GULKeychainStorage+GACKeychainStorageProtocol.h" - -#import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -NS_ASSUME_NONNULL_BEGIN - -static NSString *const kKeychainService = @"com.firebase.app_check.app_attest_artifact_storage"; - -@interface GACAppAttestArtifactStorage () - -@property(nonatomic, readonly) NSString *keySuffix; -@property(nonatomic, readonly) id keychainStorage; -@property(nonatomic, readonly, nullable) NSString *accessGroup; - -@end - -@implementation GACAppAttestArtifactStorage - -- (instancetype)initWithKeySuffix:(NSString *)keySuffix - keychainStorage:(id)keychainStorage - accessGroup:(nullable NSString *)accessGroup { - self = [super init]; - if (self) { - _keySuffix = [keySuffix copy]; - _keychainStorage = keychainStorage; - _accessGroup = [accessGroup copy]; - } - return self; -} - -- (instancetype)initWithKeySuffix:(NSString *)keySuffix - accessGroup:(nullable NSString *)accessGroup { - GULKeychainStorage *keychainStorage = - [[GULKeychainStorage alloc] initWithService:kKeychainService]; - return [self initWithKeySuffix:keySuffix keychainStorage:keychainStorage accessGroup:accessGroup]; -} - -- (FBLPromise *)getArtifactForKey:(NSString *)keyID { - return [FBLPromise - wrapObjectOrErrorCompletion:^(FBLPromiseObjectOrErrorCompletion _Nonnull handler) { - [self.keychainStorage getObjectForKey:[self artifactKey] - objectClass:[GACAppAttestStoredArtifact class] - accessGroup:self.accessGroup - completionHandler:handler]; - }] - .then(^NSData *(id storedArtifact) { - GACAppAttestStoredArtifact *artifact = (GACAppAttestStoredArtifact *)storedArtifact; - if ([artifact isKindOfClass:[GACAppAttestStoredArtifact class]] && - [artifact.keyID isEqualToString:keyID]) { - return artifact.artifact; - } else { - return nil; - } - }) - .recover(^NSError *(NSError *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]; - }); - } else { - return [FBLPromise wrapErrorCompletion:^(FBLPromiseErrorCompletion _Nonnull handler) { - [self.keychainStorage removeObjectForKey:[self artifactKey] - accessGroup:self.accessGroup - completionHandler:handler]; - }] - .then(^id _Nullable(id _Nullable __unused _) { - return nil; - }) - .recover(^NSError *(NSError *error) { - return [_GACAppCheckErrorUtil keychainErrorWithError:error]; - }); - } -} - -#pragma mark - Helpers - -- (FBLPromise *)storeArtifact:(nullable NSData *)artifact - forKey:(nonnull NSString *)keyID { - GACAppAttestStoredArtifact *storedArtifact = - [[GACAppAttestStoredArtifact alloc] initWithKeyID:keyID artifact:artifact]; - return - [FBLPromise wrapObjectOrErrorCompletion:^( - FBLPromiseObjectOrErrorCompletion _Nonnull handler) { - [self.keychainStorage setObject:storedArtifact - forKey:[self artifactKey] - accessGroup:self.accessGroup - completionHandler:handler]; - }].then(^id _Nullable(id _Nullable value) { - return artifact; - }); -} - -- (NSString *)artifactKey { - return [NSString stringWithFormat:@"app_check_app_attest_artifact.%@", self.keySuffix]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.h b/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.h deleted file mode 100644 index dcbc7a3b..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.h +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright 2021 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 - -@class FBLPromise; - -NS_ASSUME_NONNULL_BEGIN - -/// The protocol defines methods to store App Attest key IDs per Firebase app. -@protocol GACAppAttestKeyIDStorageProtocol - -/** Manages storage of an app attest key ID. - * @param keyID The app attest key ID to store or `nil` to remove the existing app attest key ID. - * @return A promise that is resolved with a stored app attest key ID or `nil` if the existing app - * attest key ID has been removed. - */ -- (FBLPromise *)setAppAttestKeyID:(nullable NSString *)keyID; - -/** Reads a stored app attest key ID. - * @return A promise that is resolved with a stored app attest key ID or `nil` if there is not a - * stored app attest key ID. The promise is rejected with an error in the case of a missing app - * attest key ID . - */ -- (FBLPromise *)getAppAttestKeyID; - -@end - -/// The App Attest key ID storage implementation. -/// This class is designed for use by `GACAppAttestProvider`. It's operations are managed by -/// `GACAppAttestProvider`'s internal serial queue. It is not considered thread safe and should not -/// be used by other classes at this time. -@interface GACAppAttestKeyIDStorage : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -/** Default convenience initializer. - * @param keySuffix A unique suffix that will be used as a part of the key to store the token for - * the storage instance. - */ -- (instancetype)initWithKeySuffix:(NSString *)keySuffix; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.m b/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.m deleted file mode 100644 index a87f6e07..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.m +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import - -#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"; - -@interface GACAppAttestKeyIDStorage () - -@property(nonatomic, readonly) NSString *keySuffix; - -/// The app attest key ID is stored using `GULUserDefaults` . -@property(nonatomic, readonly) GULUserDefaults *userDefaults; - -@end - -@implementation GACAppAttestKeyIDStorage - -- (instancetype)initWithKeySuffix:(NSString *)keySuffix { - self = [super init]; - if (self) { - _keySuffix = [keySuffix copy]; - _userDefaults = [[GULUserDefaults alloc] initWithSuiteName:kKeyIDStorageDefaultsSuiteName]; - } - return self; -} - -- (nonnull FBLPromise *)setAppAttestKeyID:(nullable NSString *)keyID { - [self storeAppAttestKeyID:keyID]; - return [FBLPromise resolvedWith:keyID]; -} - -- (nonnull FBLPromise *)getAppAttestKeyID { - NSString *appAttestKeyID = [self appAttestKeyIDFromStorage]; - if (appAttestKeyID) { - return [FBLPromise resolvedWith:appAttestKeyID]; - } else { - NSError *error = [_GACAppCheckErrorUtil appAttestKeyIDNotFound]; - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:error]; - return rejectedPromise; - } -} - -#pragma mark - Helpers - -- (void)storeAppAttestKeyID:(nullable NSString *)keyID { - if (keyID) { - [self.userDefaults setObject:keyID forKey:[self keyIDStorageKey]]; - } else { - [self.userDefaults removeObjectForKey:[self keyIDStorageKey]]; - } -} - -- (nullable NSString *)appAttestKeyIDFromStorage { - NSString *appAttestKeyID = nil; - appAttestKeyID = [self.userDefaults objectForKey:[self keyIDStorageKey]]; - return appAttestKeyID; -} - -- (NSString *)keyIDStorageKey { - return [NSString stringWithFormat:@"app_attest_keyID.%@", self.keySuffix]; -} - -@end diff --git a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.h b/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.h deleted file mode 100644 index d7895b5e..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.h +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2021 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 - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppAttestStoredArtifact : NSObject - -/// The App Attest key ID used to generate the artifact. -@property(nonatomic, readonly) NSString *keyID; - -/// The Firebase App Attest artifact generated by the backend. -@property(nonatomic, readonly) NSData *artifact; - -/// The object version. -/// WARNING: The version must be incremented if properties are added, removed or modified. Migration -/// must be handled accordingly in `initWithCoder:` method. -@property(nonatomic, readonly) NSInteger storageVersion; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithKeyID:(NSString *)keyID - artifact:(NSData *)artifact NS_DESIGNATED_INITIALIZER; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.m b/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.m deleted file mode 100644 index 88646016..00000000 --- a/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.m +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.h" - -static NSString *const kKeyIDKey = @"keyID"; -static NSString *const kArtifactKey = @"artifact"; -static NSString *const kStorageVersionKey = @"storageVersion"; - -static NSInteger const kStorageVersion = 1; - -@implementation GACAppAttestStoredArtifact - -- (instancetype)initWithKeyID:(NSString *)keyID artifact:(NSData *)artifact { - self = [super init]; - if (self) { - _keyID = keyID; - _artifact = artifact; - } - return self; -} - -- (NSInteger)storageVersion { - return kStorageVersion; -} - -#pragma mark - NSSecureCoding - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (void)encodeWithCoder:(nonnull NSCoder *)coder { - [coder encodeObject:self.keyID forKey:kKeyIDKey]; - [coder encodeObject:self.artifact forKey:kArtifactKey]; - [coder encodeInteger:self.storageVersion forKey:kStorageVersionKey]; -} - -- (nullable instancetype)initWithCoder:(nonnull NSCoder *)coder { - NSInteger storageVersion = [coder decodeIntegerForKey:kStorageVersionKey]; - - if (storageVersion < kStorageVersion) { - // Handle migration here when new versions are added - } - - // If the version of the stored object is equal or higher than the current version then try the - // best to get enough data to initialize the object. - NSString *keyID = [coder decodeObjectOfClass:[NSString class] forKey:kKeyIDKey]; - if (keyID.length < 1) { - return nil; - } - - NSData *artifact = [coder decodeObjectOfClass:[NSData class] forKey:kArtifactKey]; - if (artifact.length < 1) { - return nil; - } - - return [self initWithKeyID:keyID artifact:artifact]; -} - -@end diff --git a/AppCheckCore/Sources/Core/APIService/AppCheckCoreAPIService.swift b/AppCheckCore/Sources/Core/APIService/AppCheckCoreAPIService.swift new file mode 100644 index 00000000..a0c40aa0 --- /dev/null +++ b/AppCheckCore/Sources/Core/APIService/AppCheckCoreAPIService.swift @@ -0,0 +1,176 @@ +// 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 Foundation + +// Assuming AppCheckCoreAPIRequestHook is a typealias. +// It was defined somewhere else as a block taking an NSMutableURLRequest. +// We'll define it locally if it's missing, but it should be available. +// typealias AppCheckCoreAPIRequestHook = (NSMutableURLRequest) -> Void + +private let kAPIKeyHeaderKey = "X-Goog-Api-Key" +private let kBundleIdKey = "X-Ios-Bundle-Identifier" +private let kProdBaseURL = "https://firebaseappcheck.googleapis.com/v1" + +#if !NDEBUG + private let kStagingBaseURL = "https://staging-firebaseappcheck.sandbox.googleapis.com/v1" + private let kAppCheckUseStagingEnvKey = "_AppCheckUseStaging" +#endif + +@objc(GACAppCheckAPIServiceProtocol) +public protocol AppCheckCoreAPIServiceProtocol: NSObjectProtocol { + @objc var baseURL: String { get } + + @objc(sendRequestWithURL:HTTPMethod:body:additionalHeaders:completion:) + func sendRequest(withURL requestURL: URL, + httpMethod: String, + body: Data?, + additionalHeaders: [String: String]?) async throws + -> AppCheckCoreURLSessionDataResponse + + @objc(appCheckTokenWithAPIResponse:completion:) + func appCheckToken(withAPIResponse response: AppCheckCoreURLSessionDataResponse) async throws + -> AppCheckCoreToken +} + +@objc(GACAppCheckAPIService) +public class AppCheckCoreAPIService: NSObject, AppCheckCoreAPIServiceProtocol { + public let baseURL: String + private let urlSession: URLSession + private let apiKey: String? + // Using Any for hook as it's typically `@convention(block) (NSMutableURLRequest) -> Void` + private let requestHooks: [AppCheckCoreAPIRequestHook] + + @objc(initWithURLSession:baseURL:APIKey:requestHooks:) + public convenience init(urlSession: URLSession, + baseURL: String?, + apiKey: String?, + requestHooks: [Any]?) { + self.init( + urlSession: urlSession, + baseURL: baseURL, + apiKey: apiKey, + requestHooks: requestHooks, + environment: ProcessInfo.processInfo.environment + ) + } + + // Internal designated initializer + init(urlSession: URLSession, + baseURL: String?, + apiKey: String?, + requestHooks: [Any]?, + environment: [String: String]) { + self.urlSession = urlSession + self.apiKey = apiKey + self.requestHooks = requestHooks?.compactMap { $0 as? AppCheckCoreAPIRequestHook } ?? [] + + var resolvedBaseURL = baseURL + + #if !NDEBUG + if resolvedBaseURL == nil { + let useStaging = (environment[kAppCheckUseStagingEnvKey] as NSString?)?.boolValue ?? false + if useStaging { + resolvedBaseURL = kStagingBaseURL + let logMessage = + "App Check staging environment enabled. API calls will be routed to \(kStagingBaseURL)." + // Assuming AppCheckCoreLogger is available + AppCheckCoreLogger.log(code: .stagingModeEnabled, logLevel: .info, message: logMessage) + } + } + #endif + + self.baseURL = resolvedBaseURL ?? kProdBaseURL + super.init() + } + + @objc(sendRequestWithURL:HTTPMethod:body:additionalHeaders:completion:) + public func sendRequest(withURL requestURL: URL, + httpMethod: String, + body: Data?, + additionalHeaders: [String: String]?) async throws + -> AppCheckCoreURLSessionDataResponse { + let request = try self.request( + withURL: requestURL, + httpMethod: httpMethod, + body: body, + additionalHeaders: additionalHeaders + ) + let response = try await sendURLRequest(request) + return try validateHTTPResponseStatusCode(response) + } + + private func request(withURL requestURL: URL, + httpMethod: String, + body: Data?, + additionalHeaders: [String: String]?) throws -> URLRequest { + let mutableRequest = NSMutableURLRequest(url: requestURL) + + mutableRequest.httpMethod = httpMethod + mutableRequest.httpBody = body + mutableRequest.cachePolicy = .reloadIgnoringLocalCacheData + + if let apiKey = apiKey { + mutableRequest.setValue(apiKey, forHTTPHeaderField: kAPIKeyHeaderKey) + } + + if let bundleID = Bundle.main.bundleIdentifier { + mutableRequest.setValue(bundleID, forHTTPHeaderField: kBundleIdKey) + } + + additionalHeaders?.forEach { key, value in + mutableRequest.setValue(value, forHTTPHeaderField: key) + } + + for hook in requestHooks { + hook(mutableRequest) + } + + return mutableRequest as URLRequest + } + + private func sendURLRequest(_ request: URLRequest) async throws + -> AppCheckCoreURLSessionDataResponse { + do { + let (data, response) = try await urlSession.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw AppCheckCoreErrorUtil.apiError(withNetworkError: URLError(.badServerResponse)) + } + return AppCheckCoreURLSessionDataResponse(response: httpResponse, httpBody: data) + } catch { + throw AppCheckCoreErrorUtil.apiError(withNetworkError: error) + } + } + + private func validateHTTPResponseStatusCode(_ response: AppCheckCoreURLSessionDataResponse) throws + -> AppCheckCoreURLSessionDataResponse { + let statusCode = response.httpResponse.statusCode + if statusCode < 200 || statusCode >= 300 { + let bodyString = String(data: response.httpBody ?? Data(), encoding: .utf8) ?? "" + let logMessage = "Unexpected API response: \(response.httpResponse), body: \(bodyString)." + AppCheckCoreLogger.log(code: .unexpectedHTTPCode, logLevel: .debug, message: logMessage) + throw AppCheckCoreErrorUtil.apiError(with: response.httpResponse, data: response.httpBody) + } + return response + } + + @objc(appCheckTokenWithAPIResponse:completion:) + public func appCheckToken(withAPIResponse response: AppCheckCoreURLSessionDataResponse) async throws + -> AppCheckCoreToken { + return try AppCheckCoreToken( + tokenExchangeResponse: response.httpBody ?? Data(), + requestDate: Date() + ) + } +} diff --git a/AppCheckCore/Sources/Core/APIService/AppCheckCoreToken+APIResponse.swift b/AppCheckCore/Sources/Core/APIService/AppCheckCoreToken+APIResponse.swift new file mode 100644 index 00000000..97d87c0f --- /dev/null +++ b/AppCheckCore/Sources/Core/APIService/AppCheckCoreToken+APIResponse.swift @@ -0,0 +1,61 @@ +// 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 Foundation + +private let kResponseFieldToken = "token" +private let kResponseFieldTTL = "ttl" + +public extension AppCheckCoreToken { + @objc(initWithTokenExchangeResponse:requestDate:error:) + convenience init(tokenExchangeResponse response: Data, requestDate: Date) throws { + guard !response.isEmpty else { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Empty server response body.") + } + + let responseDict: [String: Any] + do { + guard let dict = try JSONSerialization + .jsonObject(with: response, options: []) as? [String: Any] else { + throw AppCheckCoreErrorUtil.jsonSerializationError(nil) + } + responseDict = dict + } catch { + throw AppCheckCoreErrorUtil.jsonSerializationError(error) + } + + try self.init(responseDict: responseDict, requestDate: requestDate) + } + + @objc(initWithResponseDict:requestDate:error:) + convenience init(responseDict: [String: Any], requestDate: Date) throws { + guard let token = responseDict[kResponseFieldToken] as? String else { + throw AppCheckCoreErrorUtil.appCheckTokenResponseError(withMissingField: kResponseFieldToken) + } + + guard let timeToLiveString = responseDict[kResponseFieldTTL] as? String, + !timeToLiveString.isEmpty else { + throw AppCheckCoreErrorUtil.appCheckTokenResponseError(withMissingField: kResponseFieldTTL) + } + + let timeToLiveValueString = timeToLiveString.replacingOccurrences(of: "s", with: "") + guard let secondsToLive = TimeInterval(timeToLiveValueString), secondsToLive > 0 else { + throw AppCheckCoreErrorUtil.appCheckTokenResponseError(withMissingField: kResponseFieldTTL) + } + + let expirationDate = requestDate.addingTimeInterval(secondsToLive) + + self.init(token: token, expirationDate: expirationDate, receivedAt: requestDate) + } +} diff --git a/AppCheckCore/Sources/Core/APIService/AppCheckCoreURLSessionDataResponse.swift b/AppCheckCore/Sources/Core/APIService/AppCheckCoreURLSessionDataResponse.swift new file mode 100644 index 00000000..2087e84d --- /dev/null +++ b/AppCheckCore/Sources/Core/APIService/AppCheckCoreURLSessionDataResponse.swift @@ -0,0 +1,29 @@ +// 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 Foundation + +/// The class represents HTTP response received from `URLSession`. +@objc(GACURLSessionDataResponse) +public class AppCheckCoreURLSessionDataResponse: NSObject { + @objc public let httpResponse: HTTPURLResponse + @objc public let httpBody: Data? + + @objc(initWithResponse:HTTPBody:) + public init(response: HTTPURLResponse, httpBody: Data?) { + httpResponse = response + self.httpBody = httpBody + super.init() + } +} diff --git a/AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.m b/AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.m deleted file mode 100644 index 0eab6c91..00000000 --- a/AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.m +++ /dev/null @@ -1,211 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h" -#import "AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.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" - -#import "AppCheckCore/Sources/Core/_GACAppCheckAPIService+Internal.h" - -NS_ASSUME_NONNULL_BEGIN - -static NSString *const kAPIKeyHeaderKey = @"X-Goog-Api-Key"; -static NSString *const kBundleIdKey = @"X-Ios-Bundle-Identifier"; - -static NSString *const kProdBaseURL = @"https://firebaseappcheck.googleapis.com/v1"; - -#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; -@property(nonatomic, readonly) NSArray *requestHooks; - -- (instancetype)initWithURLSession:(NSURLSession *)session - baseURL:(nullable NSString *)baseURL - APIKey:(nullable NSString *)APIKey - requestHooks:(nullable NSArray *)requestHooks - environment:(NSDictionary *)environment - NS_DESIGNATED_INITIALIZER; - -@end - -@implementation _GACAppCheckAPIService - -// Synthesize properties declared in a protocol. -@synthesize baseURL = _baseURL; - -- (instancetype)initWithURLSession:(NSURLSession *)session - baseURL:(nullable NSString *)baseURL - APIKey:(nullable NSString *)APIKey - requestHooks:(nullable NSArray *)requestHooks - environment:(NSDictionary *)environment { - self = [super init]; - if (self) { - _URLSession = session; - _APIKey = APIKey; - _requestHooks = requestHooks ? [requestHooks copy] : @[]; - - NSString *resolvedBaseURL = baseURL; - -#if !NDEBUG - if (resolvedBaseURL == nil) { - BOOL useStaging = [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; -} - -- (instancetype)initWithURLSession:(NSURLSession *)session - baseURL:(nullable NSString *)baseURL - APIKey:(nullable NSString *)APIKey - requestHooks:(nullable NSArray *)requestHooks { - return [self initWithURLSession:session - baseURL:baseURL - APIKey:APIKey - requestHooks:requestHooks - environment:[[NSProcessInfo processInfo] environment]]; -} - -- (FBLPromise<_GACURLSessionDataResponse *> *) - sendRequestWithURL:(NSURL *)requestURL - HTTPMethod:(NSString *)HTTPMethod - body:(nullable NSData *)body - additionalHeaders:(nullable NSDictionary *)additionalHeaders { - return [self requestWithURL:requestURL - HTTPMethod:HTTPMethod - body:body - additionalHeaders:additionalHeaders] - .then(^id _Nullable(NSURLRequest *_Nullable request) { - return [self sendURLRequest:request]; - }) - .then(^id _Nullable(_GACURLSessionDataResponse *_Nullable response) { - return [self validateHTTPResponseStatusCode:response]; - }); -} - -- (FBLPromise *)requestWithURL:(NSURL *)requestURL - HTTPMethod:(NSString *)HTTPMethod - body:(NSData *)body - additionalHeaders:(nullable NSDictionary *) - additionalHeaders { - return [FBLPromise - onQueue:[self defaultQueue] - do:^id _Nullable { - __block NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:requestURL]; - request.HTTPMethod = HTTPMethod; - request.HTTPBody = body; - // App Check should ignore cached data to prevent reusing - // previously received artifacts (e.g. App Attest challenge). - request.cachePolicy = NSURLRequestReloadIgnoringLocalCacheData; - - if (self.APIKey) { - [request setValue:self.APIKey forHTTPHeaderField:kAPIKeyHeaderKey]; - } - - [request setValue:[[NSBundle mainBundle] bundleIdentifier] - forHTTPHeaderField:kBundleIdKey]; - - [additionalHeaders - enumerateKeysAndObjectsUsingBlock:^(NSString *_Nonnull key, NSString *_Nonnull obj, - BOOL *_Nonnull stop) { - [request setValue:obj forHTTPHeaderField:key]; - }]; - - for (GACAppCheckAPIRequestHook requestHook in self.requestHooks) { - requestHook(request); - } - - return [request copy]; - }]; -} - -- (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]; - }) - .then(^id _Nullable(_GACURLSessionDataResponse *response) { - return [self validateHTTPResponseStatusCode:response]; - }); -} - -- (FBLPromise<_GACURLSessionDataResponse *> *)validateHTTPResponseStatusCode: - (_GACURLSessionDataResponse *)response { - NSInteger statusCode = response.HTTPResponse.statusCode; - return [FBLPromise do:^id _Nullable { - if (statusCode < 200 || statusCode >= 300) { - NSString *logMessage = [NSString - stringWithFormat:@"Unexpected API response: %@, body: %@.", response.HTTPResponse, - [[NSString alloc] initWithData:response.HTTPBody - encoding:NSUTF8StringEncoding]]; - GACAppCheckLogDebug(GACLoggerAppCheckMessageCodeUnexpectedHTTPCode, logMessage); - return [_GACAppCheckErrorUtil APIErrorWithHTTPResponse:response.HTTPResponse - data:response.HTTPBody]; - } - return response; - }]; -} - -- (FBLPromise *)appCheckTokenWithAPIResponse: - (_GACURLSessionDataResponse *)response { - return [FBLPromise onQueue:[self defaultQueue] - do:^id _Nullable { - NSError *error; - - GACAppCheckToken *token = [[GACAppCheckToken alloc] - initWithTokenExchangeResponse:response.HTTPBody - requestDate:[NSDate date] - error:&error]; - return token ?: error; - }]; -} - -- (dispatch_queue_t)defaultQueue { - return dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h b/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h deleted file mode 100644 index 3298f05d..00000000 --- a/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" - -@class FBLPromise; -@class _GACURLSessionDataResponse; - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckToken (APIResponse) - -- (nullable instancetype)initWithTokenExchangeResponse:(NSData *)response - requestDate:(NSDate *)requestDate - error:(NSError **)outError; - -- (nullable instancetype)initWithResponseDict:(NSDictionary *)responseDict - requestDate:(NSDate *)requestDate - error:(NSError **)outError; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.m b/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.m deleted file mode 100644 index 1ceba87b..00000000 --- a/AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.m +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -static NSString *const kResponseFieldToken = @"token"; -static NSString *const kResponseFieldTTL = @"ttl"; - -@implementation GACAppCheckToken (APIResponse) - -- (nullable instancetype)initWithTokenExchangeResponse:(NSData *)response - requestDate:(NSDate *)requestDate - error:(NSError **)outError { - if (response.length <= 0) { - GACAppCheckSetErrorToPointer( - [_GACAppCheckErrorUtil errorWithFailureReason:@"Empty server response body."], outError); - return nil; - } - - NSError *JSONError; - NSDictionary *responseDict = [NSJSONSerialization JSONObjectWithData:response - options:0 - error:&JSONError]; - - if (![responseDict isKindOfClass:[NSDictionary class]]) { - GACAppCheckSetErrorToPointer([_GACAppCheckErrorUtil JSONSerializationError:JSONError], - outError); - return nil; - } - - return [self initWithResponseDict:responseDict requestDate:requestDate error:outError]; -} - -- (nullable instancetype)initWithResponseDict:(NSDictionary *)responseDict - requestDate:(NSDate *)requestDate - error:(NSError **)outError { - NSString *token = responseDict[kResponseFieldToken]; - if (![token isKindOfClass:[NSString class]]) { - GACAppCheckSetErrorToPointer( - [_GACAppCheckErrorUtil appCheckTokenResponseErrorWithMissingField:kResponseFieldToken], - outError); - return nil; - } - - NSString *timeToLiveString = responseDict[kResponseFieldTTL]; - if (![token isKindOfClass:[NSString class]] || token.length <= 0) { - GACAppCheckSetErrorToPointer( - [_GACAppCheckErrorUtil appCheckTokenResponseErrorWithMissingField:kResponseFieldTTL], - outError); - return nil; - } - - // Expect a string like "3600s" representing a time interval in seconds. - NSString *timeToLiveValueString = [timeToLiveString stringByReplacingOccurrencesOfString:@"s" - withString:@""]; - NSTimeInterval secondsToLive = timeToLiveValueString.doubleValue; - - if (secondsToLive == 0) { - GACAppCheckSetErrorToPointer( - [_GACAppCheckErrorUtil appCheckTokenResponseErrorWithMissingField:kResponseFieldTTL], - outError); - return nil; - } - - NSDate *expirationDate = [requestDate dateByAddingTimeInterval:secondsToLive]; - - return [self initWithToken:token expirationDate:expirationDate receivedAtDate:requestDate]; -} - -@end diff --git a/AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.m b/AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.m deleted file mode 100644 index 1842d1a5..00000000 --- a/AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.m +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h" - -@implementation _GACURLSessionDataResponse - -- (instancetype)initWithResponse:(NSHTTPURLResponse *)response HTTPBody:(NSData *)body { - self = [super init]; - if (self) { - _HTTPResponse = response; - _HTTPBody = body; - } - return self; -} - -@end diff --git a/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.h b/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.h deleted file mode 100644 index fb3a08f4..00000000 --- a/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.h +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2024 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 - -@class FBLPromise; -@class _GACURLSessionDataResponse; - -NS_ASSUME_NONNULL_BEGIN - -/** Promise based API for `NSURLSession`. */ -@interface NSURLSession (GACPromises) - -/** Creates a promise wrapping `-[NSURLSession dataTaskWithRequest:completionHandler:]` method. - * @param URLRequest The request to create a data task with. - * @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<_GACURLSessionDataResponse *> *)gac_dataTaskPromiseWithRequest: - (NSURLRequest *)URLRequest; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.m b/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.m deleted file mode 100644 index f9a79b03..00000000 --- a/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.m +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2024 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h" - -@implementation NSURLSession (GACPromises) - -- (FBLPromise<_GACURLSessionDataResponse *> *)gac_dataTaskPromiseWithRequest: - (NSURLRequest *)URLRequest { - return [FBLPromise async:^(FBLPromiseFulfillBlock fulfill, FBLPromiseRejectBlock reject) { - [[self dataTaskWithRequest:URLRequest - completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response, - NSError *_Nullable error) { - if (error) { - reject(error); - } else { - fulfill([[_GACURLSessionDataResponse alloc] - initWithResponse:(NSHTTPURLResponse *)response - HTTPBody:data]); - } - }] resume]; - }]; -} - -@end diff --git a/AppCheckCore/Sources/Core/AppCheckCore.swift b/AppCheckCore/Sources/Core/AppCheckCore.swift new file mode 100644 index 00000000..a7838d66 --- /dev/null +++ b/AppCheckCore/Sources/Core/AppCheckCore.swift @@ -0,0 +1,205 @@ +// 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 Foundation + +public typealias AppCheckCoreTokenHandler = (AppCheckCoreTokenResult) -> Void + +@objc(GACAppCheck) +@objcMembers +public class AppCheckCore: NSObject { + public let serviceName: String + public let appCheckProvider: AppCheckCoreProvider + public let settings: AppCheckCoreSettingsProtocol + public weak var tokenDelegate: AppCheckCoreTokenDelegate? + public let storage: AppCheckCoreStorageProtocol + public let tokenRefresher: AppCheckCoreTokenRefresherProtocol + + public init(serviceName: String, + resourceName: String, + appCheckProvider: AppCheckCoreProvider, + settings: AppCheckCoreSettingsProtocol, + tokenDelegate: AppCheckCoreTokenDelegate?, + keychainAccessGroup: String?) { + self.serviceName = serviceName + self.appCheckProvider = appCheckProvider + self.settings = settings + self.tokenDelegate = tokenDelegate + let tokenKey = "app_check_token.\(serviceName).\(resourceName)" + storage = AppCheckCoreStorage(tokenKey: tokenKey, accessGroup: keychainAccessGroup) + let refreshResult = AppCheckCoreTokenRefreshResult( + status: .never, + expirationDate: nil, + receivedAtDate: nil + ) + tokenRefresher = AppCheckCoreTokenRefresher(refreshResult: refreshResult, settings: settings) + super.init() + + tokenRefresher.tokenRefreshHandler = { [weak self] completion in + self?.periodicTokenRefresh(completion: completion) + } + } + + init(serviceName: String, + appCheckProvider: AppCheckCoreProvider, + storage: AppCheckCoreStorageProtocol, + tokenRefresher: AppCheckCoreTokenRefresherProtocol, + settings: AppCheckCoreSettingsProtocol, + tokenDelegate: AppCheckCoreTokenDelegate?) { + self.serviceName = serviceName + self.appCheckProvider = appCheckProvider + self.storage = storage + self.tokenRefresher = tokenRefresher + self.settings = settings + self.tokenDelegate = tokenDelegate + super.init() + + self.tokenRefresher.tokenRefreshHandler = { [weak self] completion in + self?.periodicTokenRefresh(completion: completion) + } + } + + private func periodicTokenRefresh(completion: @escaping AppCheckCoreTokenRefreshCompletion) { + Task { + do { + let token = try await self.token(forcingRefresh: false) + let refreshResult = AppCheckCoreTokenRefreshResult(status: .success, + expirationDate: token.expirationDate, + receivedAtDate: token.receivedAtDate) + completion(refreshResult) + } catch { + let refreshResult = AppCheckCoreTokenRefreshResult(status: .failure, + expirationDate: nil, + receivedAtDate: nil) + completion(refreshResult) + } + } + } + + private var ongoingTask: Task? + private let lock = NSLock() + private let kTokenExpirationThreshold: TimeInterval = 5 * 60 // 5 minutes + + private enum GetTokenAction { + case wait(Task) + case run(Task) + } + + public func token(forcingRefresh: Bool) async throws -> AppCheckCoreToken { + let action: GetTokenAction = lock.execute { + // If not forcing refresh and there is an ongoing task, return it + if !forcingRefresh, let ongoing = ongoingTask { + return .wait(ongoing) + } + + // Create a new task and store it only if not forcing refresh + let task = Task { () -> AppCheckCoreToken in + defer { + if !forcingRefresh { + self.lock.execute { + self.ongoingTask = nil + } + } + } + return try await self.createRetrieveOrRefreshToken(forcingRefresh: forcingRefresh) + } + + if !forcingRefresh { + self.ongoingTask = task + } + + return .run(task) + } + + switch action { + case let .wait(ongoingTask): + return try await ongoingTask.value + case let .run(newTask): + return try await newTask.value + } + } + + private func createRetrieveOrRefreshToken(forcingRefresh: Bool) async throws + -> AppCheckCoreToken { + do { + let token = try await getCachedValidToken(forcingRefresh: forcingRefresh) + return token + } catch { + return try await refreshToken() + } + } + + private func getCachedValidToken(forcingRefresh: Bool) async throws -> AppCheckCoreToken { + if forcingRefresh { + throw AppCheckCoreErrorUtil.cachedTokenNotFound() + } + + guard let token = try await storage.getToken() else { + throw AppCheckCoreErrorUtil.cachedTokenNotFound() + } + + let isTokenExpiredOrExpiresSoon = token.expirationDate + .timeIntervalSinceNow < kTokenExpirationThreshold + if isTokenExpiredOrExpiresSoon { + throw AppCheckCoreErrorUtil.cachedTokenExpired() + } + + return token + } + + private func refreshToken() async throws -> AppCheckCoreToken { + let token = try await appCheckProvider.getToken() + + _ = try await storage.setToken(token) + + let refreshResult = AppCheckCoreTokenRefreshResult(status: .success, + expirationDate: token.expirationDate, + receivedAtDate: token.receivedAtDate) + tokenRefresher.updateWithRefreshResult(refreshResult) + + if let tokenDelegate = tokenDelegate { + tokenDelegate.tokenDidUpdate(token, serviceName: serviceName) + } + + return token + } + + @objc(tokenForcingRefresh:completion:) + public func token(forcingRefresh: Bool, completion: @escaping AppCheckCoreTokenHandler) { + Task { + do { + let token = try await self.token(forcingRefresh: forcingRefresh) + completion(AppCheckCoreTokenResult(token: token)) + } catch { + completion(AppCheckCoreTokenResult(error: error)) + } + } + } + + public func limitedUseToken() async throws -> AppCheckCoreToken { + return try await appCheckProvider.getLimitedUseToken() + } + + @objc(limitedUseTokenWithCompletion:) + public func limitedUseToken(completion: @escaping AppCheckCoreTokenHandler) { + Task { + do { + let token = try await self.limitedUseToken() + completion(AppCheckCoreTokenResult(token: token)) + } catch { + completion(AppCheckCoreTokenResult(error: error)) + } + } + } +} diff --git a/AppCheckCore/Sources/Core/AppCheckCoreLogger.swift b/AppCheckCore/Sources/Core/AppCheckCoreLogger.swift new file mode 100644 index 00000000..5f37cd18 --- /dev/null +++ b/AppCheckCore/Sources/Core/AppCheckCoreLogger.swift @@ -0,0 +1,54 @@ +// 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 Foundation + +@objc(GACAppCheckLogLevel) +public enum AppCheckCoreLogLevel: Int { + case debug = 1 + case info = 2 + case warning = 3 + case error = 4 + case fault = 5 +} + +@objc(GACAppCheckLogger) +@objcMembers +public class AppCheckCoreLogger: NSObject { + private static var _logLevel: AppCheckCoreLogLevel = .warning + + public static var logLevel: AppCheckCoreLogLevel { + get { return _logLevel } + set { _logLevel = newValue } + } + + public static func log(code: AppCheckCoreMessageCode, logLevel: AppCheckCoreLogLevel, + message: String) { + #if !NDEBUG + if logLevel.rawValue >= self.logLevel.rawValue { + let levelString: String + switch logLevel { + case .fault: levelString = "Fault" + case .error: levelString = "Error" + case .warning: levelString = "Warning" + case .info: levelString = "Info" + case .debug: levelString = "Debug" + @unknown default: levelString = "Unknown" + } + let codeString = String(format: "I-GAC%06ld", code.rawValue) + print("<\(levelString)> [AppCheckCore][\(codeString)] \(message)") + } + #endif + } +} diff --git a/AppCheckCore/Sources/Core/AppCheckCoreProvider.swift b/AppCheckCore/Sources/Core/AppCheckCoreProvider.swift new file mode 100644 index 00000000..417de698 --- /dev/null +++ b/AppCheckCore/Sources/Core/AppCheckCoreProvider.swift @@ -0,0 +1,66 @@ +// 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 Foundation + +public typealias AppCheckCoreAPIRequestHook = @convention(block) (NSMutableURLRequest) -> Void + +@objc(GACAppCheckProvider) +public protocol AppCheckCoreProvider: NSObjectProtocol { + @objc(getTokenWithCompletion:) + func getToken(completion: @escaping (AppCheckCoreToken?, Error?) -> Void) + + @objc(getLimitedUseTokenWithCompletion:) + func getLimitedUseToken(completion: @escaping (AppCheckCoreToken?, Error?) -> Void) +} + +public extension AppCheckCoreProvider { + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 7.0, *) + func getToken() async throws -> AppCheckCoreToken { + return try await withCheckedThrowingContinuation { continuation in + self.getToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError( + domain: "AppCheckCoreProvider", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No token and no error returned."] + )) + } + } + } + } + + @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 7.0, *) + func getLimitedUseToken() async throws -> AppCheckCoreToken { + return try await withCheckedThrowingContinuation { continuation in + self.getLimitedUseToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError( + domain: "AppCheckCoreProvider", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No token and no error returned."] + )) + } + } + } + } +} diff --git a/AppCheckCore/Sources/Core/AppCheckCoreSettings.swift b/AppCheckCore/Sources/Core/AppCheckCoreSettings.swift new file mode 100644 index 00000000..a58b3f7d --- /dev/null +++ b/AppCheckCore/Sources/Core/AppCheckCoreSettings.swift @@ -0,0 +1,30 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +@objc(GACAppCheckSettingsProtocol) +public protocol AppCheckCoreSettingsProtocol: NSObjectProtocol { + @objc var isTokenAutoRefreshEnabled: Bool { get set } +} + +@objc(GACAppCheckSettings) +@objcMembers +open class AppCheckCoreSettings: NSObject, AppCheckCoreSettingsProtocol { + open var isTokenAutoRefreshEnabled: Bool = false + + override public init() { + super.init() + } +} diff --git a/AppCheckCore/Sources/Core/AppCheckCoreToken.swift b/AppCheckCore/Sources/Core/AppCheckCoreToken.swift new file mode 100644 index 00000000..cc03d688 --- /dev/null +++ b/AppCheckCore/Sources/Core/AppCheckCoreToken.swift @@ -0,0 +1,35 @@ +// 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 Foundation + +@objc(GACAppCheckToken) +@objcMembers +public class AppCheckCoreToken: NSObject, @unchecked Sendable { + public let token: String + public let expirationDate: Date + public let receivedAtDate: Date + + @objc(initWithToken:expirationDate:receivedAtDate:) + public init(token: String, expirationDate: Date, receivedAt receivedAtDate: Date) { + self.token = token + self.expirationDate = expirationDate + self.receivedAtDate = receivedAtDate + super.init() + } + + public convenience init(token: String, expirationDate: Date) { + self.init(token: token, expirationDate: expirationDate, receivedAt: Date()) + } +} diff --git a/AppCheckCore/Sources/Core/AppCheckCoreTokenDelegate.swift b/AppCheckCore/Sources/Core/AppCheckCoreTokenDelegate.swift new file mode 100644 index 00000000..e1c05361 --- /dev/null +++ b/AppCheckCore/Sources/Core/AppCheckCoreTokenDelegate.swift @@ -0,0 +1,21 @@ +// 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 Foundation + +@objc(GACAppCheckTokenDelegate) +public protocol AppCheckCoreTokenDelegate: NSObjectProtocol { + @objc(tokenDidUpdate:serviceName:) + func tokenDidUpdate(_ token: AppCheckCoreToken, serviceName: String) +} diff --git a/AppCheckCore/Sources/Core/AppCheckCoreTokenResult.swift b/AppCheckCore/Sources/Core/AppCheckCoreTokenResult.swift new file mode 100644 index 00000000..243d7bf1 --- /dev/null +++ b/AppCheckCore/Sources/Core/AppCheckCoreTokenResult.swift @@ -0,0 +1,43 @@ +// 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 Foundation + +private let kPlaceholderTokenValue = "eyJlcnJvciI6IlVOS05PV05fRVJST1IifQ==" + +@objc(GACAppCheckTokenResult) +@objcMembers +public class AppCheckCoreTokenResult: NSObject { + public let token: AppCheckCoreToken + public let error: Error? + + public init(token: AppCheckCoreToken, error: Error?) { + self.token = token + self.error = error + super.init() + } + + public convenience init(token: AppCheckCoreToken) { + self.init(token: token, error: nil) + } + + public convenience init(error: Error) { + let placeholder = AppCheckCoreTokenResult.placeholderToken() + self.init(token: placeholder, error: error) + } + + public static func placeholderToken() -> AppCheckCoreToken { + return AppCheckCoreToken(token: kPlaceholderTokenValue, expirationDate: Date.distantPast) + } +} diff --git a/AppCheckCore/Sources/Core/Backoff/AppCheckCoreBackoffWrapper.swift b/AppCheckCore/Sources/Core/Backoff/AppCheckCoreBackoffWrapper.swift new file mode 100644 index 00000000..d221c5d2 --- /dev/null +++ b/AppCheckCore/Sources/Core/Backoff/AppCheckCoreBackoffWrapper.swift @@ -0,0 +1,177 @@ +// 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 Foundation + +@objc(GACAppCheckBackoffType) +public enum AppCheckBackoffType: UInt { + case none + case oneDay + case exponential +} + +public typealias AppCheckCoreBackoffErrorHandler = (Error) -> AppCheckBackoffType +public typealias AppCheckDateProvider = () -> Date + +public protocol AppCheckBackoffWrapperProtocol: NSObjectProtocol { + func applyBackoffToOperation(_ operationProvider: @escaping () async throws -> Any, + errorHandler: @escaping (Error) -> AppCheckBackoffType) async throws + -> Any + + func defaultAppCheckProviderErrorHandler() -> (Error) -> AppCheckBackoffType +} + +private let k24Hours: TimeInterval = 24 * 60 * 60 +private let kMaxJitterCoefficient = 0.5 +private let kMaxExponentialBackoffInterval: TimeInterval = 4 * 60 * 60 + +private class AppCheckBackoffOperationFailure: NSObject { + let finishDate: Date + let error: Error + let backoffType: AppCheckBackoffType + let retryCount: Int + + init(finishDate: Date, error: Error, backoffType: AppCheckBackoffType, retryCount: Int) { + self.finishDate = finishDate + self.error = error + self.backoffType = backoffType + self.retryCount = retryCount + super.init() + } + + static func nextRetryFailure(with previousFailure: AppCheckBackoffOperationFailure?, + finishDate: Date, error: Error, + backoffType: AppCheckBackoffType) + -> AppCheckBackoffOperationFailure { + return AppCheckBackoffOperationFailure( + finishDate: finishDate, + error: error, + backoffType: backoffType, + retryCount: (previousFailure?.retryCount ?? 0) + 1 + ) + } +} + +public class AppCheckCoreBackoffWrapper: NSObject, AppCheckBackoffWrapperProtocol { + private let dateProvider: AppCheckDateProvider + private var lastFailure: AppCheckBackoffOperationFailure? + private let lock = NSLock() + + @objc + override public convenience init() { + self.init(dateProvider: AppCheckCoreBackoffWrapper.currentDateProvider()) + } + + @objc(initWithDateProvider:) + public init(dateProvider: @escaping AppCheckDateProvider) { + self.dateProvider = dateProvider + super.init() + } + + @objc + public static func currentDateProvider() -> AppCheckDateProvider { + return { Date() } + } + + public func applyBackoffToOperation(_ operationProvider: @escaping () async throws -> Any, + errorHandler: @escaping (Error) + -> AppCheckBackoffType) async throws -> Any { + if !isNextOperationAllowed() { + guard let failure = lastFailure else { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Too many attempts.") + } + let reason = + "Too many attempts. Underlying error: \((failure.error as NSError).localizedDescription)" + throw AppCheckCoreErrorUtil.error(withFailureReason: reason) + } + + do { + let result = try await operationProvider() + lock.withLock { + lastFailure = nil + } + return result + } catch { + let backoffType = errorHandler(error) + lock.withLock { + lastFailure = AppCheckBackoffOperationFailure.nextRetryFailure( + with: lastFailure, + finishDate: dateProvider(), + error: error, + backoffType: backoffType + ) + } + throw error + } + } + + private func isNextOperationAllowed() -> Bool { + lock.lock() + defer { lock.unlock() } + + guard let failure = lastFailure else { return true } + + switch failure.backoffType { + case .none: + return true + case .oneDay: + return hasTimeIntervalPassedSinceLastFailure(k24Hours) + case .exponential: + return hasTimeIntervalPassedSinceLastFailure(exponentialBackoffInterval(for: failure)) + @unknown default: + return true + } + } + + private func hasTimeIntervalPassedSinceLastFailure(_ timeInterval: TimeInterval) -> Bool { + guard let failureDate = lastFailure?.finishDate else { return true } + let timeSinceFailure = dateProvider().timeIntervalSince(failureDate) + return timeSinceFailure >= timeInterval + } + + private func exponentialBackoffInterval(for failure: AppCheckBackoffOperationFailure) + -> TimeInterval { + let baseBackoff = pow(2.0, Double(failure.retryCount - 1)) + let maxRandom = 1000.0 + let randomNumber = Double(arc4random_uniform(UInt32(maxRandom))) / maxRandom + let jitterCoefficient = 1.0 + randomNumber * kMaxJitterCoefficient + let backoffIntervalWithJitter = baseBackoff * jitterCoefficient + return min(backoffIntervalWithJitter, kMaxExponentialBackoffInterval) + } + + @objc + public func defaultAppCheckProviderErrorHandler() -> (Error) -> AppCheckBackoffType { + return { error in + guard let httpError = error as? AppCheckCoreHTTPError else { + return .none + } + + let statusCode = httpError.httpResponse.statusCode + + if statusCode < 400 { + return .none + } + + if statusCode == 400 || statusCode == 404 { + return .oneDay + } + + if statusCode == 403 || statusCode == 429 || statusCode == 503 { + return .exponential + } + + return .exponential + } + } +} diff --git a/AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.m b/AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.m deleted file mode 100644 index 9241abcd..00000000 --- a/AppCheckCore/Sources/Core/Backoff/GACAppCheckBackoffWrapper.m +++ /dev/null @@ -1,287 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckBackoffWrapper.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -NS_ASSUME_NONNULL_BEGIN - -static NSTimeInterval const k24Hours = 24 * 60 * 60; - -/// Jitter coefficient 0.5 means that the backoff interval can be up to 50% longer. -static double const kMaxJitterCoefficient = 0.5; - -/// Maximum exponential backoff interval. -static double const kMaxExponentialBackoffInterval = 4 * 60 * 60; // 4 hours. - -/// A class representing an operation result with data required for the backoff calculation. -@interface GACAppCheckBackoffOperationFailure : NSObject - -/// The operation finish date. -@property(nonatomic, readonly) NSDate *finishDate; - -/// The operation error. -@property(nonatomic, readonly) NSError *error; - -/// A backoff type calculated based on the error. -@property(nonatomic, readonly) GACAppCheckBackoffType backoffType; - -/// Number of retries. Is 0 for the first attempt and incremented with each error. Is reset back to -/// 0 on success. -@property(nonatomic, readonly) NSInteger retryCount; - -/// Designated initializer. -- (instancetype)initWithFinishDate:(NSDate *)finishDate - error:(NSError *)error - backoffType:(GACAppCheckBackoffType)backoffType - retryCount:(NSInteger)retryCount NS_DESIGNATED_INITIALIZER; - -- (instancetype)init NS_UNAVAILABLE; - -/// Creates a new result with incremented retryCount and specified error and backoff type. -+ (instancetype)nextRetryFailureWithFailure: - (nullable GACAppCheckBackoffOperationFailure *)previousFailure - finishDate:(NSDate *)finishDate - error:(NSError *)error - backoffType:(GACAppCheckBackoffType)backoffType; - -@end - -@implementation GACAppCheckBackoffOperationFailure - -- (instancetype)initWithFinishDate:(NSDate *)finishDate - error:(NSError *)error - backoffType:(GACAppCheckBackoffType)backoffType - retryCount:(NSInteger)retryCount { - self = [super init]; - if (self) { - _finishDate = finishDate; - _error = error; - _retryCount = retryCount; - _backoffType = backoffType; - } - return self; -} - -+ (instancetype)nextRetryFailureWithFailure: - (nullable GACAppCheckBackoffOperationFailure *)previousFailure - finishDate:(NSDate *)finishDate - error:(NSError *)error - backoffType:(GACAppCheckBackoffType)backoffType { - NSInteger newRetryCount = previousFailure ? previousFailure.retryCount + 1 : 0; - - return [[self alloc] initWithFinishDate:finishDate - error:error - backoffType:backoffType - retryCount:newRetryCount]; -} - -@end - -@interface _GACAppCheckBackoffWrapper () - -/// Current date provider. Is used instead of `+[NSDate date]` for testability. -@property(nonatomic, readonly) GACAppCheckDateProvider dateProvider; - -/// Last operation result. -@property(nonatomic, nullable) GACAppCheckBackoffOperationFailure *lastFailure; - -@end - -@implementation _GACAppCheckBackoffWrapper - -- (instancetype)init { - return [self initWithDateProvider:[_GACAppCheckBackoffWrapper currentDateProvider]]; -} - -- (instancetype)initWithDateProvider:(GACAppCheckDateProvider)dateProvider { - self = [super init]; - if (self) { - _dateProvider = [dateProvider copy]; - } - return self; -} - -+ (GACAppCheckDateProvider)currentDateProvider { - return ^NSDate *(void) { - return [NSDate date]; - }; -} - -- (FBLPromise *)applyBackoffToOperation:(GACAppCheckBackoffOperationProvider)operationProvider - errorHandler:(GACAppCheckBackoffErrorHandler)errorHandler { - if (![self isNextOperationAllowed]) { - // Backing off - skip the operation and return an error straight away. - return [self promiseWithRetryDisallowedError:self.lastFailure.error]; - } - - __auto_type operationPromise = operationProvider(); - return operationPromise - .thenOn([self queue], - ^id(id result) { - @synchronized(self) { - // Reset failure on success. - self.lastFailure = nil; - } - - // Return the result. - return result; - }) - .recoverOn([self queue], ^NSError *(NSError *error) { - @synchronized(self) { - // Update the last failure to calculate the backoff. - self.lastFailure = - [GACAppCheckBackoffOperationFailure nextRetryFailureWithFailure:self.lastFailure - finishDate:self.dateProvider() - error:error - backoffType:errorHandler(error)]; - } - - // Re-throw the error. - return error; - }); -} - -#pragma mark - Private - -- (BOOL)isNextOperationAllowed { - @synchronized(self) { - if (self.lastFailure == nil) { - // It is first attempt. Always allow it. - return YES; - } - - switch (self.lastFailure.backoffType) { - case GACAppCheckBackoffTypeNone: - return YES; - break; - - case GACAppCheckBackoffType1Day: - return [self hasTimeIntervalPassedSinceLastFailure:k24Hours]; - break; - - case GACAppCheckBackoffTypeExponential: - return [self hasTimeIntervalPassedSinceLastFailure: - [self exponentialBackoffIntervalForFailure:self.lastFailure]]; - break; - } - } -} - -- (BOOL)hasTimeIntervalPassedSinceLastFailure:(NSTimeInterval)timeInterval { - NSDate *failureDate = self.lastFailure.finishDate; - // Return YES if there has not been a failure yet. - if (failureDate == nil) return YES; - - NSTimeInterval timeSinceFailure = [self.dateProvider() timeIntervalSinceDate:failureDate]; - return timeSinceFailure >= timeInterval; -} - -- (FBLPromise *)promiseWithRetryDisallowedError:(NSError *)error { - NSString *reason = - [NSString stringWithFormat:@"Too many attempts. Underlying error: %@", - error.localizedDescription ?: error.localizedFailureReason]; - NSError *retryDisallowedError = [_GACAppCheckErrorUtil errorWithFailureReason:reason]; - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:retryDisallowedError]; - return rejectedPromise; -} - -- (dispatch_queue_t)queue { - return dispatch_get_global_queue(QOS_CLASS_UTILITY, 0); -} - -#pragma mark - Exponential backoff - -/// @return Exponential backoff interval with jitter. Jitter is needed to avoid all clients to retry -/// at the same time after e.g. a backend outage. -- (NSTimeInterval)exponentialBackoffIntervalForFailure: - (GACAppCheckBackoffOperationFailure *)failure { - // Base exponential backoff interval. - NSTimeInterval baseBackoff = pow(2, failure.retryCount); - - // Get a random number from 0 to 1. - double maxRandom = 1000; - double randomNumber = (double)arc4random_uniform((int32_t)maxRandom) / maxRandom; - - // A number from 1 to 1 + kMaxJitterCoefficient, e.g. from 1 to 1.5. Indicates how much the - // backoff can be extended. - double jitterCoefficient = 1 + randomNumber * kMaxJitterCoefficient; - - // Exponential backoff interval with jitter. - NSTimeInterval backoffIntervalWithJitter = baseBackoff * jitterCoefficient; - - // Apply limit to the backoff interval. - return MIN(backoffIntervalWithJitter, kMaxExponentialBackoffInterval); -} - -#pragma mark - Error handling - -- (GACAppCheckBackoffErrorHandler)defaultAppCheckProviderErrorHandler { - return ^GACAppCheckBackoffType(NSError *error) { - GACAppCheckHTTPError *HTTPError = - [error isKindOfClass:[GACAppCheckHTTPError class]] ? (GACAppCheckHTTPError *)error : nil; - - if (HTTPError == nil) { - // No backoff for attestation providers for non-backend (e.g. network) errors. - return GACAppCheckBackoffTypeNone; - } - - NSInteger statusCode = HTTPError.HTTPResponse.statusCode; - - if (statusCode < 400) { - // No backoff for codes before 400. - return GACAppCheckBackoffTypeNone; - } - - if (statusCode == 400 || statusCode == 404) { - // Firebase project misconfiguration. It will unlikely be fixed soon and often requires - // another version of the app. Try again in 1 day. - return GACAppCheckBackoffType1Day; - } - - if (statusCode == 403) { - // Project may have been soft-deleted accidentally. There is a chance of timely recovery, so - // try again later. - return GACAppCheckBackoffTypeExponential; - } - - if (statusCode == 429) { - // Too many requests. Try again in a while. - return GACAppCheckBackoffTypeExponential; - } - - if (statusCode == 503) { - // Server is overloaded. Try again in a while. - return GACAppCheckBackoffTypeExponential; - } - - // For all other server error cases default to the exponential backoff. - return GACAppCheckBackoffTypeExponential; - }; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/Errors/AppCheckCoreErrorUtil.swift b/AppCheckCore/Sources/Core/Errors/AppCheckCoreErrorUtil.swift new file mode 100644 index 00000000..1f40bcac --- /dev/null +++ b/AppCheckCore/Sources/Core/Errors/AppCheckCoreErrorUtil.swift @@ -0,0 +1,213 @@ +// 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 Foundation +#if canImport(DeviceCheck) + import DeviceCheck +#endif + +public let kAppCheckCoreMissingRecaptchaSDKMessage = + "The reCAPTCHA Enterprise SDK is not linked. See https://firebase.google.com/docs/app-check/ios/recaptcha-enterprise-provider#prepare-environment" + +public func AppCheckCoreSetErrorToPointer(_ error: Error, _ pointer: NSErrorPointer) { + if let pointer = pointer { + pointer.pointee = error as NSError + } +} + +@objc(GACAppCheckErrorUtil) +public class AppCheckCoreErrorUtil: NSObject { + @objc + public static func publicDomainError(with error: Error) -> Error { + let nsError = error as NSError + if nsError.domain == AppCheckCoreErrorDomain { + return nsError + } + return unknownError(with: nsError) + } + + // MARK: - Internal errors + + @objc + public static func cachedTokenNotFound() -> Error { + return appCheckError( + withCode: .unknown, + failureReason: "Cached token not found.", + underlyingError: nil + ) + } + + @objc + public static func cachedTokenExpired() -> Error { + return appCheckError( + withCode: .unknown, + failureReason: "Cached token expired.", + underlyingError: nil + ) + } + + @objc + public static func keychainError(with error: Error) -> Error { + let nsError = error as NSError + // kGULKeychainUtilsErrorDomain from GULKeychainUtils + if nsError.domain == "com.google.utilities.keychain" { + return appCheckError( + withCode: .keychain, + failureReason: "Keychain access error.", + underlyingError: nsError + ) + } + return unknownError(with: nsError) + } + + @objc + public static func apiError(with httpResponse: HTTPURLResponse, + data: Data?) -> AppCheckCoreHTTPError { + return AppCheckCoreHTTPError(httpResponse: httpResponse, data: data) + } + + @objc + public static func apiError(withNetworkError networkError: Error) -> Error { + return appCheckError( + withCode: .serverUnreachable, + failureReason: "API request error.", + underlyingError: networkError + ) + } + + @objc + public static func appCheckTokenResponseError(withMissingField fieldName: String) -> Error { + let failureReason = + "Unexpected app check token response format. Field `\(fieldName)` is missing." + return appCheckError(withCode: .unknown, failureReason: failureReason, underlyingError: nil) + } + + @objc + public static func appAttestAttestationResponseError(withMissingField fieldName: String) + -> Error { + let failureReason = "Unexpected attestation response format. Field `\(fieldName)` is missing." + return appCheckError(withCode: .unknown, failureReason: failureReason, underlyingError: nil) + } + + @objc + public static func jsonSerializationError(_ error: Error?) -> Error { + return appCheckError( + withCode: .unknown, + failureReason: "JSON serialization error.", + underlyingError: error + ) + } + + @objc + public static func error(withFailureReason failureReason: String) -> Error { + return appCheckError(withCode: .unknown, failureReason: failureReason, underlyingError: nil) + } + + @objc + public static func unsupportedAttestationProvider(_ providerName: String) -> Error { + let failureReason = + "The attestation provider \(providerName) is not supported on current platform and OS version." + return appCheckError(withCode: .unsupported, failureReason: failureReason, underlyingError: nil) + } + + @objc + public static func missingRecaptchaSDKError() -> Error { + return appCheckError( + withCode: .unsupported, + failureReason: kAppCheckCoreMissingRecaptchaSDKMessage, + underlyingError: nil + ) + } + + // MARK: - App Attest Errors + + @objc + public static func appAttestKeyIDNotFound() -> Error { + return appCheckError( + withCode: .unknown, + failureReason: "App attest key ID not found.", + underlyingError: nil + ) + } + + @objc + public static func appAttestGenerateKeyFailed(with error: Error) -> Error { + let failureReason = + "Failed to generate a new cryptographic key for use with the App Attest service (`generateKeyWithCompletionHandler:`); \(errorDescription(withDeviceCheckError: error as NSError))." + return appCheckError(withCode: .unknown, failureReason: failureReason, underlyingError: error) + } + + @objc + public static func appAttestAttestKeyFailed(with error: Error, keyId: String, + clientDataHash: Data) -> Error { + let systemVersion = ProcessInfo.processInfo.operatingSystemVersionString + let failureReason = + "Failed to attest the validity of the generated cryptographic key (`attestKey:clientDataHash:completionHandler:`); keyId.length = \(keyId.count), clientDataHash = \(clientDataHash.base64EncodedString()), systemVersion = \(systemVersion); \(errorDescription(withDeviceCheckError: error as NSError))." + return appCheckError(withCode: .unknown, failureReason: failureReason, underlyingError: error) + } + + @objc + public static func appAttestGenerateAssertionFailed(with error: Error, keyId: String, + clientDataHash: Data) -> Error { + let systemVersion = ProcessInfo.processInfo.operatingSystemVersionString + let failureReason = + "Failed to create a block of data that demonstrates the legitimacy of the app instance (`generateAssertion:clientDataHash:completionHandler:`); keyId.length = \(keyId.count), clientDataHash = \(clientDataHash.base64EncodedString()), systemVersion = \(systemVersion); \(errorDescription(withDeviceCheckError: error as NSError))." + return appCheckError(withCode: .unknown, failureReason: failureReason, underlyingError: error) + } + + // MARK: - Helpers + + @objc + public static func unknownError(with error: Error) -> Error { + let nsError = error as NSError + let failureReason = nsError.userInfo[NSLocalizedFailureReasonErrorKey] as? String + return appCheckError(withCode: .unknown, failureReason: failureReason, underlyingError: nsError) + } + + @objc + public static func appCheckError(withCode code: AppCheckCoreErrorCode, failureReason: String?, + underlyingError: Error?) -> Error { + var userInfo: [String: Any] = [:] + userInfo[NSUnderlyingErrorKey] = underlyingError + userInfo[NSLocalizedFailureReasonErrorKey] = failureReason + + return NSError(domain: AppCheckCoreErrorDomain, code: code.rawValue, userInfo: userInfo) + } + + @objc + public static func errorDescription(withDeviceCheckError error: NSError) -> String { + #if canImport(DeviceCheck) && !os(watchOS) + if #available(macOS 10.15, iOS 11.0, tvOS 11.0, watchOS 9.0, *) { + if error.domain == DCErrorDomain { + let errorCode = DCError.Code(rawValue: error.code) + switch errorCode { + case .featureUnsupported: + return "DCErrorFeatureUnsupported - DeviceCheck is unavailable on this device" + case .invalidInput: + return "DCErrorInvalidInput - An error code that indicates when your app provides data that isn’t formatted correctly" + case .invalidKey: + return "DCErrorInvalidKey - An error caused by a failed attempt to use the App Attest key" + case .serverUnavailable: + return "DCErrorServerUnavailable - An error that indicates a failed attempt to contact the App Attest service during an attestation" + case .unknownSystemFailure: + return "DCErrorUnknownSystemFailure - A failure has occurred, such as the failure to generate a token" + default: + return "Unknown DCError(\(error.code)) - \(error.localizedDescription)" + } + } + } + #endif + return "Unknown Error { domain: \(error.domain), code: \(error.code) } - \(error.localizedDescription)" + } +} diff --git a/AppCheckCore/Sources/Core/Errors/AppCheckCoreErrors.swift b/AppCheckCore/Sources/Core/Errors/AppCheckCoreErrors.swift new file mode 100644 index 00000000..5d71524a --- /dev/null +++ b/AppCheckCore/Sources/Core/Errors/AppCheckCoreErrors.swift @@ -0,0 +1,58 @@ +// 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 Foundation + +/// Firebase app check error domain. +public let AppCheckCoreErrorDomain = "com.google.app_check_core" + +@objc(GACAppCheckErrorCode) +public enum AppCheckCoreErrorCode: Int, Error { + /// An unknown or non-actionable error. + case unknown = 0 + + /// A network connection error. + case serverUnreachable = 1 + + /// Invalid configuration error. Currently, an exception is thrown but this error is reserved + /// for future implementations of invalid configuration detection. + case invalidConfiguration = 2 + + /// System keychain access error. Ensure that the app has proper keychain access. + case keychain = 3 + + /// Selected app attestation provider is not supported on the current platform or OS version. + case unsupported = 4 +} + +@objc(GACAppCheckMessageCode) +public enum AppCheckCoreMessageCode: Int { + case unknown = 1001 + + // App Check + case providerIsMissing = 2002 + case stagingModeEnabled = 2003 + case unexpectedHTTPCode = 3001 + + // Debug Provider + case localDebugToken = 4001 + case environmentVariableDebugToken = 4002 + case debugProviderFirebaseEnvironmentVariable = 4003 + case debugProviderFailedExchange = 4004 + + // App Attest Provider + case appAttestNotSupported = 7001 + case attestationRejected = 7002 + case assertionRejected = 7003 +} diff --git a/AppCheckCore/Sources/Core/Errors/AppCheckCoreHTTPError.swift b/AppCheckCore/Sources/Core/Errors/AppCheckCoreHTTPError.swift new file mode 100644 index 00000000..5f0cfd3a --- /dev/null +++ b/AppCheckCore/Sources/Core/Errors/AppCheckCoreHTTPError.swift @@ -0,0 +1,56 @@ +// 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 Foundation + +@objc(GACAppCheckHTTPError) +public class AppCheckCoreHTTPError: NSError, @unchecked Sendable { + @objc public let httpResponse: HTTPURLResponse + @objc public let data: Data + + @objc(initWithHTTPResponse:data:) + public init(httpResponse: HTTPURLResponse, data: Data?) { + let actualData = data ?? Data() + self.httpResponse = httpResponse + self.data = actualData + + let responseString = String(data: actualData, encoding: .utf8) ?? "" + let failureReason = """ + The server responded with an error: + - URL: \(httpResponse.url?.absoluteString ?? "unknown") + - HTTP status code: \(httpResponse.statusCode) + - Response body: \(responseString) + """ + + let userInfo: [String: Any] = [ + NSLocalizedFailureReasonErrorKey: failureReason, + ] + + super.init( + domain: AppCheckCoreErrorDomain, + code: AppCheckCoreErrorCode.unknown.rawValue, + userInfo: userInfo + ) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // NSCopying + override public func copy(with zone: NSZone? = nil) -> Any { + return AppCheckCoreHTTPError(httpResponse: httpResponse, data: data) + } +} diff --git a/AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.m b/AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.m deleted file mode 100644 index a86121c7..00000000 --- a/AppCheckCore/Sources/Core/Errors/GACAppCheckErrorUtil.m +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -#import - -#import -#import - -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" - -NSString *const kGACAppCheckMissingRecaptchaSDKMessage = - @"The reCAPTCHA Enterprise SDK is not linked. See " - @"https://firebase.google.com/docs/app-check/ios/" - @"recaptcha-enterprise-provider#prepare-environment"; - -@implementation _GACAppCheckErrorUtil - -+ (NSError *)publicDomainErrorWithError:(NSError *)error { - if ([error.domain isEqualToString:GACAppCheckErrorDomain]) { - return error; - } - - return [self unknownErrorWithError:error]; -} - -#pragma mark - Internal errors - -+ (NSError *)cachedTokenNotFound { - NSString *failureReason = [NSString stringWithFormat:@"Cached token not found."]; - return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnknown - failureReason:failureReason - underlyingError:nil]; -} - -+ (NSError *)cachedTokenExpired { - NSString *failureReason = [NSString stringWithFormat:@"Cached token expired."]; - return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnknown - failureReason:failureReason - underlyingError:nil]; -} - -+ (NSError *)keychainErrorWithError:(NSError *)error { - if ([error.domain isEqualToString:kGULKeychainUtilsErrorDomain]) { - NSString *failureReason = [NSString stringWithFormat:@"Keychain access error."]; - return [self appCheckErrorWithCode:GACAppCheckErrorCodeKeychain - failureReason:failureReason - underlyingError:error]; - } - - return [self unknownErrorWithError:error]; -} - -+ (GACAppCheckHTTPError *)APIErrorWithHTTPResponse:(NSHTTPURLResponse *)HTTPResponse - data:(nullable NSData *)data { - return [[GACAppCheckHTTPError alloc] initWithHTTPResponse:HTTPResponse data:data]; -} - -+ (NSError *)APIErrorWithNetworkError:(NSError *)networkError { - NSString *failureReason = [NSString stringWithFormat:@"API request error."]; - return [self appCheckErrorWithCode:GACAppCheckErrorCodeServerUnreachable - failureReason:failureReason - underlyingError:networkError]; -} - -+ (NSError *)appCheckTokenResponseErrorWithMissingField:(NSString *)fieldName { - NSString *failureReason = [NSString - stringWithFormat:@"Unexpected app check token response format. Field `%@` is missing.", - fieldName]; - return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnknown - failureReason:failureReason - underlyingError:nil]; -} - -+ (NSError *)appAttestAttestationResponseErrorWithMissingField:(NSString *)fieldName { - NSString *failureReason = - [NSString stringWithFormat:@"Unexpected attestation response format. Field `%@` is missing.", - fieldName]; - return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnknown - failureReason:failureReason - underlyingError:nil]; -} - -+ (NSError *)JSONSerializationError:(NSError *)error { - NSString *failureReason = [NSString stringWithFormat:@"JSON serialization error."]; - return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnknown - failureReason:failureReason - underlyingError:error]; -} - -+ (NSError *)unsupportedAttestationProvider:(NSString *)providerName { - NSString *failureReason = [NSString - stringWithFormat: - @"The attestation provider %@ is not supported on current platform and OS version.", - providerName]; - return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnsupported - failureReason:failureReason - underlyingError:nil]; -} - -+ (NSError *)missingRecaptchaSDKError { - return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnsupported - failureReason:kGACAppCheckMissingRecaptchaSDKMessage - underlyingError:nil]; -} - -+ (NSError *)errorWithFailureReason:(NSString *)failureReason { - return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnknown - failureReason:failureReason - underlyingError:nil]; -} - -#pragma mark - App Attest - -+ (NSError *)appAttestKeyIDNotFound { - NSString *failureReason = [NSString stringWithFormat:@"App attest key ID not found."]; - return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnknown - failureReason:failureReason - underlyingError:nil]; -} - -+ (NSError *)appAttestGenerateKeyFailedWithError:(NSError *)error { - NSString *failureReason = - [NSString stringWithFormat:@"Failed to generate a new cryptographic key for use with the App " - @"Attest service (`generateKeyWithCompletionHandler:`); %@.", - [self errorDescriptionWithDeviceCheckError:error]]; - // TODO(#31): Add a new error code for this case (e.g., GACAppCheckAppAttestGenerateKeyFailed). - return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnknown - failureReason:failureReason - underlyingError:error]; -} - -+ (NSError *)appAttestAttestKeyFailedWithError:(NSError *)error - keyId:(NSString *)keyId - clientDataHash:(NSData *)clientDataHash { - NSString *failureReason = - [NSString stringWithFormat:@"Failed to attest the validity of the generated cryptographic " - @"key (`attestKey:clientDataHash:completionHandler:`); " - @"keyId.length = %lu, clientDataHash = %@, systemVersion = %@; " - @"%@.", - (unsigned long)keyId.length, - [clientDataHash base64EncodedStringWithOptions:0], - [GULAppEnvironmentUtil systemVersion], - [self errorDescriptionWithDeviceCheckError:error]]; - // TODO(#31): Add a new error code for this case (e.g., GACAppCheckAppAttestAttestKeyFailed). - return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnknown - failureReason:failureReason - underlyingError:error]; -} - -+ (NSError *)appAttestGenerateAssertionFailedWithError:(NSError *)error - keyId:(NSString *)keyId - clientDataHash:(NSData *)clientDataHash { - NSString *failureReason = [NSString - stringWithFormat:@"Failed to create a block of data that demonstrates the legitimacy of the " - @"app instance (`generateAssertion:clientDataHash:completionHandler:`); " - @"keyId.length = %lu, clientDataHash = %@, systemVersion = %@; %@.", - (unsigned long)keyId.length, - [clientDataHash base64EncodedStringWithOptions:0], - [GULAppEnvironmentUtil systemVersion], - [self errorDescriptionWithDeviceCheckError:error]]; - // TODO(#31): Add error code for this case (e.g., GACAppCheckAppAttestGenerateAssertionFailed). - return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnknown - failureReason:failureReason - underlyingError:error]; -} - -#pragma mark - Helpers - -+ (NSError *)unknownErrorWithError:(NSError *)error { - NSString *failureReason = error.userInfo[NSLocalizedFailureReasonErrorKey]; - return [self appCheckErrorWithCode:GACAppCheckErrorCodeUnknown - failureReason:failureReason - underlyingError:error]; -} - -+ (NSError *)appCheckErrorWithCode:(GACAppCheckErrorCode)code - failureReason:(nullable NSString *)failureReason - underlyingError:(nullable NSError *)underlyingError { - NSMutableDictionary *userInfo = [NSMutableDictionary dictionary]; - userInfo[NSUnderlyingErrorKey] = underlyingError; - userInfo[NSLocalizedFailureReasonErrorKey] = failureReason; - - return [NSError errorWithDomain:GACAppCheckErrorDomain code:code userInfo:userInfo]; -} - -+ (NSString *)errorDescriptionWithDeviceCheckError:(NSError *)error { - // DCError is only available on iOS 11.0+, macOS 10.15+, Mac Catalyst 13.1+, tvOS 11.0+ and - // watchOS 9.0+. - if (@available(macOS 10.15, macCatalyst 13.1, watchOS 9.0, *)) { - if ([error.domain isEqualToString:DCErrorDomain]) { - DCError errorCode = error.code; - switch (errorCode) { - case DCErrorFeatureUnsupported: - return @"DCErrorFeatureUnsupported - DeviceCheck is unavailable on this device"; - case DCErrorInvalidInput: - return @"DCErrorInvalidInput - An error code that indicates when your app provides data " - @"that isn’t formatted correctly"; - case DCErrorInvalidKey: - return @"DCErrorInvalidKey - An error caused by a failed attempt to use the App Attest " - @"key"; - case DCErrorServerUnavailable: - return @"DCErrorServerUnavailable - An error that indicates a failed attempt to contact " - @"the App Attest service during an attestation"; - case DCErrorUnknownSystemFailure: - return @"DCErrorUnknownSystemFailure - A failure has occurred, such as the failure to " - @"generate a token"; - default: - return [NSString stringWithFormat:@"Unknown DCError(%ld) - %@", (long)errorCode, - error.localizedDescription]; - } - } - } - - // Not a DeviceCheck error or DCError is not available on the platform. - return [NSString stringWithFormat:@"Unknown Error { domain: %@, code: %ld } - %@", error.domain, - (long)error.code, error.localizedDescription]; -} - -@end - -void GACAppCheckSetErrorToPointer(NSError *error, NSError **pointer) { - if (pointer != NULL) { - *pointer = error; - } -} diff --git a/AppCheckCore/Sources/Core/Errors/GACAppCheckErrors.m b/AppCheckCore/Sources/Core/Errors/GACAppCheckErrors.m deleted file mode 100644 index 935e57ff..00000000 --- a/AppCheckCore/Sources/Core/Errors/GACAppCheckErrors.m +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" - -NSErrorDomain const GACAppCheckErrorDomain = @"com.google.app_check_core"; diff --git a/AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h b/AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h deleted file mode 100644 index d37626c7..00000000 --- a/AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2021 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 - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckHTTPError : NSError - -@property(nonatomic, readonly) NSHTTPURLResponse *HTTPResponse; -@property(nonatomic, readonly, nonnull) NSData *data; - -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithHTTPResponse:(NSHTTPURLResponse *)HTTPResponse data:(nullable NSData *)data; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.m b/AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.m deleted file mode 100644 index 3f79a408..00000000 --- a/AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.m +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h" - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -@implementation GACAppCheckHTTPError - -- (instancetype)initWithHTTPResponse:(NSHTTPURLResponse *)HTTPResponse - data:(nullable NSData *)data { - NSDictionary *userInfo = [[self class] userInfoWithHTTPResponse:HTTPResponse data:data]; - self = [super initWithDomain:GACAppCheckErrorDomain - code:GACAppCheckErrorCodeUnknown - userInfo:userInfo]; - if (self) { - _HTTPResponse = HTTPResponse; - _data = data; - } - return self; -} - -+ (NSDictionary *)userInfoWithHTTPResponse:(NSHTTPURLResponse *)HTTPResponse - data:(nullable NSData *)data { - NSString *responseString = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; - NSString *failureReason = - [NSString stringWithFormat:@"The server responded with an error: \n - URL: %@ \n - HTTP " - @"status code: %ld \n - Response body: %@", - HTTPResponse.URL, (long)HTTPResponse.statusCode, responseString]; - return @{NSLocalizedFailureReasonErrorKey : failureReason}; -} - -#pragma mark - NSCopying - -- (id)copyWithZone:(NSZone *)zone { - return [[[self class] alloc] initWithHTTPResponse:self.HTTPResponse data:self.data]; -} - -#pragma mark - NSSecureCoding - -- (nullable instancetype)initWithCoder:(NSCoder *)coder { - NSHTTPURLResponse *HTTPResponse = [coder decodeObjectOfClass:[NSHTTPURLResponse class] - forKey:@"HTTPResponse"]; - if (!HTTPResponse) { - return nil; - } - NSData *data = [coder decodeObjectOfClass:[NSData class] forKey:@"data"]; - - return [self initWithHTTPResponse:HTTPResponse data:data]; -} - -- (void)encodeWithCoder:(NSCoder *)coder { - [coder encodeObject:self.HTTPResponse forKey:@"HTTPResponse"]; - [coder encodeObject:self.data forKey:@"data"]; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -@end diff --git a/AppCheckCore/Sources/Core/GACAppCheck.m b/AppCheckCore/Sources/Core/GACAppCheck.m deleted file mode 100644 index 4492e327..00000000 --- a/AppCheckCore/Sources/Core/GACAppCheck.m +++ /dev/null @@ -1,240 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheck.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckProvider.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckSettings.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/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 - -static const NSTimeInterval kTokenExpirationThreshold = 5 * 60; // 5 min. - -typedef void (^GACAppCheckTokenHandler)(GACAppCheckTokenResult *result); - -@interface GACAppCheck () - -@property(nonatomic, readonly) NSString *serviceName; -@property(nonatomic, readonly) id appCheckProvider; -@property(nonatomic, readonly) id storage; -@property(nonatomic, readonly) id settings; -@property(nonatomic, readonly, nullable, weak) id tokenDelegate; - -@property(nonatomic, readonly, nullable) id tokenRefresher; - -@property(nonatomic, nullable) FBLPromise *ongoingRetrieveOrRefreshTokenPromise; - -@end - -@implementation GACAppCheck - -#pragma mark - Internal - -- (instancetype)initWithServiceName:(NSString *)serviceName - appCheckProvider:(id)appCheckProvider - storage:(id)storage - tokenRefresher:(id)tokenRefresher - settings:(id)settings - tokenDelegate:(nullable id)tokenDelegate { - self = [super init]; - if (self) { - _serviceName = serviceName; - _appCheckProvider = appCheckProvider; - _storage = storage; - _tokenRefresher = tokenRefresher; - _settings = settings; - _tokenDelegate = tokenDelegate; - - __auto_type __weak weakSelf = self; - tokenRefresher.tokenRefreshHandler = ^(GACAppCheckTokenRefreshCompletion _Nonnull completion) { - __auto_type strongSelf = weakSelf; - [strongSelf periodicTokenRefreshWithCompletion:completion]; - }; - } - return self; -} - -#pragma mark - Public - -- (instancetype)initWithServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName - appCheckProvider:(id)appCheckProvider - settings:(id)settings - tokenDelegate:(nullable id)tokenDelegate - keychainAccessGroup:(nullable NSString *)accessGroup { - GACAppCheckTokenRefreshResult *refreshResult = - [[GACAppCheckTokenRefreshResult alloc] initWithStatusNever]; - GACAppCheckTokenRefresher *tokenRefresher = - [[GACAppCheckTokenRefresher alloc] initWithRefreshResult:refreshResult settings:settings]; - - NSString *tokenKey = - [NSString stringWithFormat:@"app_check_token.%@.%@", serviceName, resourceName]; - GACAppCheckStorage *storage = [[GACAppCheckStorage alloc] initWithTokenKey:tokenKey - accessGroup:accessGroup]; - - return [self initWithServiceName:serviceName - appCheckProvider:appCheckProvider - storage:storage - tokenRefresher:tokenRefresher - settings:settings - tokenDelegate:tokenDelegate]; -} - -- (void)tokenForcingRefresh:(BOOL)forcingRefresh completion:(GACAppCheckTokenHandler)handler { - [self retrieveOrRefreshTokenForcingRefresh:forcingRefresh] - .then(^id _Nullable(GACAppCheckToken *token) { - handler([[GACAppCheckTokenResult alloc] initWithToken:token]); - return token; - }) - .catch(^(NSError *_Nonnull error) { - handler([[GACAppCheckTokenResult alloc] initWithError:error]); - }); -} - -- (void)limitedUseTokenWithCompletion:(GACAppCheckTokenHandler)handler { - [self limitedUseToken] - .then(^id _Nullable(GACAppCheckToken *token) { - handler([[GACAppCheckTokenResult alloc] initWithToken:token]); - return token; - }) - .catch(^(NSError *_Nonnull error) { - handler([[GACAppCheckTokenResult alloc] initWithError:error]); - }); -} - -#pragma mark - FAA token cache - -- (FBLPromise *)retrieveOrRefreshTokenForcingRefresh:(BOOL)forcingRefresh { - return [FBLPromise do:^id _Nullable { - // TODO(#42): Don't re-use ongoing promise if forcingRefresh is YES. - if (self.ongoingRetrieveOrRefreshTokenPromise == nil) { - // Kick off a new operation only when there is not an ongoing one. - self.ongoingRetrieveOrRefreshTokenPromise = - [self createRetrieveOrRefreshTokenPromiseForcingRefresh:forcingRefresh] - - // Release the ongoing operation promise on completion. - .then(^GACAppCheckToken *(GACAppCheckToken *token) { - self.ongoingRetrieveOrRefreshTokenPromise = nil; - return token; - }) - .recover(^NSError *(NSError *error) { - self.ongoingRetrieveOrRefreshTokenPromise = nil; - return error; - }); - } - return self.ongoingRetrieveOrRefreshTokenPromise; - }]; -} - -- (FBLPromise *)createRetrieveOrRefreshTokenPromiseForcingRefresh: - (BOOL)forcingRefresh { - return [self getCachedValidTokenForcingRefresh:forcingRefresh].recover( - ^id _Nullable(NSError *_Nonnull error) { - return [self refreshToken]; - }); -} - -- (FBLPromise *)getCachedValidTokenForcingRefresh:(BOOL)forcingRefresh { - if (forcingRefresh) { - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:[_GACAppCheckErrorUtil cachedTokenNotFound]]; - return rejectedPromise; - } - - return [self.storage getToken].then(^id(GACAppCheckToken *_Nullable token) { - if (token == nil) { - return [_GACAppCheckErrorUtil cachedTokenNotFound]; - } - - BOOL isTokenExpiredOrExpiresSoon = - [token.expirationDate timeIntervalSinceNow] < kTokenExpirationThreshold; - if (isTokenExpiredOrExpiresSoon) { - return [_GACAppCheckErrorUtil cachedTokenExpired]; - } - - return token; - }); -} - -- (FBLPromise *)refreshToken { - return [FBLPromise - wrapObjectOrErrorCompletion:^(FBLPromiseObjectOrErrorCompletion _Nonnull handler) { - [self.appCheckProvider getTokenWithCompletion:handler]; - }] - .then(^id _Nullable(GACAppCheckToken *_Nullable token) { - return [self.storage setToken:token]; - }) - .then(^id _Nullable(GACAppCheckToken *_Nullable token) { - // TODO: Make sure the self.tokenRefresher is updated only once. Currently the timer will be - // updated twice in the case when the refresh triggered by self.tokenRefresher, but it - // should be fine for now as it is a relatively cheap operation. - __auto_type refreshResult = [[GACAppCheckTokenRefreshResult alloc] - initWithStatusSuccessAndExpirationDate:token.expirationDate - receivedAtDate:token.receivedAtDate]; - [self.tokenRefresher updateWithRefreshResult:refreshResult]; - if (self.tokenDelegate) { - [self.tokenDelegate tokenDidUpdate:token serviceName:self.serviceName]; - } - return token; - }); -} - -- (FBLPromise *)limitedUseToken { - return - [FBLPromise wrapObjectOrErrorCompletion:^( - FBLPromiseObjectOrErrorCompletion _Nonnull handler) { - [self.appCheckProvider getLimitedUseTokenWithCompletion:handler]; - }].then(^id _Nullable(GACAppCheckToken *_Nullable token) { - return token; - }); -} - -#pragma mark - Token auto refresh - -- (void)periodicTokenRefreshWithCompletion:(GACAppCheckTokenRefreshCompletion)completion { - [self retrieveOrRefreshTokenForcingRefresh:NO] - .then(^id _Nullable(GACAppCheckToken *_Nullable token) { - __auto_type refreshResult = [[GACAppCheckTokenRefreshResult alloc] - initWithStatusSuccessAndExpirationDate:token.expirationDate - receivedAtDate:token.receivedAtDate]; - completion(refreshResult); - return nil; - }) - .catch(^(NSError *error) { - __auto_type refreshResult = [[GACAppCheckTokenRefreshResult alloc] initWithStatusFailure]; - completion(refreshResult); - }); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/GACAppCheckDebugProvider+Internal.h b/AppCheckCore/Sources/Core/GACAppCheckDebugProvider+Internal.h deleted file mode 100644 index abbe0cae..00000000 --- a/AppCheckCore/Sources/Core/GACAppCheckDebugProvider+Internal.h +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckDebugProvider.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckDebugProvider (Internal) - -/// Internal initializer. -- (instancetype)initWithServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName - baseURL:(nullable NSString *)baseURL - APIKey:(NSString *)APIKey - requestHooks:(nullable NSArray *)requestHooks - environment:(NSDictionary *)environment; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/GACAppCheckLogger+Internal.h b/AppCheckCore/Sources/Core/GACAppCheckLogger+Internal.h deleted file mode 100644 index 0b71c3e3..00000000 --- a/AppCheckCore/Sources/Core/GACAppCheckLogger+Internal.h +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckLogger.h" - -NS_ASSUME_NONNULL_BEGIN - -/** Prints the given code and message to the console. - * - * @param code The message code describing the nature of the log. - * @param logLevel The log level of this log. - * @param message The message string to log. - */ -FOUNDATION_EXPORT -void GACAppCheckLog(GACAppCheckMessageCode code, - GACAppCheckLogLevel logLevel, - NSString *_Nonnull message); - -#define GACAppCheckLogFault(MESSAGE_CODE, MESSAGE) \ - GACAppCheckLog(MESSAGE_CODE, GACAppCheckLogLevelFault, MESSAGE); - -#define GACAppCheckLogError(MESSAGE_CODE, MESSAGE) \ - GACAppCheckLog(MESSAGE_CODE, GACAppCheckLogLevelError, MESSAGE); - -#define GACAppCheckLogWarning(MESSAGE_CODE, MESSAGE) \ - GACAppCheckLog(MESSAGE_CODE, GACAppCheckLogLevelWarning, MESSAGE); - -#define GACAppCheckLogInfo(MESSAGE_CODE, MESSAGE) \ - GACAppCheckLog(MESSAGE_CODE, GACAppCheckLogLevelInfo, MESSAGE); - -#define GACAppCheckLogDebug(MESSAGE_CODE, MESSAGE) \ - GACAppCheckLog(MESSAGE_CODE, GACAppCheckLogLevelDebug, MESSAGE); - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/GACAppCheckLogger.m b/AppCheckCore/Sources/Core/GACAppCheckLogger.m deleted file mode 100644 index b3c4b51f..00000000 --- a/AppCheckCore/Sources/Core/GACAppCheckLogger.m +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckLogger.h" - -#import "AppCheckCore/Sources/Core/GACAppCheckLogger+Internal.h" - -NS_ASSUME_NONNULL_BEGIN - -#pragma mark - Public - -@implementation GACAppCheckLogger - -// Note: Declared as volatile to make getting and setting atomic. -static volatile GACAppCheckLogLevel _logLevel; - -+ (void)load { - // Set the default log level (warning). - _logLevel = GACAppCheckLogLevelWarning; -} - -+ (GACAppCheckLogLevel)logLevel { - return _logLevel; -} - -+ (void)setLogLevel:(GACAppCheckLogLevel)logLevel { - _logLevel = logLevel; -} - -@end - -#pragma mark - Helpers - -static NSString *MessageCodeEnumToString(GACAppCheckMessageCode code) { - return [[NSString alloc] initWithFormat:@"I-GAC%06ld", (long)code]; -} - -static NSString *LoggerLevelEnumToString(GACAppCheckLogLevel logLevel) { - switch (logLevel) { - case GACAppCheckLogLevelFault: - return @"Fault"; - case GACAppCheckLogLevelError: - return @"Error"; - case GACAppCheckLogLevelWarning: - return @"Warning"; - case GACAppCheckLogLevelInfo: - return @"Info"; - case GACAppCheckLogLevelDebug: - return @"Debug"; - } -} - -#pragma mark - Logging Functions - -/** - * Generates the logging functions using macros. - * - * Calling GACLogError(@"Firebase", @"I-GAC000001", @"Configure %@ failed.", @"blah") shows: - * yyyy-mm-dd hh:mm:ss.SSS sender[PID] [Firebase/AppCheck][I-GAC000001] Configure blah - * failed. Calling GACLogDebug(@"GoogleSignIn", @"I-GAC000002", @"Configure succeed.") shows: - * yyyy-mm-dd hh:mm:ss.SSS sender[PID] [GoogleSignIn/AppCheck][I-COR000002] Configure - * succeed. - */ -void GACAppCheckLog(GACAppCheckMessageCode code, GACAppCheckLogLevel logLevel, NSString *message) { - // Don't log anything in not debug builds. -#if !NDEBUG - if (logLevel >= GACAppCheckLogger.logLevel) { - NSLog(@"<%@> [AppCheckCore][%@] %@", LoggerLevelEnumToString(logLevel), - MessageCodeEnumToString(code), message); - } -#endif // !NDEBUG -} - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/GACAppCheckSettings.m b/AppCheckCore/Sources/Core/GACAppCheckSettings.m deleted file mode 100644 index 6d17b751..00000000 --- a/AppCheckCore/Sources/Core/GACAppCheckSettings.m +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckSettings.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation GACAppCheckSettings - -@synthesize isTokenAutoRefreshEnabled = _isTokenAutoRefreshEnabled; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/GACAppCheckToken.m b/AppCheckCore/Sources/Core/GACAppCheckToken.m deleted file mode 100644 index c016a3f5..00000000 --- a/AppCheckCore/Sources/Core/GACAppCheckToken.m +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation GACAppCheckToken - -@synthesize token = _token; -@synthesize expirationDate = _expirationDate; -@synthesize receivedAtDate = _receivedAtDate; - -- (instancetype)initWithToken:(NSString *)token - expirationDate:(NSDate *)expirationDate - receivedAtDate:(NSDate *)receivedAtDate { - self = [super init]; - if (self) { - _token = [token copy]; - _expirationDate = expirationDate; - _receivedAtDate = receivedAtDate; - } - return self; -} - -- (instancetype)initWithToken:(NSString *)token expirationDate:(NSDate *)expirationDate { - return [self initWithToken:token expirationDate:expirationDate receivedAtDate:[NSDate date]]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/GACAppCheckTokenResult.m b/AppCheckCore/Sources/Core/GACAppCheckTokenResult.m deleted file mode 100644 index dc6724ee..00000000 --- a/AppCheckCore/Sources/Core/GACAppCheckTokenResult.m +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckTokenResult.h" - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" - -/// Placeholder value that indicates failure. -/// This value is `{"error":"UNKNOWN_ERROR"}` encoded as base64. -static NSString *const kPlaceholderTokenValue = @"eyJlcnJvciI6IlVOS05PV05fRVJST1IifQ=="; - -@implementation GACAppCheckTokenResult - -- (instancetype)initWithToken:(GACAppCheckToken *)token error:(NSError *)error { - if (self = [super init]) { - _token = token; - _error = error; - } - - return self; -} - -- (instancetype)initWithToken:(GACAppCheckToken *)token { - return [self initWithToken:token error:nil]; -} - -- (instancetype)initWithError:(NSError *)error { - return [self initWithToken:[GACAppCheckTokenResult placeholderToken] error:error]; -} - -#pragma mark - Internal - -+ (GACAppCheckToken *)placeholderToken { - return [[GACAppCheckToken alloc] initWithToken:kPlaceholderTokenValue - expirationDate:[NSDate distantPast]]; -} - -@end diff --git a/AppCheckCore/Sources/Core/Storage/AppCheckCoreStorage.swift b/AppCheckCore/Sources/Core/Storage/AppCheckCoreStorage.swift new file mode 100644 index 00000000..f760a8ca --- /dev/null +++ b/AppCheckCore/Sources/Core/Storage/AppCheckCoreStorage.swift @@ -0,0 +1,105 @@ +// 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 Foundation +#if COCOAPODS + import GoogleUtilities +#else + import GoogleUtilities_Environment +#endif + +@objc(GACAppCheckStorageProtocol) +public protocol AppCheckCoreStorageProtocol: NSObjectProtocol { + func setToken(_ token: AppCheckCoreToken?) async throws -> AppCheckCoreToken? + func getToken() async throws -> AppCheckCoreToken? +} + +@objc(GACAppCheckStorage) +@objcMembers +public class AppCheckCoreStorage: NSObject, AppCheckCoreStorageProtocol { + private let kKeychainService = "com.google.app_check_core.token_storage" + + public let tokenKey: String + public let keychainStorage: GULKeychainStorage + public let accessGroup: String? + + public init(tokenKey: String, + keychainStorage: GULKeychainStorage, + accessGroup: String?) { + self.tokenKey = tokenKey + self.keychainStorage = keychainStorage + self.accessGroup = accessGroup + super.init() + } + + public convenience init(tokenKey: String, accessGroup: String?) { + let keychainStorage = GULKeychainStorage(service: "com.google.app_check_core.token_storage") + self.init(tokenKey: tokenKey, keychainStorage: keychainStorage, accessGroup: accessGroup) + } + + public func getToken() async throws -> AppCheckCoreToken? { + return try await withCheckedThrowingContinuation { continuation in + keychainStorage.getObjectForKey( + tokenKey, + objectClass: AppCheckCoreStoredToken.self, + accessGroup: accessGroup + ) { storedToken, error in + if let error = error { + // Wrap keychain error + let wrappedError = AppCheckCoreErrorUtil.keychainError(with: error) + continuation.resume(throwing: wrappedError) + } else if let stored = storedToken as? AppCheckCoreStoredToken { + continuation.resume(returning: stored.appCheckToken()) + } else { + continuation.resume(returning: nil) + } + } + } + } + + public func setToken(_ token: AppCheckCoreToken?) async throws -> AppCheckCoreToken? { + return try await withCheckedThrowingContinuation { continuation in + if let token = token { + let storedToken = AppCheckCoreStoredToken() + storedToken.update(with: token) + keychainStorage + .setObject(storedToken, forKey: tokenKey, accessGroup: accessGroup) { result, error in + if let error = error { + let nsError = error as NSError + if nsError.domain == "com.gul.keychain.ErrorDomain", + let failureReason = nsError.userInfo[NSLocalizedFailureReasonErrorKey] as? String, + failureReason.contains("-25299") { + // Ignore errSecDuplicateItem (-25299) caused by concurrent tests + continuation.resume(returning: token) + } else { + let wrappedError = AppCheckCoreErrorUtil.keychainError(with: error) + continuation.resume(throwing: wrappedError) + } + } else { + continuation.resume(returning: token) + } + } + } else { + keychainStorage.removeObject(forKey: tokenKey, accessGroup: accessGroup) { error in + if let error = error { + let wrappedError = AppCheckCoreErrorUtil.keychainError(with: error) + continuation.resume(throwing: wrappedError) + } else { + continuation.resume(returning: nil) + } + } + } + } + } +} diff --git a/AppCheckCore/Sources/Core/Storage/AppCheckCoreStoredToken.swift b/AppCheckCore/Sources/Core/Storage/AppCheckCoreStoredToken.swift new file mode 100644 index 00000000..7d61a5fb --- /dev/null +++ b/AppCheckCore/Sources/Core/Storage/AppCheckCoreStoredToken.swift @@ -0,0 +1,82 @@ +// 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 Foundation + +@objc(GACAppCheckStoredToken) +@objcMembers +public class AppCheckCoreStoredToken: NSObject, NSSecureCoding { + private static let kTokenKey = "token" + private static let kExpirationDateKey = "expirationDate" + private static let kReceivedAtDateKey = "receivedAtDate" + private static let kStorageVersionKey = "storageVersion" + + private static let kStorageVersion: Int = 2 + + public var token: String? + public var expirationDate: Date? + public var receivedAtDate: Date? + + public var storageVersion: Int { + return Self.kStorageVersion + } + + public static var supportsSecureCoding: Bool { + return true + } + + override public init() { + super.init() + } + + public func encode(with coder: NSCoder) { + coder.encode(token, forKey: Self.kTokenKey) + coder.encode(expirationDate, forKey: Self.kExpirationDateKey) + coder.encode(receivedAtDate, forKey: Self.kReceivedAtDateKey) + coder.encode(storageVersion, forKey: Self.kStorageVersionKey) + } + + public required init?(coder: NSCoder) { + super.init() + let decodedStorageVersion = coder.decodeInteger(forKey: Self.kStorageVersionKey) + if decodedStorageVersion > Self.kStorageVersion { + // TODO: Log a message. + } + + token = coder.decodeObject(of: NSString.self, forKey: Self.kTokenKey) as String? + expirationDate = coder.decodeObject(of: NSDate.self, forKey: Self.kExpirationDateKey) as Date? + receivedAtDate = coder.decodeObject(of: NSDate.self, forKey: Self.kReceivedAtDateKey) as Date? + } +} + +public extension AppCheckCoreStoredToken { + @objc func update(with token: AppCheckCoreToken) { + self.token = token.token + expirationDate = token.expirationDate + receivedAtDate = token.receivedAtDate + } + + @objc func appCheckToken() -> AppCheckCoreToken? { + guard let token = token, + let expirationDate = expirationDate, + let receivedAtDate = receivedAtDate else { + return nil + } + return AppCheckCoreToken( + token: token, + expirationDate: expirationDate, + receivedAt: receivedAtDate + ) + } +} diff --git a/AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.h b/AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.h deleted file mode 100644 index e122c785..00000000 --- a/AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.h +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright 2020 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 - -@class GACAppCheckToken; -@class FBLPromise; -@protocol GACKeychainStorageProtocol; - -NS_ASSUME_NONNULL_BEGIN - -@protocol GACAppCheckStorageProtocol - -/** Manages storage of the FAA token. - * @param token A token object to store or `nil` to remove existing token. - * @return A promise that is resolved with the stored object in the case of success or is rejected - * with a specific error otherwise. - */ -- (FBLPromise *)setToken:(nullable GACAppCheckToken *)token; - -/** Reads a stored FAA token. - * @return A promise that is resolved with a stored token or `nil` if there is not a stored token. - * The promise is rejected with an error in the case of a failure. - */ -- (FBLPromise *)getToken; - -@end - -/// The class provides an implementation of persistent storage to store data like FAA token, etc. -@interface GACAppCheckStorage : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -/** Default convenience initializer. - * @param tokenKey The key to store the token for the storage instance. - * @param accessGroup The Keychain Access Group. - */ -- (instancetype)initWithTokenKey:(NSString *)tokenKey accessGroup:(nullable NSString *)accessGroup; - -/** Designated initializer. - * @param tokenKey The key to store the token for the storage instance. - * @param keychainStorage An instance of `GACKeychainStorageProtocol` used as an underlying secure - * storage. - * @param accessGroup The Keychain Access Group. - */ -- (instancetype)initWithTokenKey:(NSString *)tokenKey - keychainStorage:(id)keychainStorage - accessGroup:(nullable NSString *)accessGroup NS_DESIGNATED_INITIALIZER; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.m b/AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.m deleted file mode 100644 index 8af9bdbb..00000000 --- a/AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.m +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import - -#import "AppCheckCore/Sources/Core/Storage/GACKeychainStorageProtocol.h" - -#import "AppCheckCore/Sources/Core/Storage/GULKeychainStorage+GACKeychainStorageProtocol.h" - -#import "AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken+GACAppCheckToken.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -NS_ASSUME_NONNULL_BEGIN - -static NSString *const kKeychainService = @"com.google.app_check_core.token_storage"; - -@interface GACAppCheckStorage () - -@property(nonatomic, readonly) NSString *tokenKey; -@property(nonatomic, readonly) id keychainStorage; -@property(nonatomic, readonly, nullable) NSString *accessGroup; - -@end - -@implementation GACAppCheckStorage - -- (instancetype)initWithTokenKey:(NSString *)tokenKey - keychainStorage:(id)keychainStorage - accessGroup:(nullable NSString *)accessGroup { - self = [super init]; - if (self) { - _tokenKey = [tokenKey copy]; - _keychainStorage = keychainStorage; - _accessGroup = [accessGroup copy]; - } - return self; -} - -- (instancetype)initWithTokenKey:(NSString *)tokenKey accessGroup:(nullable NSString *)accessGroup { - GULKeychainStorage *keychainStorage = - [[GULKeychainStorage alloc] initWithService:kKeychainService]; - return [self initWithTokenKey:tokenKey keychainStorage:keychainStorage accessGroup:accessGroup]; -} - -- (FBLPromise *)getToken { - return [FBLPromise - wrapObjectOrErrorCompletion:^(FBLPromiseObjectOrErrorCompletion _Nonnull handler) { - [self.keychainStorage getObjectForKey:[self tokenKey] - objectClass:[GACAppCheckStoredToken class] - accessGroup:self.accessGroup - completionHandler:handler]; - }] - .then(^GACAppCheckToken *(id storedToken) { - if ([(NSObject *)storedToken isKindOfClass:[GACAppCheckStoredToken class]]) { - return [(GACAppCheckStoredToken *)storedToken appCheckToken]; - } else { - return nil; - } - }) - .recover(^NSError *(NSError *error) { - return [_GACAppCheckErrorUtil keychainErrorWithError:error]; - }); -} - -- (FBLPromise *)setToken:(nullable GACAppCheckToken *)token { - if (token) { - return [self storeToken:token].recover(^NSError *(NSError *error) { - return [_GACAppCheckErrorUtil keychainErrorWithError:error]; - }); - } else { - return [FBLPromise wrapErrorCompletion:^(FBLPromiseErrorCompletion _Nonnull handler) { - [self.keychainStorage removeObjectForKey:[self tokenKey] - accessGroup:self.accessGroup - completionHandler:handler]; - }] - .then(^id _Nullable(id _Nullable __unused _) { - return token; - }) - .recover(^NSError *(NSError *error) { - return [_GACAppCheckErrorUtil keychainErrorWithError:error]; - }); - } -} - -#pragma mark - Helpers - -- (FBLPromise *)storeToken:(nullable GACAppCheckToken *)token { - GACAppCheckStoredToken *storedToken = [[GACAppCheckStoredToken alloc] init]; - [storedToken updateWithToken:token]; - return - [FBLPromise wrapObjectOrErrorCompletion:^( - FBLPromiseObjectOrErrorCompletion _Nonnull handler) { - [self.keychainStorage setObject:storedToken - forKey:[self tokenKey] - accessGroup:self.accessGroup - completionHandler:handler]; - }].then(^id _Nullable(id _Nullable value) { - return token; - }); -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken+GACAppCheckToken.h b/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken+GACAppCheckToken.h deleted file mode 100644 index a0bb67d2..00000000 --- a/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken+GACAppCheckToken.h +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.h" - -@class GACAppCheckToken; - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckStoredToken (GACAppCheckToken) - -- (void)updateWithToken:(GACAppCheckToken *)token; - -- (GACAppCheckToken *)appCheckToken; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken+GACAppCheckToken.m b/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken+GACAppCheckToken.m deleted file mode 100644 index af8c0a05..00000000 --- a/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken+GACAppCheckToken.m +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken+GACAppCheckToken.h" - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" - -@implementation GACAppCheckStoredToken (GACAppCheckToken) - -- (void)updateWithToken:(GACAppCheckToken *)token { - self.token = token.token; - self.expirationDate = token.expirationDate; - self.receivedAtDate = token.receivedAtDate; -} - -- (GACAppCheckToken *)appCheckToken { - return [[GACAppCheckToken alloc] initWithToken:self.token - expirationDate:self.expirationDate - receivedAtDate:self.receivedAtDate]; -} - -@end diff --git a/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.h b/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.h deleted file mode 100644 index ba17a8a4..00000000 --- a/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.h +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2020 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 - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckStoredToken : NSObject - -/// The Firebase App Check token. -@property(nonatomic, copy, nullable) NSString *token; - -/// The Firebase App Check token expiration date in the device local time. -@property(nonatomic, strong, nullable) NSDate *expirationDate; - -/// The date when the Firebase App Check token was received in the device's local time. -@property(nonatomic, strong, nullable) NSDate *receivedAtDate; - -/// The version of local storage. -@property(nonatomic, readonly) NSInteger storageVersion; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.m b/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.m deleted file mode 100644 index e79a1251..00000000 --- a/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.m +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.h" - -static NSString *const kTokenKey = @"token"; -static NSString *const kExpirationDateKey = @"expirationDate"; -static NSString *const kReceivedAtDateKey = @"receivedAtDate"; -static NSString *const kStorageVersionKey = @"storageVersion"; - -static const NSInteger kStorageVersion = 2; - -NS_ASSUME_NONNULL_BEGIN - -@implementation GACAppCheckStoredToken - -- (NSInteger)storageVersion { - return kStorageVersion; -} - -+ (BOOL)supportsSecureCoding { - return YES; -} - -- (void)encodeWithCoder:(NSCoder *)coder { - [coder encodeObject:self.token forKey:kTokenKey]; - [coder encodeObject:self.expirationDate forKey:kExpirationDateKey]; - [coder encodeObject:self.receivedAtDate forKey:kReceivedAtDateKey]; - [coder encodeInteger:self.storageVersion forKey:kStorageVersionKey]; -} - -- (nullable instancetype)initWithCoder:(NSCoder *)coder { - self = [super init]; - if (self) { - NSInteger decodedStorageVersion = [coder decodeIntegerForKey:kStorageVersionKey]; - if (decodedStorageVersion > kStorageVersion) { - // TODO: Log a message. - } - - _token = [coder decodeObjectOfClass:[NSString class] forKey:kTokenKey]; - _expirationDate = [coder decodeObjectOfClass:[NSDate class] forKey:kExpirationDateKey]; - _receivedAtDate = [coder decodeObjectOfClass:[NSDate class] forKey:kReceivedAtDateKey]; - } - return self; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/Storage/GACKeychainStorageProtocol.h b/AppCheckCore/Sources/Core/Storage/GACKeychainStorageProtocol.h deleted file mode 100644 index f5baeb32..00000000 --- a/AppCheckCore/Sources/Core/Storage/GACKeychainStorageProtocol.h +++ /dev/null @@ -1,39 +0,0 @@ -/* - * 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 - -NS_ASSUME_NONNULL_BEGIN - -@protocol GACKeychainStorageProtocol - -- (void)getObjectForKey:(NSString *)key - objectClass:(Class)objectClass - accessGroup:(nullable NSString *)accessGroup - completionHandler:(void (^)(id _Nullable, NSError *_Nullable))handler; - -- (void)setObject:(id)object - forKey:(NSString *)key - accessGroup:(nullable NSString *)accessGroup - completionHandler:(void (^)(id _Nullable, NSError *_Nullable))handler; - -- (void)removeObjectForKey:(NSString *)key - accessGroup:(nullable NSString *)accessGroup - completionHandler:(void (^)(NSError *_Nullable))handler; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/Storage/GULKeychainStorage+GACKeychainStorageProtocol.h b/AppCheckCore/Sources/Core/Storage/GULKeychainStorage+GACKeychainStorageProtocol.h deleted file mode 100644 index 819b34b6..00000000 --- a/AppCheckCore/Sources/Core/Storage/GULKeychainStorage+GACKeychainStorageProtocol.h +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Core/Storage/GACKeychainStorageProtocol.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GULKeychainStorage (GACKeychainStorageProtocol) -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/Storage/GULKeychainStorage+GACKeychainStorageProtocol.m b/AppCheckCore/Sources/Core/Storage/GULKeychainStorage+GACKeychainStorageProtocol.m deleted file mode 100644 index f52a0dba..00000000 --- a/AppCheckCore/Sources/Core/Storage/GULKeychainStorage+GACKeychainStorageProtocol.m +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Core/Storage/GULKeychainStorage+GACKeychainStorageProtocol.h" - -@implementation GULKeychainStorage (GACKeychainStorageProtocol) -@end diff --git a/AppCheckCore/Sources/Core/TokenRefresh/AppCheckCoreTimer.swift b/AppCheckCore/Sources/Core/TokenRefresh/AppCheckCoreTimer.swift new file mode 100644 index 00000000..be43d271 --- /dev/null +++ b/AppCheckCore/Sources/Core/TokenRefresh/AppCheckCoreTimer.swift @@ -0,0 +1,64 @@ +// 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 Foundation + +@objc(GACAppCheckTimerProtocol) +public protocol AppCheckCoreTimerProtocol: NSObjectProtocol { + func invalidate() +} + +public typealias AppCheckCoreTimerProvider = (Date, DispatchQueue, @escaping () -> Void) + -> AppCheckCoreTimerProtocol? + +@objc(GACAppCheckTimer) +@objcMembers +public class AppCheckCoreTimer: NSObject, AppCheckCoreTimerProtocol { + private var timer: DispatchSourceTimer? + + public static func timerProvider() -> AppCheckCoreTimerProvider { + return { fireDate, queue, handler in + AppCheckCoreTimer(fireDate: fireDate, dispatchQueue: queue, block: handler) + } + } + + public init?(fireDate: Date, dispatchQueue: DispatchQueue, block: @escaping () -> Void) { + let timeInterval = fireDate.timeIntervalSinceNow + // Negative or zero time interval should fire immediately, but this timer class + // expects positive intervals or handles immediate via `dispatch_async` in the caller. + + let timer = DispatchSource.makeTimerSource(queue: dispatchQueue) + + if timeInterval <= 0 { + timer.schedule(deadline: .now()) + } else { + timer.schedule(deadline: .now() + timeInterval) + } + + timer.setEventHandler(handler: block) + timer.resume() + + self.timer = timer + super.init() + } + + public func invalidate() { + timer?.cancel() + timer = nil + } + + deinit { + invalidate() + } +} diff --git a/AppCheckCore/Sources/Core/TokenRefresh/AppCheckCoreTokenRefreshResult.swift b/AppCheckCore/Sources/Core/TokenRefresh/AppCheckCoreTokenRefreshResult.swift new file mode 100644 index 00000000..960342f9 --- /dev/null +++ b/AppCheckCore/Sources/Core/TokenRefresh/AppCheckCoreTokenRefreshResult.swift @@ -0,0 +1,56 @@ +// 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 Foundation + +@objc(GACAppCheckTokenRefreshStatus) +public enum AppCheckCoreTokenRefreshStatus: Int { + case never = 0 + case success = 1 + case failure = 2 +} + +@objc(GACAppCheckTokenRefreshResult) +@objcMembers +public class AppCheckCoreTokenRefreshResult: NSObject { + public let status: AppCheckCoreTokenRefreshStatus + public let tokenExpirationDate: Date? + public let tokenReceivedAtDate: Date? + + public init(status: AppCheckCoreTokenRefreshStatus, + expirationDate tokenExpirationDate: Date?, + receivedAtDate tokenReceivedAtDate: Date?) { + self.status = status + self.tokenExpirationDate = tokenExpirationDate + self.tokenReceivedAtDate = tokenReceivedAtDate + super.init() + } + + public convenience init(statusNever: ()) { + self.init(status: .never, expirationDate: nil, receivedAtDate: nil) + } + + public convenience init(statusFailure: ()) { + self.init(status: .failure, expirationDate: nil, receivedAtDate: nil) + } + + public convenience init(statusSuccessAndExpirationDate tokenExpirationDate: Date, + receivedAtDate tokenReceivedAtDate: Date) { + self.init( + status: .success, + expirationDate: tokenExpirationDate, + receivedAtDate: tokenReceivedAtDate + ) + } +} diff --git a/AppCheckCore/Sources/Core/TokenRefresh/AppCheckCoreTokenRefresher.swift b/AppCheckCore/Sources/Core/TokenRefresh/AppCheckCoreTokenRefresher.swift new file mode 100644 index 00000000..bb649425 --- /dev/null +++ b/AppCheckCore/Sources/Core/TokenRefresh/AppCheckCoreTokenRefresher.swift @@ -0,0 +1,195 @@ +// 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 Foundation + +public typealias AppCheckCoreTokenRefreshCompletion = (AppCheckCoreTokenRefreshResult) -> Void +public typealias AppCheckCoreTokenRefreshBlock = (@escaping AppCheckCoreTokenRefreshCompletion) + -> Void + +@objc(GACAppCheckTokenRefresherProtocol) +public protocol AppCheckCoreTokenRefresherProtocol: NSObjectProtocol { + @objc var tokenRefreshHandler: AppCheckCoreTokenRefreshBlock? { get set } + @objc func updateWithRefreshResult(_ refreshResult: AppCheckCoreTokenRefreshResult) +} + +@objc(GACAppCheckTokenRefresher) +@objcMembers +public class AppCheckCoreTokenRefresher: NSObject, AppCheckCoreTokenRefresherProtocol { + private static let kInitialBackoffTimeInterval: TimeInterval = 30 + private static let kMaximumBackoffTimeInterval: TimeInterval = 16 * 60 + private let kMinimumAutoRefreshTimeInterval: TimeInterval = 60 // 1 min. + private let kAutoRefreshFraction: Double = 0.5 + + private let refreshQueue = DispatchQueue(label: "com.firebase.AppCheckCoreTokenRefresher") + private let timerProvider: AppCheckCoreTimerProvider + private let settings: AppCheckCoreSettingsProtocol + + private var timer: AppCheckCoreTimerProtocol? + private var retryCount: Int = 0 + private var initialRefreshResult: AppCheckCoreTokenRefreshResult? + private var _tokenRefreshHandler: AppCheckCoreTokenRefreshBlock? + + private let lock = NSRecursiveLock() + + public init(refreshResult: AppCheckCoreTokenRefreshResult, + timerProvider: @escaping AppCheckCoreTimerProvider, + settings: AppCheckCoreSettingsProtocol) { + initialRefreshResult = refreshResult + self.timerProvider = timerProvider + self.settings = settings + super.init() + } + + public convenience init(refreshResult: AppCheckCoreTokenRefreshResult, + settings: AppCheckCoreSettingsProtocol) { + self.init(refreshResult: refreshResult, + timerProvider: AppCheckCoreTimer.timerProvider(), + settings: settings) + } + + deinit { + cancelTimer() + } + + public var tokenRefreshHandler: AppCheckCoreTokenRefreshBlock? { + get { + lock.lock() + defer { lock.unlock() } + return _tokenRefreshHandler + } + set { + lock.lock() + defer { lock.unlock() } + _tokenRefreshHandler = newValue + + if newValue != nil, let initialResult = initialRefreshResult { + initialRefreshResult = nil + schedule(with: initialResult) + } + } + } + + @objc(updateWithRefreshResult:) + public func updateWithRefreshResult(_ refreshResult: AppCheckCoreTokenRefreshResult) { + lock.lock() + defer { lock.unlock() } + + switch refreshResult.status { + case .never, .success: + retryCount = 0 + case .failure: + retryCount += 1 + @unknown default: + break + } + + schedule(with: refreshResult) + } + + private func refresh() { + guard let handler = tokenRefreshHandler, settings.isTokenAutoRefreshEnabled else { + return + } + + handler { [weak self] refreshResult in + self?.updateWithRefreshResult(refreshResult) + } + } + + private func schedule(with refreshResult: AppCheckCoreTokenRefreshResult) { + if settings.isTokenAutoRefreshEnabled { + let refreshDate = nextRefreshDate(with: refreshResult) + scheduleRefresh(at: refreshDate) + } + } + + private func scheduleRefresh(at refreshDate: Date) { + lock.lock() + defer { lock.unlock() } + + cancelTimer() + + let scheduleInSec = refreshDate.timeIntervalSinceNow + + if scheduleInSec <= 0 { + refreshQueue.async { [weak self] in + self?.refresh() + } + return + } + + timer = timerProvider(refreshDate, refreshQueue) { [weak self] in + self?.refresh() + } + } + + private func cancelTimer() { + lock.lock() + defer { lock.unlock() } + + timer?.invalidate() + timer = nil + } + + private func nextRefreshDate(with refreshResult: AppCheckCoreTokenRefreshResult) -> Date { + switch refreshResult.status { + case .success: + guard let expirationDate = refreshResult.tokenExpirationDate, + let receivedAtDate = refreshResult.tokenReceivedAtDate else { + return Date() + } + + var timeToLive = expirationDate.timeIntervalSince(receivedAtDate) + timeToLive = max(timeToLive, 0) + + let targetRefreshSinceReceivedDate = timeToLive * kAutoRefreshFraction + 5 * 60 + let targetRefreshDate = receivedAtDate.addingTimeInterval(targetRefreshSinceReceivedDate) + + var refreshDate = min(targetRefreshDate, expirationDate) + + if refreshDate.timeIntervalSinceNow < kMinimumAutoRefreshTimeInterval { + refreshDate = Date(timeIntervalSinceNow: kMinimumAutoRefreshTimeInterval) + } + return refreshDate + + case .failure: + let backoffTime = AppCheckCoreTokenRefresher.backoffTime(forRetryCount: retryCount) + return Date(timeIntervalSinceNow: backoffTime) + + case .never: + return Date() + + @unknown default: + return Date() + } + } + + private static func backoffTime(forRetryCount retryCount: Int) -> TimeInterval { + if retryCount == 0 { + return 0 + } + + let exponentialInterval = AppCheckCoreTokenRefresher.kInitialBackoffTimeInterval * pow( + 2.0, + Double(retryCount - 1) + ) + randomMilliseconds() + return min(exponentialInterval, AppCheckCoreTokenRefresher.kMaximumBackoffTimeInterval) + } + + private static func randomMilliseconds() -> TimeInterval { + let random_millis = abs(Int32.random(in: 0 ... 999)) + return Double(random_millis) * 0.001 + } +} diff --git a/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTimer.h b/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTimer.h deleted file mode 100644 index a6302a8a..00000000 --- a/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTimer.h +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2021 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 - -NS_ASSUME_NONNULL_BEGIN - -@protocol GACAppCheckTimerProtocol - -- (void)invalidate; - -@end - -typedef id _Nullable (^GACTimerProvider)(NSDate *fireDate, - dispatch_queue_t queue, - dispatch_block_t handler); - -@interface GACAppCheckTimer : NSObject - -+ (GACTimerProvider)timerProvider; - -- (nullable instancetype)initWithFireDate:(NSDate *)date - dispatchQueue:(dispatch_queue_t)dispatchQueue - block:(dispatch_block_t)block; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTimer.m b/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTimer.m deleted file mode 100644 index 8c2b0cf3..00000000 --- a/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTimer.m +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTimer.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckTimer () -@property(nonatomic, readonly) dispatch_queue_t dispatchQueue; -@property(atomic, readonly) dispatch_source_t timer; -@end - -@implementation GACAppCheckTimer - -+ (GACTimerProvider)timerProvider { - return ^id _Nullable(NSDate *fireDate, dispatch_queue_t queue, - dispatch_block_t handler) { - return [[GACAppCheckTimer alloc] initWithFireDate:fireDate dispatchQueue:queue block:handler]; - }; -} - -+ (nullable instancetype)timerFireDate:(NSDate *)fireDate - dispatchQueue:(dispatch_queue_t)dispatchQueue - block:(dispatch_block_t)block { - return [[GACAppCheckTimer alloc] initWithFireDate:fireDate - dispatchQueue:dispatchQueue - block:block]; -} - -- (nullable instancetype)initWithFireDate:(NSDate *)date - dispatchQueue:(dispatch_queue_t)dispatchQueue - block:(dispatch_block_t)block { - self = [super init]; - if (self == nil) { - return nil; - } - - if (block == nil) { - return nil; - } - - NSTimeInterval scheduleInSec = [date timeIntervalSinceNow]; - if (scheduleInSec <= 0) { - return nil; - } - - dispatch_time_t startTime = dispatch_time(DISPATCH_TIME_NOW, scheduleInSec * NSEC_PER_SEC); - _timer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, self.dispatchQueue); - dispatch_source_set_timer(_timer, startTime, UINT64_MAX * NSEC_PER_SEC, 0); - - __auto_type __weak weakSelf = self; - dispatch_source_set_event_handler(_timer, ^{ - __auto_type strongSelf = weakSelf; - - // The initializer returns a one-off timer, so we need to invalidate the dispatch timer to - // prevent firing again. - [strongSelf invalidate]; - block(); - }); - - dispatch_resume(_timer); - - return self; -} - -- (void)dealloc { - [self invalidate]; -} - -- (void)invalidate { - if (self.timer != nil) { - dispatch_source_cancel(self.timer); - } -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefreshResult.h b/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefreshResult.h deleted file mode 100644 index 6cfc875f..00000000 --- a/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefreshResult.h +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2021 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 - -NS_ASSUME_NONNULL_BEGIN - -/// Represents possible results of a Firebase App Check token refresh attempt that matter for -/// `GACAppCheckTokenRefresher`. -typedef NS_ENUM(NSInteger, GACAppCheckTokenRefreshStatus) { - // The token has not been refreshed. - GACAppCheckTokenRefreshStatusNever, - - // The token was successfully refreshed. - GACAppCheckTokenRefreshStatusSuccess, - - // The token refresh failed. - GACAppCheckTokenRefreshStatusFailure -}; - -/// An object to pass the possible results of a Firebase App Check token refresh attempt and -/// supplementary data. -@interface GACAppCheckTokenRefreshResult : NSObject - -/// Status of the refresh. -@property(nonatomic, readonly) GACAppCheckTokenRefreshStatus status; - -/// A date when the new Firebase App Check token is expiring. -@property(nonatomic, readonly, nullable) NSDate *tokenExpirationDate; - -/// A date when the new Firebase App Check token was received from the server. -@property(nonatomic, readonly, nullable) NSDate *tokenReceivedAtDate; - -- (instancetype)init NS_UNAVAILABLE; - -/// Initializes the instance with `GACAppCheckTokenRefreshStatusNever`. -- (instancetype)initWithStatusNever; - -/// Initializes the instance with `GACAppCheckTokenRefreshStatusFailure`. -- (instancetype)initWithStatusFailure; - -/// Initializes the instance with `GACAppCheckTokenRefreshStatusSuccess`. -/// @param tokenExpirationDate See `tokenExpirationDate` property. -/// @param tokenReceivedAtDate See `tokenReceivedAtDate` property. -- (instancetype)initWithStatusSuccessAndExpirationDate:(NSDate *)tokenExpirationDate - receivedAtDate:(NSDate *)tokenReceivedAtDate; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefreshResult.m b/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefreshResult.m deleted file mode 100644 index 33ad19e5..00000000 --- a/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefreshResult.m +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefreshResult.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckTokenRefreshResult () - -- (instancetype)initWithStatus:(GACAppCheckTokenRefreshStatus)status - expirationDate:(nullable NSDate *)tokenExpirationDate - receivedAtDate:(nullable NSDate *)tokenReceivedAtDate NS_DESIGNATED_INITIALIZER; - -@end - -@implementation GACAppCheckTokenRefreshResult - -- (instancetype)initWithStatus:(GACAppCheckTokenRefreshStatus)status - expirationDate:(nullable NSDate *)tokenExpirationDate - receivedAtDate:(nullable NSDate *)tokenReceivedAtDate { - self = [super init]; - if (self) { - _status = status; - _tokenExpirationDate = tokenExpirationDate; - _tokenReceivedAtDate = tokenReceivedAtDate; - } - return self; -} - -- (instancetype)initWithStatusNever { - return [self initWithStatus:GACAppCheckTokenRefreshStatusNever - expirationDate:nil - receivedAtDate:nil]; -} - -- (instancetype)initWithStatusFailure { - return [self initWithStatus:GACAppCheckTokenRefreshStatusFailure - expirationDate:nil - receivedAtDate:nil]; -} - -- (instancetype)initWithStatusSuccessAndExpirationDate:(NSDate *)tokenExpirationDate - receivedAtDate:(NSDate *)tokenReceivedAtDate { - return [self initWithStatus:GACAppCheckTokenRefreshStatusSuccess - expirationDate:tokenExpirationDate - receivedAtDate:tokenReceivedAtDate]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefresher.h b/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefresher.h deleted file mode 100644 index dc6bf219..00000000 --- a/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefresher.h +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTimer.h" - -@protocol GACAppCheckSettingsProtocol; -@class GACAppCheckTokenRefreshResult; - -NS_ASSUME_NONNULL_BEGIN - -/** The block to be called on the token refresh completion. - * @param refreshResult The refresh result. - */ -typedef void (^GACAppCheckTokenRefreshCompletion)(GACAppCheckTokenRefreshResult *refreshResult); - -/** The block that will be called by `GACAppCheckTokenRefresher` to trigger the token refresh. - * @param completion The block that the client must call when the token refresh was completed. - */ -typedef void (^GACAppCheckTokenRefreshBlock)(GACAppCheckTokenRefreshCompletion completion); - -@protocol GACAppCheckTokenRefresherProtocol - -/// The block to be called when refresh is needed. The client is responsible for actual token -/// refresh in the block. -@property(nonatomic, copy) GACAppCheckTokenRefreshBlock tokenRefreshHandler; - -/// Updates the next refresh date based on the new token expiration date. This method should be -/// called when the token update was initiated not by the refresher. -/// @param refreshResult A result of a refresh attempt. -- (void)updateWithRefreshResult:(GACAppCheckTokenRefreshResult *)refreshResult; - -@end - -/// The class calls `tokenRefreshHandler` periodically to keep FAC token fresh to reduce FAC token -/// exchange overhead for product requests. -@interface GACAppCheckTokenRefresher : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -/// The designated initializer. -/// @param refreshResult A previous token refresh attempt result. -/// @param settings An object that handles Firebase app check settings. -- (instancetype)initWithRefreshResult:(GACAppCheckTokenRefreshResult *)refreshResult - timerProvider:(GACTimerProvider)timerProvider - settings:(id)settings - NS_DESIGNATED_INITIALIZER; - -/// A convenience initializer with a timer provider returning an instance of `GACAppCheckTimer`. -- (instancetype)initWithRefreshResult:(GACAppCheckTokenRefreshResult *)refreshResult - settings:(id)settings; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefresher.m b/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefresher.m deleted file mode 100644 index 084d93eb..00000000 --- a/AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefresher.m +++ /dev/null @@ -1,216 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefresher.h" - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckSettings.h" - -#import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTimer.h" -#import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefreshResult.h" - -NS_ASSUME_NONNULL_BEGIN - -static const NSTimeInterval kInitialBackoffTimeInterval = 30; -static const NSTimeInterval kMaximumBackoffTimeInterval = 16 * 60; - -static const NSTimeInterval kMinimumAutoRefreshTimeInterval = 60; // 1 min. - -/// How much time in advance to auto-refresh token before it's expiration. E.g. 0.5 means that the -/// token will be refreshed half way through it's intended time to live. -static const double kAutoRefreshFraction = 0.5; - -@interface GACAppCheckTokenRefresher () - -@property(nonatomic, readonly) dispatch_queue_t refreshQueue; - -@property(nonatomic, readonly) id settings; - -@property(nonatomic, readonly) GACTimerProvider timerProvider; -@property(atomic, nullable) id timer; -@property(atomic) NSUInteger retryCount; - -/// Initial refresh result to be used when `tokenRefreshHandler` has been sent. -@property(nonatomic, nullable) GACAppCheckTokenRefreshResult *initialRefreshResult; - -@end - -@implementation GACAppCheckTokenRefresher - -@synthesize tokenRefreshHandler = _tokenRefreshHandler; - -- (instancetype)initWithRefreshResult:(GACAppCheckTokenRefreshResult *)refreshResult - timerProvider:(GACTimerProvider)timerProvider - settings:(id)settings { - self = [super init]; - if (self) { - _refreshQueue = - dispatch_queue_create("com.firebase.GACAppCheckTokenRefresher", DISPATCH_QUEUE_SERIAL); - _initialRefreshResult = refreshResult; - _timerProvider = timerProvider; - _settings = settings; - } - return self; -} - -- (instancetype)initWithRefreshResult:(GACAppCheckTokenRefreshResult *)refreshResult - settings:(id)settings { - return [self initWithRefreshResult:refreshResult - timerProvider:[GACAppCheckTimer timerProvider] - settings:settings]; -} - -- (void)dealloc { - [self cancelTimer]; -} - -- (void)setTokenRefreshHandler:(GACAppCheckTokenRefreshBlock)tokenRefreshHandler { - @synchronized(self) { - _tokenRefreshHandler = tokenRefreshHandler; - - // Check if handler is being set for the first time and if yes then schedule first refresh. - if (tokenRefreshHandler && self.initialRefreshResult) { - GACAppCheckTokenRefreshResult *initialTokenRefreshResult = self.initialRefreshResult; - self.initialRefreshResult = nil; - [self scheduleWithTokenRefreshResult:initialTokenRefreshResult]; - } - } -} - -- (GACAppCheckTokenRefreshBlock)tokenRefreshHandler { - @synchronized(self) { - return _tokenRefreshHandler; - } -} - -- (void)updateWithRefreshResult:(GACAppCheckTokenRefreshResult *)refreshResult { - switch (refreshResult.status) { - case GACAppCheckTokenRefreshStatusNever: - case GACAppCheckTokenRefreshStatusSuccess: - self.retryCount = 0; - break; - - case GACAppCheckTokenRefreshStatusFailure: - self.retryCount += 1; - break; - } - - [self scheduleWithTokenRefreshResult:refreshResult]; -} - -- (void)refresh { - if (self.tokenRefreshHandler == nil) { - return; - } - - if (!self.settings.isTokenAutoRefreshEnabled) { - return; - } - - __auto_type __weak weakSelf = self; - self.tokenRefreshHandler(^(GACAppCheckTokenRefreshResult *refreshResult) { - __auto_type strongSelf = weakSelf; - [strongSelf updateWithRefreshResult:refreshResult]; - }); -} - -- (void)scheduleWithTokenRefreshResult:(GACAppCheckTokenRefreshResult *)refreshResult { - // Schedule the refresh only when allowed. - if (self.settings.isTokenAutoRefreshEnabled) { - NSDate *refreshDate = [self nextRefreshDateWithTokenRefreshResult:refreshResult]; - [self scheduleRefreshAtDate:refreshDate]; - } -} - -- (void)scheduleRefreshAtDate:(NSDate *)refreshDate { - [self cancelTimer]; - - NSTimeInterval scheduleInSec = [refreshDate timeIntervalSinceNow]; - - __auto_type __weak weakSelf = self; - dispatch_block_t refreshHandler = ^{ - __auto_type strongSelf = weakSelf; - [strongSelf refresh]; - }; - - // Refresh straight away if the refresh time is too close. - if (scheduleInSec <= 0) { - dispatch_async(self.refreshQueue, refreshHandler); - return; - } - - self.timer = self.timerProvider(refreshDate, self.refreshQueue, refreshHandler); -} - -- (void)cancelTimer { - [self.timer invalidate]; -} - -- (NSDate *)nextRefreshDateWithTokenRefreshResult:(GACAppCheckTokenRefreshResult *)refreshResult { - switch (refreshResult.status) { - case GACAppCheckTokenRefreshStatusSuccess: { - NSTimeInterval timeToLive = [refreshResult.tokenExpirationDate - timeIntervalSinceDate:refreshResult.tokenReceivedAtDate]; - timeToLive = MAX(timeToLive, 0); - - // Refresh in 50% of TTL + 5 min. - NSTimeInterval targetRefreshSinceReceivedDate = timeToLive * kAutoRefreshFraction + 5 * 60; - NSDate *targetRefreshDate = [refreshResult.tokenReceivedAtDate - dateByAddingTimeInterval:targetRefreshSinceReceivedDate]; - - // Don't schedule later than expiration date. - NSDate *refreshDate = [targetRefreshDate earlierDate:refreshResult.tokenExpirationDate]; - - // Don't schedule a refresh earlier than in 1 min from now. - if ([refreshDate timeIntervalSinceNow] < kMinimumAutoRefreshTimeInterval) { - refreshDate = [NSDate dateWithTimeIntervalSinceNow:kMinimumAutoRefreshTimeInterval]; - } - return refreshDate; - } break; - - case GACAppCheckTokenRefreshStatusFailure: { - // Repeat refresh attempt later. - NSTimeInterval backoffTime = [[self class] backoffTimeForRetryCount:self.retryCount]; - return [NSDate dateWithTimeIntervalSinceNow:backoffTime]; - } break; - - case GACAppCheckTokenRefreshStatusNever: - // Refresh ASAP. - return [NSDate date]; - break; - } -} - -#pragma mark - Backoff - -+ (NSTimeInterval)backoffTimeForRetryCount:(NSInteger)retryCount { - if (retryCount == 0) { - // No backoff for the first attempt. - return 0; - } - - NSTimeInterval exponentialInterval = - kInitialBackoffTimeInterval * pow(2, retryCount - 1) + [self randomMilliseconds]; - return MIN(exponentialInterval, kMaximumBackoffTimeInterval); -} - -+ (NSTimeInterval)randomMilliseconds { - int32_t random_millis = ABS(arc4random() % 1000); - return (double)random_millis * 0.001; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/Utils/AppCheckCoreCryptoUtils.swift b/AppCheckCore/Sources/Core/Utils/AppCheckCoreCryptoUtils.swift new file mode 100644 index 00000000..6bd4965d --- /dev/null +++ b/AppCheckCore/Sources/Core/Utils/AppCheckCoreCryptoUtils.swift @@ -0,0 +1,29 @@ +// 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 CommonCrypto +import Foundation + +@objc(GACAppCheckCryptoUtils) +@objcMembers +public class AppCheckCoreCryptoUtils: NSObject { + @objc(sha256HashFromData:) + public static func sha256Hash(from dataToHash: Data) -> Data { + var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH)) + dataToHash.withUnsafeBytes { buffer in + _ = CC_SHA256(buffer.baseAddress, CC_LONG(dataToHash.count), &digest) + } + return Data(digest) + } +} diff --git a/AppCheckCore/Sources/Core/Utils/GACAppCheckCryptoUtils.h b/AppCheckCore/Sources/Core/Utils/GACAppCheckCryptoUtils.h deleted file mode 100644 index 839dcc1f..00000000 --- a/AppCheckCore/Sources/Core/Utils/GACAppCheckCryptoUtils.h +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright 2021 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 - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckCryptoUtils : NSObject - -+ (NSData *)sha256HashFromData:(NSData *)dataToHash; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Core/Utils/GACAppCheckCryptoUtils.m b/AppCheckCore/Sources/Core/Utils/GACAppCheckCryptoUtils.m deleted file mode 100644 index cb15516b..00000000 --- a/AppCheckCore/Sources/Core/Utils/GACAppCheckCryptoUtils.m +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Core/Utils/GACAppCheckCryptoUtils.h" - -#import - -@implementation GACAppCheckCryptoUtils - -+ (NSData *)sha256HashFromData:(NSData *)dataToHash { - NSMutableData *digest = [[NSMutableData alloc] initWithLength:CC_SHA256_DIGEST_LENGTH]; - CC_SHA256(dataToHash.bytes, (CC_LONG)dataToHash.length, digest.mutableBytes); - return [digest copy]; -} - -@end diff --git a/AppCheckCore/Sources/Core/_GACAppCheckAPIService+Internal.h b/AppCheckCore/Sources/Core/_GACAppCheckAPIService+Internal.h deleted file mode 100644 index 369519d9..00000000 --- a/AppCheckCore/Sources/Core/_GACAppCheckAPIService+Internal.h +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface _GACAppCheckAPIService (Internal) - -/** - * Internal initializer. - * @param session The URL session used to make network requests. - * @param baseURL The base URL for the App Check service, e.g., - * `https://firebaseappcheck.googleapis.com/v1`. - * @param APIKey The Google Cloud Platform API key, if needed, or nil. - * @param requestHooks Hooks that will be invoked on requests through this service. - * @param environment A dictionary containing environment variables. - */ -- (instancetype)initWithURLSession:(NSURLSession *)session - baseURL:(nullable NSString *)baseURL - APIKey:(nullable NSString *)APIKey - requestHooks:(nullable NSArray *)requestHooks - environment:(NSDictionary *)environment; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.h b/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.h deleted file mode 100644 index a75ad75c..00000000 --- a/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.h +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright 2020 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 - -@class FBLPromise; -@class GACAppCheckToken; -@protocol _GACAppCheckAPIServiceProtocol; - -NS_ASSUME_NONNULL_BEGIN - -@protocol GACAppCheckDebugProviderAPIServiceProtocol - -- (FBLPromise *)appCheckTokenWithDebugToken:(NSString *)debugToken - limitedUse:(BOOL)limitedUse; - -@end - -@interface GACAppCheckDebugProviderAPIService - : NSObject - -/// Default initializer. -/// @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<_GACAppCheckAPIServiceProtocol>)APIService - resourceName:(NSString *)resourceName; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.m b/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.m deleted file mode 100644 index 72af8f02..00000000 --- a/AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.m +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" - -#import "AppCheckCore/Sources/Core/GACAppCheckLogger+Internal.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -NS_ASSUME_NONNULL_BEGIN - -static NSString *const kContentTypeKey = @"Content-Type"; -static NSString *const kJSONContentType = @"application/json"; -static NSString *const kDebugTokenField = @"debug_token"; -static NSString *const kLimitedUseField = @"limited_use"; - -@interface GACAppCheckDebugProviderAPIService () - -@property(nonatomic, readonly) id<_GACAppCheckAPIServiceProtocol> APIService; - -@property(nonatomic, readonly) NSString *resourceName; - -@end - -@implementation GACAppCheckDebugProviderAPIService - -- (instancetype)initWithAPIService:(id<_GACAppCheckAPIServiceProtocol>)APIService - resourceName:(NSString *)resourceName { - self = [super init]; - if (self) { - _APIService = APIService; - _resourceName = resourceName; - } - return self; -} - -#pragma mark - Public API - -- (FBLPromise *)appCheckTokenWithDebugToken:(NSString *)debugToken - limitedUse:(BOOL)limitedUse { - NSString *URLString = [NSString - stringWithFormat:@"%@/%@:exchangeDebugToken", self.APIService.baseURL, self.resourceName]; - NSURL *URL = [NSURL URLWithString:URLString]; - - return [self HTTPBodyWithDebugToken:debugToken limitedUse:limitedUse] - .then(^FBLPromise<_GACURLSessionDataResponse *> *(NSData *HTTPBody) { - return [self.APIService sendRequestWithURL:URL - HTTPMethod:@"POST" - body:HTTPBody - additionalHeaders:@{kContentTypeKey : kJSONContentType}]; - }) - .then(^id _Nullable(_GACURLSessionDataResponse *_Nullable response) { - return [self.APIService appCheckTokenWithAPIResponse:response]; - }); -} - -#pragma mark - Helpers - -- (FBLPromise *)HTTPBodyWithDebugToken:(NSString *)debugToken - limitedUse:(BOOL)limitedUse { - if (debugToken.length <= 0) { - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise - reject:[_GACAppCheckErrorUtil errorWithFailureReason:@"Debug token must not be empty."]]; - return rejectedPromise; - } - - NSError *encodingError; - NSData *payloadJSON = [NSJSONSerialization - dataWithJSONObject:@{kDebugTokenField : debugToken, kLimitedUseField : @(limitedUse)} - options:0 - error:&encodingError]; - - FBLPromise *payloadPromise = [FBLPromise pendingPromise]; - if (payloadJSON != nil) { - [payloadPromise fulfill:payloadJSON]; - } else { - [payloadPromise reject:[_GACAppCheckErrorUtil JSONSerializationError:encodingError]]; - } - return payloadPromise; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/DebugProvider/AppCheckCoreDebugProvider.swift b/AppCheckCore/Sources/DebugProvider/AppCheckCoreDebugProvider.swift new file mode 100644 index 00000000..e7b9c8e0 --- /dev/null +++ b/AppCheckCore/Sources/DebugProvider/AppCheckCoreDebugProvider.swift @@ -0,0 +1,221 @@ +// 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 Foundation + +#if canImport(GoogleUtilities) + import GoogleUtilities +#endif +#if COCOAPODS + import GoogleUtilities +#else + import GoogleUtilities_UserDefaults +#endif + +private let kDebugTokenEnvKey = "AppCheckDebugToken" +private let kFirebaseDebugTokenEnvKey = "FIRAAppCheckDebugToken" +private let kDebugTokenUserDefaultsKey = "AppCheckCoreDebugToken" +private let kDebugTokenRegisteredUserDefaultsKey = "AppCheckCoreDebugTokenRegistered" + +@objc(GACAppCheckDebugProvider) +@objcMembers +public class AppCheckCoreDebugProvider: NSObject, AppCheckCoreProvider { + private let apiService: AppCheckCoreDebugProviderAPIServiceProtocol + private let debugTokenEnvValue: String? + private let registeredUserDefaultsKey: String + + // Internal initializer + init(apiService: AppCheckCoreDebugProviderAPIServiceProtocol, + serviceName: String, + resourceName: String, + environment: [String: String]) { + self.apiService = apiService + registeredUserDefaultsKey = Self.registeredUserDefaultsKey( + forServiceName: serviceName, + resourceName: resourceName + ) + debugTokenEnvValue = Self.environmentVariableDebugToken( + registeredUserDefaultsKey: registeredUserDefaultsKey, + environment: environment + ) + super.init() + } + + @objc(initWithServiceName:resourceName:baseURL:APIKey:requestHooks:) + public convenience init(serviceName: String, + resourceName: String, + baseURL: String?, + apiKey: String, + requestHooks: [Any]?) { + self.init(serviceName: serviceName, + resourceName: resourceName, + baseURL: baseURL, + apiKey: apiKey, + requestHooks: requestHooks, + environment: ProcessInfo.processInfo.environment) + } + + // Additional internal initializer for testing + convenience init(serviceName: String, + resourceName: String, + baseURL: String?, + apiKey: String, + requestHooks: [Any]?, + environment: [String: String]) { + let urlSession = URLSession(configuration: .ephemeral) + let coreAPIService = AppCheckCoreAPIService(urlSession: urlSession, + baseURL: baseURL, + apiKey: apiKey, + requestHooks: requestHooks, + environment: environment) + let debugAPIService = AppCheckCoreDebugProviderAPIService(apiService: coreAPIService, + resourceName: resourceName) + self.init(apiService: debugAPIService, + serviceName: serviceName, + resourceName: resourceName, + environment: environment) + } + + public func localDebugToken() -> String { + return Self.localDebugToken() + } + + public func currentDebugToken() -> String { + return debugTokenEnvValue ?? Self.localDebugToken() + } + + // MARK: - AppCheckCoreProvider + + public func getToken() async throws -> AppCheckCoreToken { + return try await getToken(limitedUse: false) + } + + public func getLimitedUseToken() async throws -> AppCheckCoreToken { + return try await getToken(limitedUse: true) + } + + public func getToken(completion handler: @escaping (AppCheckCoreToken?, Error?) -> Void) { + getToken(limitedUse: false, completion: handler) + } + + public func getLimitedUseToken(completion handler: @escaping (AppCheckCoreToken?, Error?) + -> Void) { + getToken(limitedUse: true, completion: handler) + } + + // MARK: - Internal + + private func getToken(limitedUse: Bool) async throws -> AppCheckCoreToken { + do { + let token = try await apiService.appCheckToken( + debugToken: currentDebugToken(), + limitedUse: limitedUse + ) + GULUserDefaults.standard().setObject(true, forKey: registeredUserDefaultsKey) + return token + } catch { + let logMessage = "Failed to exchange debug token to app check token: \(error)" + AppCheckCoreLogger.log( + code: .debugProviderFailedExchange, + logLevel: .debug, + message: logMessage + ) + + let nsError = error as NSError + if nsError.domain == AppCheckCoreErrorDomain && nsError.code == AppCheckCoreErrorCode + .serverUnreachable.rawValue { + // Do nothing + } else { + GULUserDefaults.standard().removeObject(forKey: registeredUserDefaultsKey) + } + throw error + } + } + + private func getToken(limitedUse: Bool, + completion handler: @escaping (AppCheckCoreToken?, Error?) -> Void) { + Task { + do { + let token = try await getToken(limitedUse: limitedUse) + handler(token, nil) + } catch { + handler(nil, error) + } + } + } + + private static func localDebugToken() -> String { + if let token = GULUserDefaults.standard().string(forKey: kDebugTokenUserDefaultsKey) { + return token + } else { + let token = UUID().uuidString + GULUserDefaults.standard().setObject(token, forKey: kDebugTokenUserDefaultsKey) + return token + } + } + + private static func registeredUserDefaultsKey(forServiceName serviceName: String, + resourceName: String) -> String { + let safeServiceName = serviceName.isEmpty ? "default" : serviceName + var safeResourceName = resourceName.replacingOccurrences(of: "/", with: "_") + if safeResourceName.isEmpty { + safeResourceName = "default" + } + return "\(kDebugTokenRegisteredUserDefaultsKey)_\(safeServiceName)_\(safeResourceName)" + } + + private static func environmentVariableDebugToken(registeredUserDefaultsKey: String, + environment: [String: String]) -> String? { + let envVariableValue = environment[kDebugTokenEnvKey]? + .isEmpty == false ? environment[kDebugTokenEnvKey] : nil + let firebaseEnvVariableValue = environment[kFirebaseDebugTokenEnvKey]? + .isEmpty == false ? environment[kFirebaseDebugTokenEnvKey] : nil + + if let env = envVariableValue, let _ = firebaseEnvVariableValue { + let message = + "The environment variables \(kDebugTokenEnvKey) and \(kFirebaseDebugTokenEnvKey) are both set; using the debug token specified in \(kDebugTokenEnvKey) and ignoring the value of \(kFirebaseDebugTokenEnvKey)." + AppCheckCoreLogger.log( + code: .debugProviderFirebaseEnvironmentVariable, + logLevel: .warning, + message: message + ) + return env + } else if let env = envVariableValue { + let message = + "Using the debug token specified in the environment variable \(kDebugTokenEnvKey)." + AppCheckCoreLogger.log( + code: .environmentVariableDebugToken, + logLevel: .debug, + message: message + ) + return env + } else if let firebaseEnv = firebaseEnvVariableValue { + let message = + "Using the debug token specified in the environment variable \(kFirebaseDebugTokenEnvKey)." + AppCheckCoreLogger.log( + code: .debugProviderFirebaseEnvironmentVariable, + logLevel: .debug, + message: message + ) + return firebaseEnv + } else { + let isRegistered = GULUserDefaults.standard().bool(forKey: registeredUserDefaultsKey) + if !isRegistered { + let message = "App Check debug token: '\(Self.localDebugToken())'." + AppCheckCoreLogger.log(code: .localDebugToken, logLevel: .warning, message: message) + } + return nil + } + } +} diff --git a/AppCheckCore/Sources/DebugProvider/AppCheckCoreDebugProviderAPIService.swift b/AppCheckCore/Sources/DebugProvider/AppCheckCoreDebugProviderAPIService.swift new file mode 100644 index 00000000..575b91eb --- /dev/null +++ b/AppCheckCore/Sources/DebugProvider/AppCheckCoreDebugProviderAPIService.swift @@ -0,0 +1,71 @@ +// 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 Foundation + +@objc(GACAppCheckDebugProviderAPIServiceProtocol) +protocol AppCheckCoreDebugProviderAPIServiceProtocol: NSObjectProtocol { + @objc func appCheckToken(debugToken: String, limitedUse: Bool) async throws -> AppCheckCoreToken +} + +@objc(GACAppCheckDebugProviderAPIService) +class AppCheckCoreDebugProviderAPIService: NSObject, AppCheckCoreDebugProviderAPIServiceProtocol { + private let apiService: AppCheckCoreAPIServiceProtocol + private let resourceName: String + + private static let contentTypeKey = "Content-Type" + private static let jsonContentType = "application/json" + private static let debugTokenField = "debug_token" + private static let limitedUseField = "limited_use" + + @objc init(apiService: AppCheckCoreAPIServiceProtocol, resourceName: String) { + self.apiService = apiService + self.resourceName = resourceName + super.init() + } + + @objc func appCheckToken(debugToken: String, limitedUse: Bool) async throws -> AppCheckCoreToken { + let urlString = "\(apiService.baseURL)/\(resourceName):exchangeDebugToken" + guard let url = URL(string: urlString) else { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Invalid URL: \(urlString)") + } + + let httpBody = try self.httpBody(debugToken: debugToken, limitedUse: limitedUse) + + let response = try await apiService.sendRequest(withURL: url, + httpMethod: "POST", + body: httpBody, + additionalHeaders: [Self.contentTypeKey: Self + .jsonContentType]) + + return try await apiService.appCheckToken(withAPIResponse: response) + } + + private func httpBody(debugToken: String, limitedUse: Bool) throws -> Data { + if debugToken.isEmpty { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Debug token must not be empty.") + } + + let payload: [String: Any] = [ + Self.debugTokenField: debugToken, + Self.limitedUseField: limitedUse, + ] + + do { + return try JSONSerialization.data(withJSONObject: payload, options: []) + } catch { + throw AppCheckCoreErrorUtil.jsonSerializationError(error) + } + } +} diff --git a/AppCheckCore/Sources/DebugProvider/GACAppCheckDebugProvider.m b/AppCheckCore/Sources/DebugProvider/GACAppCheckDebugProvider.m deleted file mode 100644 index 0e078942..00000000 --- a/AppCheckCore/Sources/DebugProvider/GACAppCheckDebugProvider.m +++ /dev/null @@ -1,249 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckDebugProvider.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import - -#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" - -#import "AppCheckCore/Sources/Core/GACAppCheckDebugProvider+Internal.h" -#import "AppCheckCore/Sources/Core/_GACAppCheckAPIService+Internal.h" - -NS_ASSUME_NONNULL_BEGIN - -static NSString *const kDebugTokenEnvKey = @"AppCheckDebugToken"; -static NSString *const kFirebaseDebugTokenEnvKey = @"FIRAAppCheckDebugToken"; -static NSString *const kDebugTokenUserDefaultsKey = @"GACAppCheckDebugToken"; - -// The base key for registration status. -// NOTE: Do not use this key directly. Use `registeredUserDefaultsKeyForServiceName:resourceName:` -// to obtain the namespaced key. -static NSString *const kDebugTokenRegisteredUserDefaultsKey = @"GACAppCheckDebugTokenRegistered"; - -@interface GACAppCheckDebugProvider () -@property(nonatomic, readonly) id APIService; -@property(nonatomic, readonly, nullable, copy) NSString *debugTokenEnvValue; -@property(nonatomic, readonly, copy) NSString *registeredUserDefaultsKey; - -+ (NSString *)registeredUserDefaultsKeyForServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName; -@end - -static NSString *_Nullable EnvironmentVariableDebugToken( - NSString *registeredUserDefaultsKey, NSDictionary *environment); - -@implementation GACAppCheckDebugProvider - -- (instancetype)initWithAPIService:(id)APIService - serviceName:(NSString *)serviceName - resourceName:(NSString *)resourceName - environment:(NSDictionary *)environment { - self = [super init]; - if (self) { - _APIService = APIService; - _registeredUserDefaultsKey = - [[[self class] registeredUserDefaultsKeyForServiceName:serviceName - resourceName:resourceName] copy]; - _debugTokenEnvValue = EnvironmentVariableDebugToken(_registeredUserDefaultsKey, environment); - } - return self; -} - -- (instancetype)initWithServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName - baseURL:(nullable NSString *)baseURL - APIKey:(NSString *)APIKey - requestHooks:(nullable NSArray *)requestHooks - environment:(NSDictionary *)environment { - NSURLSession *URLSession = [NSURLSession - sessionWithConfiguration:[NSURLSessionConfiguration ephemeralSessionConfiguration]]; - - _GACAppCheckAPIService *APIService = - [[_GACAppCheckAPIService alloc] initWithURLSession:URLSession - baseURL:baseURL - APIKey:APIKey - requestHooks:requestHooks - environment:environment]; - - GACAppCheckDebugProviderAPIService *debugAPIService = - [[GACAppCheckDebugProviderAPIService alloc] initWithAPIService:APIService - resourceName:resourceName]; - - return [self initWithAPIService:debugAPIService - serviceName:serviceName - resourceName:resourceName - environment:environment]; -} - -- (instancetype)initWithServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName - baseURL:(nullable NSString *)baseURL - APIKey:(NSString *)APIKey - requestHooks:(nullable NSArray *)requestHooks { - return [self initWithServiceName:serviceName - resourceName:resourceName - baseURL:baseURL - APIKey:APIKey - requestHooks:requestHooks - environment:[[NSProcessInfo processInfo] environment]]; -} - -- (NSString *)currentDebugToken { - if (self.debugTokenEnvValue) { - return self.debugTokenEnvValue; - } else { - return [self localDebugToken]; - } -} - -- (NSString *)localDebugToken { - return LocalDebugToken(); -} - -#pragma mark - GACAppCheckProvider - -- (void)getTokenWithCompletion:(void (^)(GACAppCheckToken *_Nullable, NSError *_Nullable))handler { - [self getTokenWithLimitedUse:NO completion:handler]; -} - -- (void)getLimitedUseTokenWithCompletion:(void (^)(GACAppCheckToken *_Nullable, - NSError *_Nullable))handler { - [self getTokenWithLimitedUse:YES completion:handler]; -} - -#pragma mark - Internal - -- (void)getTokenWithLimitedUse:(BOOL)limitedUse - completion:(void (^)(GACAppCheckToken *_Nullable token, - NSError *_Nullable error))handler { - [FBLPromise do:^NSString * { - return [self currentDebugToken]; - }] - .then(^FBLPromise *(NSString *debugToken) { - return [self.APIService appCheckTokenWithDebugToken:debugToken limitedUse:limitedUse]; - }) - .then(^id(GACAppCheckToken *appCheckToken) { - [[GULUserDefaults standardUserDefaults] setBool:YES forKey:self.registeredUserDefaultsKey]; - handler(appCheckToken, nil); - return nil; - }) - .catch(^void(NSError *error) { - NSString *logMessage = [NSString - stringWithFormat:@"Failed to exchange debug token to app check token: %@", error]; - GACAppCheckLogDebug(GACLoggerAppCheckMessageDebugProviderFailedExchange, logMessage); - if (error.code != GACAppCheckErrorCodeServerUnreachable) { - [[GULUserDefaults standardUserDefaults] - removeObjectForKey:self.registeredUserDefaultsKey]; - } - handler(nil, error); - }); -} - -static NSString *LocalDebugToken(void) { - return StoredDebugToken() ?: GenerateAndStoreDebugToken(); -} - -static NSString *_Nullable StoredDebugToken(void) { - return [[GULUserDefaults standardUserDefaults] stringForKey:kDebugTokenUserDefaultsKey]; -} - -static NSString *GenerateAndStoreDebugToken(void) { - NSString *token = [NSUUID UUID].UUIDString; - [[GULUserDefaults standardUserDefaults] setObject:token forKey:kDebugTokenUserDefaultsKey]; - return token; -} - -+ (NSString *)registeredUserDefaultsKeyForServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName { - NSString *safeServiceName = serviceName.length > 0 ? serviceName : @"default"; - NSString *safeResourceName = [resourceName stringByReplacingOccurrencesOfString:@"/" - withString:@"_"]; - if (safeResourceName.length == 0) { - safeResourceName = @"default"; - } - return [NSString stringWithFormat:@"%@_%@_%@", kDebugTokenRegisteredUserDefaultsKey, - safeServiceName, safeResourceName]; -} - -static NSString *_Nullable EnvironmentVariableDebugToken( - NSString *registeredUserDefaultsKey, NSDictionary *environment) { - NSString *envVariableValue = environment[kDebugTokenEnvKey]; - NSString *firebaseEnvVariableValue = environment[kFirebaseDebugTokenEnvKey]; - if (envVariableValue.length == 0) { - envVariableValue = nil; - } - if (firebaseEnvVariableValue == 0) { - firebaseEnvVariableValue = nil; - } - - if (envVariableValue && firebaseEnvVariableValue) { - GACAppCheckLog( - GACLoggerAppCheckMessageDebugProviderFirebaseEnvironmentVariable, - GACAppCheckLogLevelWarning, - [NSString stringWithFormat:@"The environment variables %@ and %@ are both set; using the " - @"debug token specified in %@ and ignoring the value of %@.", - kDebugTokenEnvKey, kFirebaseDebugTokenEnvKey, kDebugTokenEnvKey, - kFirebaseDebugTokenEnvKey]); - - return envVariableValue; - } else if (envVariableValue) { - GACAppCheckLog( - GACLoggerAppCheckMessageEnvironmentVariableDebugToken, GACAppCheckLogLevelDebug, - [NSString - stringWithFormat:@"Using the debug token specified in the environment variable %@.", - kDebugTokenEnvKey]); - - return envVariableValue; - } else if (firebaseEnvVariableValue) { - // TODO(andrewheard): Update the message to warn that "FIRAAppCheckDebugToken" - // (kFirebaseDebugTokenEnvKey) is deprecated after Firebase App Check supports - // "AppCheckDebugToken" (kDebugTokenEnvKey) and increase the severity to - // GACAppCheckLogLevelWarning. - GACAppCheckLog( - GACLoggerAppCheckMessageDebugProviderFirebaseEnvironmentVariable, GACAppCheckLogLevelDebug, - [NSString - stringWithFormat:@"Using the debug token specified in the environment variable %@.", - kFirebaseDebugTokenEnvKey]); - - return firebaseEnvVariableValue; - } else { - BOOL isRegistered = - [[GULUserDefaults standardUserDefaults] boolForKey:registeredUserDefaultsKey]; - if (!isRegistered) { - // Print only a locally generated token to avoid a valid token leak on CI. - GACAppCheckLog( - GACLoggerAppCheckMessageLocalDebugToken, GACAppCheckLogLevelWarning, - [NSString stringWithFormat:@"App Check debug token: '%@'.", LocalDebugToken()]); - } - - return nil; - } -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/DeviceCheckProvider/API/AppCheckCoreDeviceCheckAPIService.swift b/AppCheckCore/Sources/DeviceCheckProvider/API/AppCheckCoreDeviceCheckAPIService.swift new file mode 100644 index 00000000..e09c9c92 --- /dev/null +++ b/AppCheckCore/Sources/DeviceCheckProvider/API/AppCheckCoreDeviceCheckAPIService.swift @@ -0,0 +1,67 @@ +// 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 Foundation + +@objc(GACDeviceCheckAPIServiceProtocol) +protocol AppCheckCoreDeviceCheckAPIServiceProtocol: NSObjectProtocol { + @objc func appCheckToken(deviceToken: Data, limitedUse: Bool) async throws -> AppCheckCoreToken +} + +@objc(GACDeviceCheckAPIService) +class AppCheckCoreDeviceCheckAPIService: NSObject, AppCheckCoreDeviceCheckAPIServiceProtocol { + private let apiService: AppCheckCoreAPIServiceProtocol + private let resourceName: String + + @objc + init(apiService: AppCheckCoreAPIServiceProtocol, resourceName: String) { + self.apiService = apiService + self.resourceName = resourceName + super.init() + } + + @objc + func appCheckToken(deviceToken: Data, limitedUse: Bool) async throws -> AppCheckCoreToken { + guard !deviceToken.isEmpty else { + throw AppCheckCoreErrorUtil.error(withFailureReason: "DeviceCheck token must not be empty.") + } + + let base64EncodedToken = deviceToken.base64EncodedString() + let payload: [String: Any] = [ + "device_token": base64EncodedToken, + "limited_use": limitedUse, + ] + + let payloadJSON: Data + do { + payloadJSON = try JSONSerialization.data(withJSONObject: payload) + } catch { + throw AppCheckCoreErrorUtil.jsonSerializationError(error) + } + + let urlString = "\(apiService.baseURL)/\(resourceName):exchangeDeviceCheckToken" + guard let url = URL(string: urlString) else { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Invalid URL.") + } + + let response = try await apiService.sendRequest( + withURL: url, + httpMethod: "POST", + body: payloadJSON, + additionalHeaders: ["Content-Type": "application/json"] + ) + + return try await apiService.appCheckToken(withAPIResponse: response) + } +} diff --git a/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.h b/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.h deleted file mode 100644 index cbc4468e..00000000 --- a/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.h +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright 2020 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 - -@class FBLPromise; -@class GACAppCheckToken; -@protocol _GACAppCheckAPIServiceProtocol; - -NS_ASSUME_NONNULL_BEGIN - -@protocol GACDeviceCheckAPIServiceProtocol - -- (FBLPromise *)appCheckTokenWithDeviceToken:(NSData *)deviceToken - limitedUse:(BOOL)limitedUse; - -@end - -@interface GACDeviceCheckAPIService : NSObject - -/// Default initializer. -/// @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<_GACAppCheckAPIServiceProtocol>)APIService - resourceName:(NSString *)resourceName; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.m b/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.m deleted file mode 100644 index fb82f69c..00000000 --- a/AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.m +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import "AppCheckCore/Sources/Core/APIService/GACAppCheckToken+APIResponse.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" - -#import "AppCheckCore/Sources/Core/GACAppCheckLogger+Internal.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -NS_ASSUME_NONNULL_BEGIN - -static NSString *const kContentTypeKey = @"Content-Type"; -static NSString *const kJSONContentType = @"application/json"; -static NSString *const kDeviceTokenField = @"device_token"; -static NSString *const kLimitedUseField = @"limited_use"; - -@interface GACDeviceCheckAPIService () - -@property(nonatomic, readonly) id<_GACAppCheckAPIServiceProtocol> APIService; - -@property(nonatomic, readonly) NSString *resourceName; - -@end - -@implementation GACDeviceCheckAPIService - -- (instancetype)initWithAPIService:(id<_GACAppCheckAPIServiceProtocol>)APIService - resourceName:(NSString *)resourceName { - self = [super init]; - if (self) { - _APIService = APIService; - _resourceName = resourceName; - } - return self; -} - -#pragma mark - Public API - -- (FBLPromise *)appCheckTokenWithDeviceToken:(NSData *)deviceToken - limitedUse:(BOOL)limitedUse { - NSString *URLString = [NSString stringWithFormat:@"%@/%@:exchangeDeviceCheckToken", - self.APIService.baseURL, self.resourceName]; - NSURL *URL = [NSURL URLWithString:URLString]; - - return [self HTTPBodyWithDeviceToken:deviceToken limitedUse:limitedUse] - .then(^FBLPromise<_GACURLSessionDataResponse *> *(NSData *HTTPBody) { - return [self.APIService sendRequestWithURL:URL - HTTPMethod:@"POST" - body:HTTPBody - additionalHeaders:@{kContentTypeKey : kJSONContentType}]; - }) - .then(^id _Nullable(_GACURLSessionDataResponse *_Nullable response) { - return [self.APIService appCheckTokenWithAPIResponse:response]; - }); -} - -- (FBLPromise *)HTTPBodyWithDeviceToken:(NSData *)deviceToken - limitedUse:(BOOL)limitedUse { - if (deviceToken.length <= 0) { - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:[_GACAppCheckErrorUtil - errorWithFailureReason:@"DeviceCheck token must not be empty."]]; - return rejectedPromise; - } - - NSString *base64EncodedToken = [deviceToken base64EncodedStringWithOptions:0]; - - NSError *encodingError; - NSData *payloadJSON = [NSJSONSerialization - dataWithJSONObject:@{kDeviceTokenField : base64EncodedToken, kLimitedUseField : @(limitedUse)} - options:0 - error:&encodingError]; - - FBLPromise *payloadPromise = [FBLPromise pendingPromise]; - if (payloadJSON != nil) { - [payloadPromise fulfill:payloadJSON]; - } else { - [payloadPromise reject:[_GACAppCheckErrorUtil JSONSerializationError:encodingError]]; - } - return payloadPromise; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/DeviceCheckProvider/AppCheckCoreDeviceCheckProvider.swift b/AppCheckCore/Sources/DeviceCheckProvider/AppCheckCoreDeviceCheckProvider.swift new file mode 100644 index 00000000..ba526d12 --- /dev/null +++ b/AppCheckCore/Sources/DeviceCheckProvider/AppCheckCoreDeviceCheckProvider.swift @@ -0,0 +1,114 @@ +// 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 Foundation +#if canImport(DeviceCheck) + import DeviceCheck +#endif + +@available(iOS 11.0, macOS 10.15, macCatalyst 13.0, tvOS 11.0, watchOS 9.0, *) +@objc(GACDeviceCheckProvider) +public class AppCheckCoreDeviceCheckProvider: NSObject, AppCheckCoreProvider { + private let apiService: AppCheckCoreDeviceCheckAPIServiceProtocol + private let deviceTokenGenerator: AppCheckCoreDeviceCheckTokenGenerator + private let backoffWrapper: AppCheckBackoffWrapperProtocol + + @objc(initWithServiceName:resourceName:APIKey:requestHooks:) + public init(serviceName: String, resourceName: String, apiKey: String, + requestHooks: [Any]?) { + let session = URLSession(configuration: .ephemeral) + let coreAPIService = AppCheckCoreAPIService( + urlSession: session, + baseURL: nil, + apiKey: apiKey, + requestHooks: requestHooks + ) + let deviceCheckAPIService = AppCheckCoreDeviceCheckAPIService( + apiService: coreAPIService, + resourceName: resourceName + ) + apiService = deviceCheckAPIService + deviceTokenGenerator = DCDevice.current + backoffWrapper = AppCheckCoreBackoffWrapper() + super.init() + } + + init(apiService: AppCheckCoreDeviceCheckAPIServiceProtocol, + deviceTokenGenerator: AppCheckCoreDeviceCheckTokenGenerator, + backoffWrapper: AppCheckBackoffWrapperProtocol) { + self.apiService = apiService + self.deviceTokenGenerator = deviceTokenGenerator + self.backoffWrapper = backoffWrapper + super.init() + } + + // MARK: - AppCheckCoreProvider + + public func getToken() async throws -> AppCheckCoreToken { + return try await getToken(limitedUse: false) + } + + public func getLimitedUseToken() async throws -> AppCheckCoreToken { + return try await getToken(limitedUse: true) + } + + public func getToken(completion handler: @escaping (AppCheckCoreToken?, Error?) -> Void) { + Task { + do { + let token = try await getToken(limitedUse: false) + handler(token, nil) + } catch { + handler(nil, error) + } + } + } + + @objc + public func getLimitedUseToken(completion handler: @escaping (AppCheckCoreToken?, Error?) + -> Void) { + Task { + do { + let token = try await getToken(limitedUse: true) + handler(token, nil) + } catch { + handler(nil, error) + } + } + } + + // MARK: - Internal + + private func getToken(limitedUse: Bool) async throws -> AppCheckCoreToken { + let result = try await backoffWrapper.applyBackoffToOperation({ [weak self] () -> Any in + guard let self = self else { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Self is nil") + } + return try await self.getTokenPromise(limitedUse: limitedUse) + }, errorHandler: backoffWrapper.defaultAppCheckProviderErrorHandler()) + + guard let token = result as? AppCheckCoreToken else { + throw AppCheckCoreErrorUtil + .error(withFailureReason: "Internal error: promise resolved with invalid type") + } + return token + } + + private func getTokenPromise(limitedUse: Bool) async throws -> AppCheckCoreToken { + guard deviceTokenGenerator.isSupported else { + throw AppCheckCoreErrorUtil.unsupportedAttestationProvider("DeviceCheckProvider") + } + let deviceToken = try await deviceTokenGenerator.generateTokenAsync() + return try await apiService.appCheckToken(deviceToken: deviceToken, limitedUse: limitedUse) + } +} diff --git a/AppCheckCore/Sources/DeviceCheckProvider/AppCheckCoreDeviceCheckTokenGenerator.swift b/AppCheckCore/Sources/DeviceCheckProvider/AppCheckCoreDeviceCheckTokenGenerator.swift new file mode 100644 index 00000000..70776eea --- /dev/null +++ b/AppCheckCore/Sources/DeviceCheckProvider/AppCheckCoreDeviceCheckTokenGenerator.swift @@ -0,0 +1,44 @@ +// 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 Foundation + +@objc(GACDeviceCheckTokenGenerator) +public protocol AppCheckCoreDeviceCheckTokenGenerator: NSObjectProtocol { + @objc var isSupported: Bool { get } + + @objc(generateTokenWithCompletionHandler:) + func generateToken(completionHandler: @escaping @Sendable (Data?, Error?) -> Void) +} + +extension AppCheckCoreDeviceCheckTokenGenerator { + func generateTokenAsync() async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + self.generateToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + let err = NSError( + domain: "AppCheckCoreDeviceCheckTokenGenerator", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "No token and no error."] + ) + continuation.resume(throwing: err) + } + } + } + } +} diff --git a/AppCheckCore/Sources/DeviceCheckProvider/DCDevice+GACDeviceCheckTokenGenerator.h b/AppCheckCore/Sources/DeviceCheckProvider/DCDevice+GACDeviceCheckTokenGenerator.h deleted file mode 100644 index 0a920372..00000000 --- a/AppCheckCore/Sources/DeviceCheckProvider/DCDevice+GACDeviceCheckTokenGenerator.h +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckAvailability.h" - -#import - -#import "AppCheckCore/Sources/DeviceCheckProvider/GACDeviceCheckTokenGenerator.h" - -NS_ASSUME_NONNULL_BEGIN - -GAC_DEVICE_CHECK_PROVIDER_AVAILABILITY -@interface DCDevice (GACDeviceCheckTokenGenerator) - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/DeviceCheckProvider/DCDevice+GACDeviceCheckTokenGenerator.m b/AppCheckCore/Sources/DeviceCheckProvider/DCDevice+GACDeviceCheckTokenGenerator.m deleted file mode 100644 index d807a486..00000000 --- a/AppCheckCore/Sources/DeviceCheckProvider/DCDevice+GACDeviceCheckTokenGenerator.m +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/DeviceCheckProvider/DCDevice+GACDeviceCheckTokenGenerator.h" - -@implementation DCDevice (GACDeviceCheckTokenGenerator) - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACFixtureLoader.h b/AppCheckCore/Sources/DeviceCheckProvider/DCDevice+GACDeviceCheckTokenGenerator.swift similarity index 71% rename from AppCheckCore/Tests/Unit/Utils/GACFixtureLoader.h rename to AppCheckCore/Sources/DeviceCheckProvider/DCDevice+GACDeviceCheckTokenGenerator.swift index bc1212dc..00609164 100644 --- a/AppCheckCore/Tests/Unit/Utils/GACFixtureLoader.h +++ b/AppCheckCore/Sources/DeviceCheckProvider/DCDevice+GACDeviceCheckTokenGenerator.swift @@ -1,4 +1,4 @@ -// Copyright 2021 Google LLC +// 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. @@ -12,14 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -#import +import Foundation +#if canImport(DeviceCheck) + import DeviceCheck -NS_ASSUME_NONNULL_BEGIN - -@interface GACFixtureLoader : NSObject - -+ (NSData *)loadFixtureNamed:(NSString *)fileName; - -@end - -NS_ASSUME_NONNULL_END + @available(iOS 11.0, macOS 10.15, tvOS 11.0, watchOS 9.0, *) + extension DCDevice: AppCheckCoreDeviceCheckTokenGenerator {} +#endif diff --git a/AppCheckCore/Sources/DeviceCheckProvider/GACDeviceCheckProvider.m b/AppCheckCore/Sources/DeviceCheckProvider/GACDeviceCheckProvider.m deleted file mode 100644 index 7d7fecda..00000000 --- a/AppCheckCore/Sources/DeviceCheckProvider/GACDeviceCheckProvider.m +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckAvailability.h" - -#import - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACDeviceCheckProvider.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<_GACAppCheckBackoffWrapperProtocol> backoffWrapper; - -- (instancetype)initWithAPIService:(id)APIService - deviceTokenGenerator:(id)deviceTokenGenerator - backoffWrapper:(id<_GACAppCheckBackoffWrapperProtocol>)backoffWrapper - NS_DESIGNATED_INITIALIZER; - -@end - -@implementation GACDeviceCheckProvider - -- (instancetype)initWithAPIService:(id)APIService - deviceTokenGenerator:(id)deviceTokenGenerator - backoffWrapper:(id<_GACAppCheckBackoffWrapperProtocol>)backoffWrapper { - self = [super init]; - if (self) { - _APIService = APIService; - _deviceTokenGenerator = deviceTokenGenerator; - _backoffWrapper = backoffWrapper; - } - return self; -} - -- (instancetype)initWithAPIService:(id)APIService { - _GACAppCheckBackoffWrapper *backoffWrapper = [[_GACAppCheckBackoffWrapper alloc] init]; - return [self initWithAPIService:APIService - deviceTokenGenerator:[DCDevice currentDevice] - backoffWrapper:backoffWrapper]; -} - -- (instancetype)initWithServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName - APIKey:(NSString *)APIKey - requestHooks:(nullable NSArray *)requestHooks { - NSURLSession *URLSession = [NSURLSession - sessionWithConfiguration:[NSURLSessionConfiguration ephemeralSessionConfiguration]]; - - _GACAppCheckAPIService *APIService = - [[_GACAppCheckAPIService alloc] initWithURLSession:URLSession - baseURL:nil - APIKey:APIKey - requestHooks:requestHooks]; - - GACDeviceCheckAPIService *deviceCheckAPIService = - [[GACDeviceCheckAPIService alloc] initWithAPIService:APIService resourceName:resourceName]; - - return [self initWithAPIService:deviceCheckAPIService]; -} - -#pragma mark - GACAppCheckProvider - -- (void)getTokenWithCompletion:(void (^)(GACAppCheckToken *_Nullable, NSError *_Nullable))handler { - [self getTokenWithLimitedUse:NO completion:handler]; -} - -- (void)getLimitedUseTokenWithCompletion:(void (^)(GACAppCheckToken *_Nullable, - NSError *_Nullable))handler { - [self getTokenWithLimitedUse:YES completion:handler]; -} - -#pragma mark - Internal - -- (void)getTokenWithLimitedUse:(BOOL)limitedUse - completion:(void (^)(GACAppCheckToken *_Nullable token, - NSError *_Nullable error))handler { - [self.backoffWrapper - applyBackoffToOperation:^FBLPromise *_Nonnull { - return [self getTokenPromiseWithLimitedUse:limitedUse]; - } - errorHandler:[self.backoffWrapper defaultAppCheckProviderErrorHandler]] - // Call the handler with either token or error. - .then(^id(GACAppCheckToken *appCheckToken) { - handler(appCheckToken, nil); - return nil; - }) - .catch(^void(NSError *error) { - handler(nil, error); - }); -} - -- (FBLPromise *)getTokenPromiseWithLimitedUse:(BOOL)limitedUse { - // Get DeviceCheck token - return [self deviceToken] - // Exchange DeviceCheck token for FAC token. - .then(^FBLPromise *(NSData *deviceToken) { - return [self.APIService appCheckTokenWithDeviceToken:deviceToken limitedUse:limitedUse]; - }); -} - -#pragma mark - DeviceCheck - -- (FBLPromise *)deviceToken { - return [self isDeviceCheckSupported].then(^FBLPromise *(NSNull *ignored) { - return [FBLPromise - wrapObjectOrErrorCompletion:^(FBLPromiseObjectOrErrorCompletion _Nonnull handler) { - [self.deviceTokenGenerator generateTokenWithCompletionHandler:handler]; - }]; - }); -} - -#pragma mark - Helpers - -/// Returns a resolved promise if DeviceCheck is supported and a rejected promise if it is not. -- (FBLPromise *)isDeviceCheckSupported { - if (self.deviceTokenGenerator.isSupported) { - return [FBLPromise resolvedWith:[NSNull null]]; - } else { - NSError *error = [_GACAppCheckErrorUtil unsupportedAttestationProvider:@"DeviceCheckProvider"]; - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:error]; - return rejectedPromise; - } -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/DeviceCheckProvider/GACDeviceCheckTokenGenerator.h b/AppCheckCore/Sources/DeviceCheckProvider/GACDeviceCheckTokenGenerator.h deleted file mode 100644 index 63a0aca9..00000000 --- a/AppCheckCore/Sources/DeviceCheckProvider/GACDeviceCheckTokenGenerator.h +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2020 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 - -NS_ASSUME_NONNULL_BEGIN - -@protocol GACDeviceCheckTokenGenerator - -@property(getter=isSupported, readonly) BOOL supported; - -- (void)generateTokenWithCompletionHandler:(void (^)(NSData* _Nullable token, - NSError* _Nullable error))completion; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Public/AppCheckCore/AppCheckCore.h b/AppCheckCore/Sources/Public/AppCheckCore/AppCheckCore.h index 3c8dd2fb..bfba5753 100644 --- a/AppCheckCore/Sources/Public/AppCheckCore/AppCheckCore.h +++ b/AppCheckCore/Sources/Public/AppCheckCore/AppCheckCore.h @@ -1,39 +1,23 @@ -/* - * Copyright 2020 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. - */ +// 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 "GACAppCheck.h" -#import "GACAppCheckErrors.h" -#import "GACAppCheckLogger.h" -#import "GACAppCheckProvider.h" -#import "GACAppCheckSettings.h" -#import "GACAppCheckToken.h" -#import "GACAppCheckTokenDelegate.h" -#import "GACAppCheckTokenResult.h" +#import -// Debug provider -#import "GACAppCheckDebugProvider.h" - -// DeviceCheck provider -#import "GACDeviceCheckProvider.h" - -// 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" +#if __has_include() +#import +#elif __has_include("AppCheckCore-Swift.h") +#import "AppCheckCore-Swift.h" +#else +// Fallback for Swift package manager which auto-generates the bridging header +#endif diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACAppAttestProvider.h b/AppCheckCore/Sources/Public/AppCheckCore/GACAppAttestProvider.h deleted file mode 100644 index 39175bd5..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACAppAttestProvider.h +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "GACAppCheckProvider.h" - -#import "GACAppCheckAvailability.h" - -NS_ASSUME_NONNULL_BEGIN - -/// Firebase App Check provider that verifies app integrity using the -/// [DeviceCheck](https://developer.apple.com/documentation/devicecheck/dcappattestservice) API. -/// This class is available on all platforms for select OS versions. See -/// https://firebase.google.com/docs/ios/learn-more for more details. -GAC_APP_ATTEST_PROVIDER_AVAILABILITY -NS_SWIFT_NAME(AppCheckCoreAppAttestProvider) -@interface GACAppAttestProvider : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -/// The default initializer. -/// @param serviceName A unique identifier to differentiate storage keys corresponding to the same -/// `resourceName`; may be a Firebase App Name or an SDK name. -/// @param resourceName The name of the resource protected by App Check; for a Firebase App this is -/// "projects/{project_id}/apps/{app_id}". -/// @param baseURL The base URL for the App Check service; defaults to -/// `https://firebaseappcheck.googleapis.com/v1` if nil. -/// @param APIKey The Google Cloud Platform API key, if needed, or nil. -/// @param accessGroup The Keychain Access Group. -/// @param requestHooks Hooks that will be invoked on requests through this service. -/// @return An instance of `AppAttestProvider`. -- (instancetype)initWithServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName - baseURL:(nullable NSString *)baseURL - APIKey:(nullable NSString *)APIKey - keychainAccessGroup:(nullable NSString *)accessGroup - requestHooks:(nullable NSArray *)requestHooks; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheck.h b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheck.h deleted file mode 100644 index 0c953ff8..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheck.h +++ /dev/null @@ -1,80 +0,0 @@ -/* - * Copyright 2020 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 - -@protocol GACAppCheckProvider; -@protocol GACAppCheckSettingsProtocol; -@protocol GACAppCheckTokenDelegate; -@class GACAppCheckToken; -@class GACAppCheckTokenResult; - -NS_ASSUME_NONNULL_BEGIN - -NS_SWIFT_NAME(AppCheckCoreProtocol) @protocol GACAppCheckProtocol - -/// Requests an App Check token. -/// -/// @param forcingRefresh If `YES`, a new Firebase app check token is requested and the token -/// cache is ignored. If `NO`, the cached token is used if it exists and has not expired yet. In -/// most cases, `NO` should be used. `YES` should only be used if the server explicitly returns an -/// error, indicating a revoked token. -/// @param handler The completion handler to call when the token fetch request completes. The -/// `result` parameter includes the App Check token if the request succeeds, or a placeholder token -/// and an error if the request fails. -- (void)tokenForcingRefresh:(BOOL)forcingRefresh - completion:(void (^)(GACAppCheckTokenResult *result))handler - NS_SWIFT_NAME(token(forcingRefresh:completion:)); - -/// Retrieve a new limited-use App Check token -/// -/// This method does not affect the token generation behavior of the -/// ``tokenForcingRefresh()`` method. -/// -/// @param handler The completion handler to call when the token fetch request completes. The -/// `result` parameter includes the App Check token if the request succeeds, or a placeholder token -/// and an error if the request fails. -- (void)limitedUseTokenWithCompletion:(void (^)(GACAppCheckTokenResult *result))handler; - -@end - -/// A class used to manage App Check tokens for a given resource. -NS_SWIFT_NAME(AppCheckCore) -@interface GACAppCheck : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -/// Returns an instance of `AppCheck` for an application. -/// @param serviceName A unique identifier for the App Check instance, may be a Firebase App Name -/// or an SDK name. -/// @param resourceName The name of the resource protected by App Check; for a Firebase App this is -/// "projects/{project_id}/apps/{app_id}". -/// @param appCheckProvider An object that provides App Check tokens. -/// @param settings An object that provides App Check settings. -/// @param tokenDelegate A delegate that receives token update notifications. -/// @param accessGroup The identifier for a keychain group that the app shares items with; if -/// provided, requires the Keychain Access Groups Entitlement. -/// @return An instance of `AppCheckCore` with the specified token provider. -- (instancetype)initWithServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName - appCheckProvider:(id)appCheckProvider - settings:(id)settings - tokenDelegate:(nullable id)tokenDelegate - keychainAccessGroup:(nullable NSString *)accessGroup; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckAvailability.h b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckAvailability.h deleted file mode 100644 index f64ca430..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckAvailability.h +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2021 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. - */ - -// Availability conditions for different App Check SDK components. - -#import -#import - -#pragma mark - DeviceCheck - -// `DeviceCheckProvider` availability. -#define GAC_DEVICE_CHECK_PROVIDER_AVAILABILITY \ - API_AVAILABLE(ios(11.0), macos(10.15), macCatalyst(13.0), tvos(11.0), watchos(9.0)) - -#pragma mark - App Attest - -// `AppAttestProvider` availability annotations -#define GAC_APP_ATTEST_PROVIDER_AVAILABILITY \ - API_AVAILABLE(ios(14.0), macos(11.3), macCatalyst(14.5), tvos(15.0), watchos(9.0)) diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckDebugProvider.h b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckDebugProvider.h deleted file mode 100644 index 9a1decea..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckDebugProvider.h +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "GACAppCheckProvider.h" - -NS_ASSUME_NONNULL_BEGIN - -/// An App Check provider that can exchange a debug token registered in the Firebase console for an -/// App Check token. The debug provider is designed to enable testing applications on a simulator or -/// in a test environment. -/// -/// NOTE: Do not use the debug provider in production applications used by real users. -/// -/// WARNING: Keep the App Check debug token secret. If you accidentally share one (e.g., commit it -/// to a public source repository), remove it in the Firebase console ASAP. -/// -/// To use `AppCheckCoreDebugProvider` on a local simulator: -/// 1. Launch the app. A local debug token will be logged when the `AppCheckCoreDebugProvider` is -/// instantiated. For example: -/// "[AppCheckCore][I-GAC004001] App Check debug token: 'AB12C3D4-56EF-789G-01H2-IJ234567K8L9'." -/// 2. Register the debug token in the Firebase console. -/// -/// Once the debug token is registered in the Firebase console, the debug provider will be able to -/// provide a valid App Check token. -/// -/// To use `AppCheckCoreDebugProvider` in a Continuous Integration (CI) environment: -/// 1. Create a new App Check debug token in the Firebase console. -/// 2. Add the debug token to the secure storage of your build environment. E.g., see -/// [Encrypted secrets](https://docs.github.com/en/actions/reference/encrypted-secrets) for -/// GitHub Actions. -/// 4. Add an environment variable to the scheme with a name `AppCheckDebugToken` and a value like -/// `$(MY_APP_CHECK_DEBUG_TOKEN)`. -/// 5. Configure the build script to pass the debug token as in environment variable, e.g.: -/// `xcodebuild test -scheme InstallationsExample -workspace InstallationsExample.xcworkspace \ -/// MY_APP_CHECK_DEBUG_TOKEN=$(MY_SECRET_ON_CI)` -NS_SWIFT_NAME(AppCheckCoreDebugProvider) -@interface GACAppCheckDebugProvider : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -/// The default initializer. -/// @param serviceName A unique identifier to differentiate storage keys corresponding to the same -/// `resourceName`; may be a Firebase App Name or an SDK name. -/// @param resourceName The name of the resource protected by App Check; for a Firebase App this is -/// "projects/{project_id}/apps/{app_id}". -/// @param baseURL The base URL for the App Check service; defaults to -/// `https://firebaseappcheck.googleapis.com/v1` if nil. -/// @param APIKey The Google Cloud Platform API key. -/// @param requestHooks Hooks that will be invoked on requests through this service. -/// @return An instance of `AppCheckCoreDebugProvider`. -- (instancetype)initWithServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName - baseURL:(nullable NSString *)baseURL - APIKey:(NSString *)APIKey - requestHooks:(nullable NSArray *)requestHooks; - -/// Returns the locally generated token. -- (NSString *)localDebugToken; - -/// Returns the currently used App Check debug token. -/// -/// The priority of the token used is: -/// 1. The `AppCheckDebugToken` environment variable value -/// 2. The `FIRAAppCheckDebugToken` environment variable value -/// 3. A previously generated token, stored locally on the device -/// 4. A newly generated random token. The generated token will be stored locally for future use -/// -/// @return The currently used App Check debug token. -- (NSString *)currentDebugToken; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h deleted file mode 100644 index 6a2e9e96..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright 2021 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 - -/// Firebase app check error domain. -FOUNDATION_EXTERN NSErrorDomain const GACAppCheckErrorDomain NS_SWIFT_NAME(AppCheckCoreErrorDomain); - -typedef NS_ERROR_ENUM(GACAppCheckErrorDomain, GACAppCheckErrorCode){ - /// An unknown or non-actionable error. - GACAppCheckErrorCodeUnknown = 0, - - /// A network connection error. - GACAppCheckErrorCodeServerUnreachable = 1, - - /// Invalid configuration error. Currently, an exception is thrown but this error is reserved - /// for future implementations of invalid configuration detection. - GACAppCheckErrorCodeInvalidConfiguration = 2, - - /// System keychain access error. Ensure that the app has proper keychain access. - GACAppCheckErrorCodeKeychain = 3, - - /// Selected app attestation provider is not supported on the current platform or OS version. - GACAppCheckErrorCodeUnsupported = 4 - -} NS_SWIFT_NAME(AppCheckCoreErrorCode); - -#pragma mark - Error Message Codes - -typedef NS_ENUM(NSInteger, GACAppCheckMessageCode) { - GACLoggerAppCheckMessageCodeUnknown = 1001, - - // App Check - GACLoggerAppCheckMessageCodeProviderIsMissing = 2002, - GACLoggerAppCheckMessageCodeStagingModeEnabled = 2003, - GACLoggerAppCheckMessageCodeUnexpectedHTTPCode = 3001, - - // Debug Provider - GACLoggerAppCheckMessageLocalDebugToken = 4001, - GACLoggerAppCheckMessageEnvironmentVariableDebugToken = 4002, - GACLoggerAppCheckMessageDebugProviderFirebaseEnvironmentVariable = 4003, - GACLoggerAppCheckMessageDebugProviderFailedExchange = 4004, - - // App Attest Provider - GACLoggerAppCheckMessageCodeAppAttestNotSupported = 7001, - GACLoggerAppCheckMessageCodeAttestationRejected = 7002, - GACLoggerAppCheckMessageCodeAssertionRejected = 7003 -} NS_SWIFT_NAME(AppCheckCoreMessageCode); diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckLogger.h b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckLogger.h deleted file mode 100644 index cab60cee..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckLogger.h +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2023 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 "GACAppCheckErrors.h" - -/// Constants that specify the level of logging to perform in App Check Core. -typedef NS_ENUM(NSInteger, GACAppCheckLogLevel) { - /// The debug log level; equivalent to `OS_LOG_TYPE_DEBUG`. - GACAppCheckLogLevelDebug = 1, - /// The informational log level; equivalent to `OS_LOG_TYPE_INFO`. - GACAppCheckLogLevelInfo = 2, - /// The warning log level; equivalent to `OS_LOG_TYPE_DEFAULT`. - GACAppCheckLogLevelWarning = 3, - /// The error log level; equivalent to `OS_LOG_TYPE_ERROR`. - GACAppCheckLogLevelError = 4, - /// The fault log level; equivalent to `OS_LOG_TYPE_FAULT`. - GACAppCheckLogLevelFault = 5 -} NS_SWIFT_NAME(AppCheckCoreLogLevel); - -NS_SWIFT_NAME(AppCheckCoreLogger) -@interface GACAppCheckLogger : NSObject - -/// The current logging level. -/// -/// Messages with levels equal to or higher priority than `logLevel` will be printed, where -/// Fault > Error > Warning > Info > Debug. -@property(class, atomic, assign) GACAppCheckLogLevel logLevel; - -- (instancetype)init NS_UNAVAILABLE; - -@end diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckProvider.h b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckProvider.h deleted file mode 100644 index 437c6128..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckProvider.h +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2020 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 - -@class GACAppCheckToken; - -NS_ASSUME_NONNULL_BEGIN - -/// A block to be called before sending API requests. -/// @param request The request that is about to be sent. -typedef void (^GACAppCheckAPIRequestHook)(NSMutableURLRequest *request); - -/// Defines the methods required to be implemented by a specific App Check provider. -NS_SWIFT_NAME(AppCheckCoreProvider) -@protocol GACAppCheckProvider - -/// Returns a new App Check token. -/// @param handler The completion handler. Make sure to call the handler with either a token -/// or an error. -- (void)getTokenWithCompletion: - (void (^)(GACAppCheckToken *_Nullable token, NSError *_Nullable error))handler - NS_SWIFT_NAME(getToken(completion:)); - -/// Returns a new App Check token suitable for consumption in a limited-use scenario. -/// @param handler The completion handler. Make sure to call the handler with either a token -/// or an error. -- (void)getLimitedUseTokenWithCompletion: - (void (^)(GACAppCheckToken *_Nullable token, NSError *_Nullable error))handler - NS_SWIFT_NAME(getLimitedUseToken(completion:)); - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckSettings.h b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckSettings.h deleted file mode 100644 index d0092d82..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckSettings.h +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2023 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 - -NS_ASSUME_NONNULL_BEGIN - -/// A collection of App Check-wide settings and parameters. -NS_SWIFT_NAME(AppCheckCoreSettingsProtocol) -@protocol GACAppCheckSettingsProtocol - -/// If App Check token auto-refresh is enabled. -@property(nonatomic, assign) BOOL isTokenAutoRefreshEnabled; - -@end - -@interface GACAppCheckSettings : NSObject -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h deleted file mode 100644 index 29f859e8..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2020 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 - -NS_ASSUME_NONNULL_BEGIN - -/// An object representing an App Check token. -NS_SWIFT_NAME(AppCheckCoreToken) -@interface GACAppCheckToken : NSObject - -/// The App Check token. -@property(nonatomic, readonly) NSString *token; - -/// The App Check token's expiration date in the device's local time. -@property(nonatomic, readonly) NSDate *expirationDate; - -/// The date when the App Check token was received in the device's local time. -@property(nonatomic, readonly) NSDate *receivedAtDate; - -- (instancetype)init NS_UNAVAILABLE; - -/// Convenience initializer that uses the current device local time to set `receivedAtDate`. -/// @param token A Firebase App Check token. -/// @param expirationDate A Firebase App Check token expiration date in the device local time. -- (instancetype)initWithToken:(NSString *)token expirationDate:(NSDate *)expirationDate; - -/// The designated initializer. -/// @param token A Firebase App Check token. -/// @param expirationDate A Firebase App Check token expiration date in the device local time. -/// @param receivedAtDate A date when the Firebase App Check token was received in the device's -/// local time. -- (instancetype)initWithToken:(NSString *)token - expirationDate:(NSDate *)expirationDate - receivedAtDate:(NSDate *)receivedAtDate NS_DESIGNATED_INITIALIZER; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckTokenDelegate.h b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckTokenDelegate.h deleted file mode 100644 index ab8fddfc..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckTokenDelegate.h +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2023 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 - -@class GACAppCheckToken; - -NS_ASSUME_NONNULL_BEGIN - -NS_SWIFT_NAME(AppCheckCoreTokenDelegate) -@protocol GACAppCheckTokenDelegate - -/// Called each time an App Check token is refreshed. -/// -/// @param token The updated App Check token. -/// @param serviceName A unique identifier for the App Check instance, may be a Firebase App Name -/// or an SDK name. -- (void)tokenDidUpdate:(GACAppCheckToken *)token serviceName:(NSString *)serviceName; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckTokenResult.h b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckTokenResult.h deleted file mode 100644 index 86733965..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckTokenResult.h +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2023 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 - -NS_ASSUME_NONNULL_BEGIN - -@class GACAppCheckToken; - -NS_SWIFT_NAME(AppCheckCoreTokenResult) -@interface GACAppCheckTokenResult : NSObject - -/// An App Check token in the case of success or a placeholder token in the case of a failure. -@property(nonatomic, readonly) GACAppCheckToken *token; - -/// A token fetch error in the case of a failure or `nil` in the case of success. -@property(nonatomic, readonly, nullable) NSError *error; - -- (instancetype)initWithToken:(GACAppCheckToken *)token; - -- (instancetype)initWithError:(NSError *)error; - -- (instancetype)initWithToken:(GACAppCheckToken *)token - error:(nullable NSError *)error NS_DESIGNATED_INITIALIZER; - -- (instancetype)init NS_UNAVAILABLE; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACDeviceCheckProvider.h b/AppCheckCore/Sources/Public/AppCheckCore/GACDeviceCheckProvider.h deleted file mode 100644 index 930da786..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACDeviceCheckProvider.h +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "GACAppCheckAvailability.h" -#import "GACAppCheckProvider.h" - -@protocol GACDeviceCheckAPIServiceProtocol; -@protocol GACDeviceCheckTokenGenerator; - -NS_ASSUME_NONNULL_BEGIN - -/// Firebase App Check provider that verifies app integrity using the -/// [DeviceCheck](https://developer.apple.com/documentation/devicecheck) API. -/// This class is available on all platforms for select OS versions. See -/// https://firebase.google.com/docs/ios/learn-more for more details. -GAC_DEVICE_CHECK_PROVIDER_AVAILABILITY -NS_SWIFT_NAME(AppCheckCoreDeviceCheckProvider) -@interface GACDeviceCheckProvider : NSObject - -- (instancetype)init NS_UNAVAILABLE; - -/// The default initializer. -/// @param serviceName A unique identifier to differentiate storage keys corresponding to the same -/// `resourceName`; may be a Firebase App Name or an SDK name. -/// @param resourceName The name of the resource protected by App Check; for a Firebase App this is -/// "projects/{project_id}/apps/{app_id}". -/// @param APIKey The Google Cloud Platform API key. -/// @param requestHooks Hooks that will be invoked on requests through this service. -/// @return An instance of `AppCheckCoreDeviceCheckProvider`. -- (instancetype)initWithServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName - APIKey:(NSString *)APIKey - requestHooks:(nullable NSArray *)requestHooks; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h b/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h deleted file mode 100644 index 73c7d5af..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "GACAppCheckProvider.h" - -@class FBLPromise; -@class _GACURLSessionDataResponse; -@class GACAppCheckToken; - -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. - -@protocol _GACAppCheckAPIServiceProtocol - -@property(nonatomic, readonly) NSString *baseURL; - -- (FBLPromise<_GACURLSessionDataResponse *> *) - sendRequestWithURL:(NSURL *)requestURL - HTTPMethod:(NSString *)HTTPMethod - body:(nullable NSData *)body - additionalHeaders:(nullable NSDictionary *)additionalHeaders; - -- (FBLPromise *)appCheckTokenWithAPIResponse: - (_GACURLSessionDataResponse *)response; - -@end - -@interface _GACAppCheckAPIService : NSObject <_GACAppCheckAPIServiceProtocol> - -/** - * The default initializer. - * @param session The URL session used to make network requests. - * @param baseURL The base URL for the App Check service, e.g., - * `https://firebaseappcheck.googleapis.com/v1`. - * @param APIKey The Google Cloud Platform API key, if needed, or nil. - * @param requestHooks Hooks that will be invoked on requests through this service. - */ -- (instancetype)initWithURLSession:(NSURLSession *)session - baseURL:(nullable NSString *)baseURL - APIKey:(nullable NSString *)APIKey - requestHooks:(nullable NSArray *)requestHooks; - -- (instancetype)init NS_UNAVAILABLE; - -- (FBLPromise *)appCheckTokenWithAPIResponse: - (_GACURLSessionDataResponse *)response; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckBackoffWrapper.h b/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckBackoffWrapper.h deleted file mode 100644 index 4fc5c0e8..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckBackoffWrapper.h +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright 2021 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 - -@class FBLPromise; - -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. - GACAppCheckBackoffTypeNone, - - /// Next retry will be allowed in 1 day (24 hours) after the failure. - GACAppCheckBackoffType1Day, - - /// A small backoff interval that exponentially increases after each consequent failure. - GACAppCheckBackoffTypeExponential -}; - -/// Creates a promise for an operation to apply the backoff to. -typedef FBLPromise *_Nonnull (^GACAppCheckBackoffOperationProvider)(void); - -/// Converts an error to a backoff type. -typedef GACAppCheckBackoffType (^GACAppCheckBackoffErrorHandler)(NSError *error); - -/// A block returning a date. Is used instead of `+[NSDate date]` for better testability of logic -/// dependent on the current time. -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 - -/// Conditionally applies backoff to the given operation. -/// @param operationProvider A block that returns a new promise. The block will be called only when -/// the operation is allowed. -/// NOTE: We cannot accept just a promise because the operation will be started once the -/// promise has been instantiated, so we need to have a way to instantiate the promise only -/// when the operation is good to go. The provider block is the way we use. -/// @param errorHandler A block that receives an operation error as an input and returns the -/// appropriate backoff type. `defaultErrorHandler` provides a default implementation for Firebase -/// services. -/// @return A promise that is either: -/// - a promise returned by the promise provider if no backoff is required -/// - rejected if the backoff is needed -- (FBLPromise *)applyBackoffToOperation:(GACAppCheckBackoffOperationProvider)operationProvider - errorHandler:(GACAppCheckBackoffErrorHandler)errorHandler; - -/// The default Firebase services error handler. It keeps track of network errors and -/// `GACAppCheckHTTPError.HTTPResponse.statusCode.statusCode` value to return the appropriate -/// backoff type for the standard Firebase App Check backend response codes. -- (GACAppCheckBackoffErrorHandler)defaultAppCheckProviderErrorHandler; - -@end - -/// 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 <_GACAppCheckBackoffWrapperProtocol> - -/// Initializes the wrapper with `+[GACAppCheckBackoffWrapper currentDateProvider]`. -- (instancetype)init; - -- (instancetype)initWithDateProvider:(GACAppCheckDateProvider)dateProvider - NS_DESIGNATED_INITIALIZER; - -/// A date provider that returns `+[NSDate date]`. -+ (GACAppCheckDateProvider)currentDateProvider; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h b/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h deleted file mode 100644 index 811cf0ed..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h +++ /dev/null @@ -1,75 +0,0 @@ -/* - * Copyright 2020 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 - -@class GACAppCheckHTTPError; - -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 - -+ (NSError *)publicDomainErrorWithError:(NSError *)error; - -// MARK: - Internal errors - -+ (NSError *)cachedTokenNotFound; - -+ (NSError *)cachedTokenExpired; - -+ (NSError *)keychainErrorWithError:(NSError *)error; - -+ (GACAppCheckHTTPError *)APIErrorWithHTTPResponse:(NSHTTPURLResponse *)HTTPResponse - data:(nullable NSData *)data; - -+ (NSError *)APIErrorWithNetworkError:(NSError *)networkError; - -+ (NSError *)appCheckTokenResponseErrorWithMissingField:(NSString *)fieldName; - -+ (NSError *)appAttestAttestationResponseErrorWithMissingField:(NSString *)fieldName; - -+ (NSError *)JSONSerializationError:(NSError *)error; - -+ (NSError *)errorWithFailureReason:(NSString *)failureReason; - -+ (NSError *)unsupportedAttestationProvider:(NSString *)providerName; - -+ (NSError *)missingRecaptchaSDKError; - -// MARK: - App Attest Errors - -+ (NSError *)appAttestKeyIDNotFound; - -+ (NSError *)appAttestGenerateKeyFailedWithError:(NSError *)error; - -+ (NSError *)appAttestAttestKeyFailedWithError:(NSError *)error - keyId:(NSString *)keyId - clientDataHash:(NSData *)clientDataHash; - -+ (NSError *)appAttestGenerateAssertionFailedWithError:(NSError *)error - keyId:(NSString *)keyId - clientDataHash:(NSData *)clientDataHash; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h b/AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h deleted file mode 100644 index 07aeb335..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2024 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 - -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 - -@property(nonatomic, readonly) NSHTTPURLResponse *HTTPResponse; -@property(nonatomic, nullable, readonly) NSData *HTTPBody; - -- (instancetype)initWithResponse:(NSHTTPURLResponse *)response HTTPBody:(nullable NSData *)body; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Integration/AppCheckCoreDeviceCheckAPIServiceE2ETests.swift b/AppCheckCore/Tests/Integration/AppCheckCoreDeviceCheckAPIServiceE2ETests.swift new file mode 100644 index 00000000..19ac8105 --- /dev/null +++ b/AppCheckCore/Tests/Integration/AppCheckCoreDeviceCheckAPIServiceE2ETests.swift @@ -0,0 +1,73 @@ +// 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 +import XCTest + +// TODO: Replace with real resource name to run on CI +private let kResourceName = "projects/test-project-id/google-app-id" + +// Tests that use the Keychain require a host app and Swift Package Manager +// does not support adding a host app to test targets. +#if !SWIFT_PACKAGE + + // Skip keychain tests on Catalyst and macOS. Tests are skipped because they + // involve interactions with the keychain that require a provisioning profile. + // See go/firebase-macos-keychain-popups for more details. + #if !targetEnvironment(macCatalyst) && !os(macOS) + + // TODO(ncooke3): Fix these tests up and get them running on CI. + + class AppCheckCoreDeviceCheckAPIServiceE2ETests: XCTestCase { + var deviceCheckAPIService: AppCheckCoreDeviceCheckAPIService! + var APIService: AppCheckCoreAPIService! + var URLSession: Foundation.URLSession! + + override func setUp() { + super.setUp() + URLSession = Foundation.URLSession(configuration: .default) + APIService = AppCheckCoreAPIService( + urlSession: URLSession, + baseURL: nil, + apiKey: nil, + requestHooks: nil + ) + deviceCheckAPIService = AppCheckCoreDeviceCheckAPIService( + apiService: APIService, + resourceName: kResourceName + ) + } + + override func tearDown() { + deviceCheckAPIService = nil + APIService = nil + URLSession = nil + super.tearDown() + } + + // TODO: Re-enable the test once secret with "GoogleService-Info.plist" is configured. + func temporaryDisabled_testAppCheckTokenSuccess() async throws { + let appCheckToken = try await deviceCheckAPIService.appCheckToken( + deviceToken: Data(), + limitedUse: false + ) + + XCTAssertNotNil(appCheckToken.token) + XCTAssertNotNil(appCheckToken.expirationDate) + } + } + + #endif // !targetEnvironment(macCatalyst) && !os(macOS) + +#endif // !SWIFT_PACKAGE diff --git a/AppCheckCore/Tests/Integration/GACDeviceCheckAPIServiceE2ETests.m b/AppCheckCore/Tests/Integration/GACDeviceCheckAPIServiceE2ETests.m deleted file mode 100644 index 83db330b..00000000 --- a/AppCheckCore/Tests/Integration/GACDeviceCheckAPIServiceE2ETests.m +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright 2020 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 - -// Tests that use the Keychain require a host app and Swift Package Manager -// does not support adding a host app to test targets. -#if !SWIFT_PACKAGE - -// Skip keychain tests on Catalyst and macOS. Tests are skipped because they -// involve interactions with the keychain that require a provisioning profile. -// See go/firebase-macos-keychain-popups for more details. -#if !TARGET_OS_MACCATALYST && !TARGET_OS_OSX - -#import - -#import "FBLPromise+Testing.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) NSURLSession *URLSession; -@end - -// TODO(ncooke3): Fix these tests up and get them running on CI. - -@implementation GACDeviceCheckAPIServiceE2ETests - -- (void)setUp { - self.URLSession = [NSURLSession - sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration]]; - - self.APIService = [[_GACAppCheckAPIService alloc] initWithURLSession:self.URLSession - baseURL:nil - APIKey:nil - requestHooks:nil]; - self.deviceCheckAPIService = [[GACDeviceCheckAPIService alloc] initWithAPIService:self.APIService - resourceName:kResourceName]; -} - -- (void)tearDown { - self.deviceCheckAPIService = nil; - self.APIService = nil; - self.URLSession = nil; -} - -// TODO: Re-enable the test once secret with "GoogleService-Info.plist" is configured. -- (void)temporaryDisabled_testAppCheckTokenSuccess { - __auto_type appCheckPromise = - [self.deviceCheckAPIService appCheckTokenWithDeviceToken:[NSData data] limitedUse:NO]; - - XCTAssert(FBLWaitForPromisesWithTimeout(20)); - - XCTAssertNil(appCheckPromise.error); - XCTAssertNotNil(appCheckPromise.value); - - XCTAssertNotNil(appCheckPromise.value.token); - XCTAssertNotNil(appCheckPromise.value.expirationDate); -} - -@end - -#endif // !TARGET_OS_MACCATALYST && !TARGET_OS_OSX - -#endif // !SWIFT_PACKAGE diff --git a/AppCheckCore/Tests/Unit/AppAttestProvider/AppCheckCoreAppAttestAPIServiceTests.swift b/AppCheckCore/Tests/Unit/AppAttestProvider/AppCheckCoreAppAttestAPIServiceTests.swift new file mode 100644 index 00000000..a748697d --- /dev/null +++ b/AppCheckCore/Tests/Unit/AppAttestProvider/AppCheckCoreAppAttestAPIServiceTests.swift @@ -0,0 +1,304 @@ +// 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 +import XCTest + +private class MockAppCheckAPIService: NSObject, AppCheckCoreAPIServiceProtocol { + var baseURL: String = "https://test.appcheck.url.com/beta" + + var passedRequestURL: URL? + var passedHTTPMethod: String? + var passedBody: Data? + var passedAdditionalHeaders: [String: String]? + + var sendRequestResult: Result? + var appCheckTokenResult: Result? + + var passedAPIResponse: AppCheckCoreURLSessionDataResponse? + + func sendRequest(withURL requestURL: URL, httpMethod: String, body: Data?, + additionalHeaders: [String: String]?) async throws + -> AppCheckCoreURLSessionDataResponse { + passedRequestURL = requestURL + passedHTTPMethod = httpMethod + passedBody = body + passedAdditionalHeaders = additionalHeaders + + if let result = sendRequestResult { + switch result { + case let .success(response): return response + case let .failure(error): throw error + } + } + throw NSError(domain: "MockAppCheckAPIService", code: -1, userInfo: nil) + } + + func appCheckToken(withAPIResponse response: AppCheckCoreURLSessionDataResponse) async throws + -> AppCheckCoreToken { + passedAPIResponse = response + if let result = appCheckTokenResult { + switch result { + case let .success(token): return token + case let .failure(error): throw error + } + } + throw NSError(domain: "MockAppCheckAPIService", code: -1, userInfo: nil) + } +} + +class AppCheckCoreAppAttestAPIServiceTests: XCTestCase { + var appAttestAPIService: AppCheckCoreAppAttestAPIService! + private var fakeAPIService: MockAppCheckAPIService! + + let kResourceName = "projects/project_id/apps/app_id" + + override func setUp() { + super.setUp() + + fakeAPIService = MockAppCheckAPIService() + appAttestAPIService = AppCheckCoreAppAttestAPIService( + apiService: fakeAPIService, + resourceName: kResourceName + ) + } + + override func tearDown() { + appAttestAPIService = nil + fakeAPIService = nil + super.tearDown() + } + + // MARK: - Random challenge request + + func testGetRandomChallengeWhenAPIResponseValid() async throws { + // 1. Prepare API response. + let challengeString = "random_challenge" + let responseDict: [String: Any] = [ + "challenge": challengeString.data(using: .utf8)!.base64EncodedString(), + ] + let responseBody = try JSONSerialization.data(withJSONObject: responseDict, options: []) + let validAPIResponse = APIResponse(code: 200, responseBody: responseBody) + + // 2. Stub API Service Request + fakeAPIService.sendRequestResult = .success(validAPIResponse) + + // 3. Request the random challenge and verify results. + let challenge = try await appAttestAPIService.getRandomChallenge() + + let retrievedString = String(data: challenge, encoding: .utf8) + XCTAssertEqual(retrievedString, challengeString) + + let expectedRequestURL = "\(fakeAPIService.baseURL)/\(kResourceName):generateAppAttestChallenge" + XCTAssertEqual(fakeAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(fakeAPIService.passedHTTPMethod, "POST") + } + + func testGetRandomChallengeWhenAPIError() async { + // 1. Prepare API response. + let responseBodyString = "Generate challenge failed with invalid format." + let responseBody = responseBodyString.data(using: .utf8)! + let invalidAPIResponse = APIResponse(code: 300, responseBody: responseBody) + let apiError = AppCheckCoreErrorUtil.apiError( + with: invalidAPIResponse.httpResponse, + data: invalidAPIResponse.httpBody + ) + + // 2. Stub API Service Request + fakeAPIService.sendRequestResult = .failure(apiError) + + // 3. Request the random challenge and verify results. + do { + _ = try await appAttestAPIService.getRandomChallenge() + XCTFail("Expected error to be thrown") + } catch let error as NSError { + XCTAssertEqual(error.domain, AppCheckCoreErrorDomain) + XCTAssertEqual(error.code, AppCheckCoreErrorCode.unknown.rawValue) + let failureReason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String + XCTAssertTrue(failureReason?.contains("300") ?? false) + XCTAssertTrue(failureReason?.contains(responseBodyString) ?? false) + } + + let expectedRequestURL = "\(fakeAPIService.baseURL)/\(kResourceName):generateAppAttestChallenge" + XCTAssertEqual(fakeAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(fakeAPIService.passedHTTPMethod, "POST") + } + + // MARK: - Assertion request + + func testGetAppCheckTokenSuccess() async throws { + try await testGetAppCheckTokenSuccess(withLimitedUse: false) + } + + func testGetAppCheckTokenSuccessWithLimitedUse() async throws { + try await testGetAppCheckTokenSuccess(withLimitedUse: true) + } + + func testGetAppCheckTokenSuccess(withLimitedUse limitedUse: Bool) async throws { + let artifact = generateRandomData() + let challenge = generateRandomData() + let assertion = generateRandomData() + + // 1. Prepare response. + let responseBody = "{}".data(using: .utf8)! + let validAPIResponse = APIResponse(code: 200, responseBody: responseBody) + + // 2. Stub API Service + fakeAPIService.sendRequestResult = .success(validAPIResponse) + + let expectedToken = AppCheckCoreToken(token: "app_check_token", expirationDate: Date()) + fakeAPIService.appCheckTokenResult = .success(expectedToken) + + // 3. Send request. + let token = try await appAttestAPIService.getAppCheckToken( + withArtifact: artifact, + challenge: challenge, + assertion: assertion, + limitedUse: limitedUse + ) + + // 4. Verify. + XCTAssertEqual(token.token, expectedToken.token) + XCTAssertEqual(token.expirationDate, expectedToken.expirationDate) + + let expectedRequestURL = "\(fakeAPIService.baseURL)/\(kResourceName):exchangeAppAttestAssertion" + XCTAssertEqual(fakeAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(fakeAPIService.passedHTTPMethod, "POST") + try assertTokenExchangeBody( + fakeAPIService.passedBody, + artifact: artifact, + challenge: challenge, + assertion: assertion, + limitedUse: limitedUse + ) + } + + // MARK: - Attestation request + + func testAttestKeySuccess() async throws { + try await testAttestKeySuccess(withLimitedUse: false) + } + + func testAttestKeySuccessWithLimitedUse() async throws { + try await testAttestKeySuccess(withLimitedUse: true) + } + + func testAttestKeySuccess(withLimitedUse limitedUse: Bool) async throws { + let attestation = generateRandomData() + let challenge = generateRandomData() + let keyID = UUID().uuidString + + // 1. Prepare response. + let expectedArtifactString = "valid Firebase app attest artifact" + let responseDict: [String: Any] = [ + "artifact": expectedArtifactString.data(using: .utf8)!.base64EncodedString(), + "appCheckToken": [ + "token": "valid_app_check_token", + "ttl": "1800s", + ], + ] + let responseBody = try JSONSerialization.data(withJSONObject: responseDict, options: []) + let validAPIResponse = APIResponse(code: 200, responseBody: responseBody) + + // 2. Stub API Service + fakeAPIService.sendRequestResult = .success(validAPIResponse) + + // 3. Send request. + let response = try await appAttestAPIService.attestKey( + withAttestation: attestation, + keyID: keyID, + challenge: challenge, + limitedUse: limitedUse + ) + + // 4. Verify. + let expectedArtifact = expectedArtifactString.data(using: .utf8)! + XCTAssertEqual(response.artifact, expectedArtifact) + XCTAssertEqual(response.token.token, "valid_app_check_token") + + let expectedRequestURL = + "\(fakeAPIService.baseURL)/\(kResourceName):exchangeAppAttestAttestation" + XCTAssertEqual(fakeAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(fakeAPIService.passedHTTPMethod, "POST") + try assertAttestKeyBody( + fakeAPIService.passedBody, + attestation: attestation, + challenge: challenge, + keyID: keyID, + limitedUse: limitedUse + ) + } + + // MARK: - Helpers + + private func APIResponse(code: Int, responseBody: Data) -> AppCheckCoreURLSessionDataResponse { + let httpResponse = HTTPURLResponse( + url: URL(string: "https://test.com")!, + statusCode: code, + httpVersion: nil, + headerFields: nil + )! + return AppCheckCoreURLSessionDataResponse(response: httpResponse, httpBody: responseBody) + } + + private func generateRandomData() -> Data { + return UUID().uuidString.data(using: .utf8)! + } + + private func assertTokenExchangeBody(_ requestBody: Data?, artifact: Data, challenge: Data, + assertion: Data, limitedUse: Bool) throws { + let unwrappedBody = try XCTUnwrap(requestBody) + let decodedData = try JSONSerialization + .jsonObject(with: unwrappedBody, options: []) as? [String: Any] + let unwrappedDecodedData = try XCTUnwrap(decodedData) + + let base64EncodedArtifact = try XCTUnwrap(unwrappedDecodedData["artifact"] as? String) + let decodedArtifact = try XCTUnwrap(Data(base64Encoded: base64EncodedArtifact)) + XCTAssertEqual(decodedArtifact, artifact) + + let base64EncodedChallenge = try XCTUnwrap(unwrappedDecodedData["challenge"] as? String) + let decodedChallenge = try XCTUnwrap(Data(base64Encoded: base64EncodedChallenge)) + XCTAssertEqual(decodedChallenge, challenge) + + let base64EncodedAssertion = try XCTUnwrap(unwrappedDecodedData["assertion"] as? String) + let decodedAssertion = try XCTUnwrap(Data(base64Encoded: base64EncodedAssertion)) + XCTAssertEqual(decodedAssertion, assertion) + + let decodedLimitedUse = try XCTUnwrap(unwrappedDecodedData["limited_use"] as? Bool) + XCTAssertEqual(decodedLimitedUse, limitedUse) + } + + private func assertAttestKeyBody(_ requestBody: Data?, attestation: Data, challenge: Data, + keyID: String, limitedUse: Bool) throws { + let unwrappedBody = try XCTUnwrap(requestBody) + let decodedData = try JSONSerialization + .jsonObject(with: unwrappedBody, options: []) as? [String: Any] + let unwrappedDecodedData = try XCTUnwrap(decodedData) + + let base64EncodedAttestation = + try XCTUnwrap(unwrappedDecodedData["attestation_statement"] as? String) + let decodedAttestation = try XCTUnwrap(Data(base64Encoded: base64EncodedAttestation)) + XCTAssertEqual(decodedAttestation, attestation) + + let base64EncodedChallenge = try XCTUnwrap(unwrappedDecodedData["challenge"] as? String) + let decodedChallenge = try XCTUnwrap(Data(base64Encoded: base64EncodedChallenge)) + XCTAssertEqual(decodedChallenge, challenge) + + let decodedKeyID = try XCTUnwrap(unwrappedDecodedData["key_id"] as? String) + XCTAssertEqual(decodedKeyID, keyID) + + let decodedLimitedUse = try XCTUnwrap(unwrappedDecodedData["limited_use"] as? Bool) + XCTAssertEqual(decodedLimitedUse, limitedUse) + } +} diff --git a/AppCheckCore/Tests/Unit/AppAttestProvider/AppCheckCoreAppAttestProviderTests.swift b/AppCheckCore/Tests/Unit/AppAttestProvider/AppCheckCoreAppAttestProviderTests.swift new file mode 100644 index 00000000..f1ba0461 --- /dev/null +++ b/AppCheckCore/Tests/Unit/AppAttestProvider/AppCheckCoreAppAttestProviderTests.swift @@ -0,0 +1,813 @@ +// 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 +import DeviceCheck +import XCTest + +@available(iOS 14.0, macOS 11.0, tvOS 15.0, watchOS 9.0, *) +class MockAppCheckCoreAppAttestService: NSObject, AppCheckCoreAppAttestService { + var isSupportedResult = true + var isSupported: Bool { return isSupportedResult } + + var generateKeyResults: [Result] = [] + var generateKeyCallCount = 0 + func generateKey(completionHandler: @escaping (String?, Error?) -> Void) { + let result = generateKeyResults[generateKeyCallCount] + generateKeyCallCount += 1 + switch result { + case let .success(keyId): completionHandler(keyId, nil) + case let .failure(error): completionHandler(nil, error) + } + } + + var attestKeyResults: [Result] = [] + var attestKeyCallCount = 0 + var attestKeyArgs: [(keyId: String, clientDataHash: Data)] = [] + func attestKey(_ keyId: String, clientDataHash: Data, + completionHandler: @escaping (Data?, Error?) -> Void) { + let result = attestKeyResults[attestKeyCallCount] + attestKeyCallCount += 1 + attestKeyArgs.append((keyId, clientDataHash)) + switch result { + case let .success(data): completionHandler(data, nil) + case let .failure(error): completionHandler(nil, error) + } + } + + var generateAssertionResults: [Result] = [] + var generateAssertionCallCount = 0 + var generateAssertionArgs: [(keyId: String, clientDataHash: Data)] = [] + func generateAssertion(_ keyId: String, clientDataHash: Data, + completionHandler: @escaping (Data?, Error?) -> Void) { + let result = generateAssertionResults[generateAssertionCallCount] + generateAssertionCallCount += 1 + generateAssertionArgs.append((keyId, clientDataHash)) + switch result { + case let .success(data): completionHandler(data, nil) + case let .failure(error): completionHandler(nil, error) + } + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 15.0, watchOS 9.0, *) +class MockAppAttestAPIService: NSObject, AppCheckCoreAppAttestAPIServiceProtocol { + var getRandomChallengeResults: [Result] = [] + var getRandomChallengeCallCount = 0 + func getRandomChallenge() async throws -> Data { + let result = getRandomChallengeResults[getRandomChallengeCallCount] + getRandomChallengeCallCount += 1 + return try result.get() + } + + var attestKeyResults: [Result] = [] + var attestKeyCallCount = 0 + var attestKeyArgs: [(attestation: Data, keyId: String, challenge: Data, limitedUse: Bool)] = [] + func attestKey(withAttestation attestation: Data, keyID: String, challenge: Data, + limitedUse: Bool) async throws -> AppCheckCoreAppAttestAttestationResponse { + let result = attestKeyResults[attestKeyCallCount] + attestKeyCallCount += 1 + attestKeyArgs.append((attestation, keyID, challenge, limitedUse)) + return try result.get() + } + + var getAppCheckCoreTokenResults: [Result] = [] + var getAppCheckCoreTokenCallCount = 0 + var getAppCheckCoreTokenArgs: [( + artifact: Data, + challenge: Data, + assertion: Data, + limitedUse: Bool + )] = [] + func getAppCheckToken(withArtifact artifact: Data, challenge: Data, assertion: Data, + limitedUse: Bool) async throws -> AppCheckCoreToken { + let result = getAppCheckCoreTokenResults[getAppCheckCoreTokenCallCount] + getAppCheckCoreTokenCallCount += 1 + getAppCheckCoreTokenArgs.append((artifact, challenge, assertion, limitedUse)) + return try result.get() + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 15.0, watchOS 9.0, *) +class MockAppAttestKeyIDStorage: NSObject, AppCheckCoreAppAttestKeyIDStorageProtocol { + var getAppAttestKeyIDResults: [Result] = [] + var getAppAttestKeyIDCallCount = 0 + func getAppAttestKeyID() async throws -> String? { + let result = getAppAttestKeyIDResults[getAppAttestKeyIDCallCount] + getAppAttestKeyIDCallCount += 1 + return try result.get() + } + + var setAppAttestKeyIDResults: [Result] = [] + var setAppAttestKeyIDCallCount = 0 + var setAppAttestKeyIDArgs: [String?] = [] + func setAppAttestKeyID(_ keyID: String?) async throws -> String? { + let result = setAppAttestKeyIDResults[setAppAttestKeyIDCallCount] + setAppAttestKeyIDCallCount += 1 + setAppAttestKeyIDArgs.append(keyID) + return try result.get() + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 15.0, watchOS 9.0, *) +class MockAppAttestArtifactStorage: NSObject, AppCheckCoreAppAttestArtifactStorageProtocol { + var getArtifactResults: [Result] = [] + var getArtifactCallCount = 0 + var getArtifactArgs: [String] = [] + func getArtifact(forKey keyID: String) async throws -> Data? { + let result = getArtifactResults[getArtifactCallCount] + getArtifactCallCount += 1 + getArtifactArgs.append(keyID) + return try result.get() + } + + var setArtifactResults: [Result] = [] + var setArtifactCallCount = 0 + var setArtifactArgs: [(artifact: Data?, keyId: String)] = [] + func setArtifact(_ artifact: Data?, forKey keyID: String) async throws -> Data? { + let result = setArtifactResults[setArtifactCallCount] + setArtifactCallCount += 1 + setArtifactArgs.append((artifact, keyID)) + return try result.get() + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 15.0, watchOS 9.0, *) +class FakeAppCheckBackoffWrapper: NSObject, AppCheckBackoffWrapperProtocol { + var isNextOperationAllowed: Bool = true + var backoffCalledCount = 0 + var defaultErrorHandler: ((Error) -> AppCheckBackoffType)? + + func defaultAppCheckProviderErrorHandler() -> (Error) -> AppCheckBackoffType { + return { error in .none } + } + + func applyBackoffToOperation(_ operation: @escaping () async throws -> Any, + errorHandler: @escaping (Error) -> AppCheckBackoffType) async throws + -> Any { + backoffCalledCount += 1 + guard isNextOperationAllowed else { + throw NSError(domain: "FakeBackoff", code: -1, userInfo: nil) + } + do { + return try await operation() + } catch { + if let defaultErrorHandler = defaultErrorHandler { + _ = defaultErrorHandler(error) + } else { + _ = errorHandler(error) + } + throw error + } + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 15.0, watchOS 9.0, *) +class AppCheckCoreAppAttestProviderTests: XCTestCase { + var provider: AppCheckCoreProvider! + var mockAppCheckCoreAppAttestService: MockAppCheckCoreAppAttestService! + var mockAPIService: MockAppAttestAPIService! + var mockStorage: MockAppAttestKeyIDStorage! + var mockArtifactStorage: MockAppAttestArtifactStorage! + var fakeBackoffWrapper: FakeAppCheckBackoffWrapper! + + var randomChallenge: Data! + var randomChallengeHash: Data! + + override func setUp() { + super.setUp() + resetMocks() + } + + func resetMocks() { + mockAppCheckCoreAppAttestService = MockAppCheckCoreAppAttestService() + mockAPIService = MockAppAttestAPIService() + mockStorage = MockAppAttestKeyIDStorage() + mockArtifactStorage = MockAppAttestArtifactStorage() + fakeBackoffWrapper = FakeAppCheckBackoffWrapper() + + provider = AppCheckCoreAppAttestProvider( + appAttestService: mockAppCheckCoreAppAttestService, + apiService: mockAPIService, + keyIDStorage: mockStorage, + artifactStorage: mockArtifactStorage, + backoffWrapper: fakeBackoffWrapper + ) + randomChallenge = "random challenge".data(using: .utf8)! + randomChallengeHash = Data(base64Encoded: "vEq8yE9g+WwfifNqC2wsXN9M3NIDeOKpDBVYLpGbUDY=")! + } + + override func tearDown() { + provider = nil + mockArtifactStorage = nil + mockStorage = nil + mockAPIService = nil + mockAppCheckCoreAppAttestService = nil + fakeBackoffWrapper = nil + super.tearDown() + } + + func dataHashForAssertion(withArtifactData artifact: Data) -> Data { + var statement = artifact + statement.append(randomChallenge) + return AppCheckCoreCryptoUtils.sha256Hash(from: statement) + } + + func attestationRejectionHTTPError() -> AppCheckCoreHTTPError { + let response = HTTPURLResponse( + url: URL(string: "http://localhost")!, + statusCode: 403, + httpVersion: "HTTP/1.1", + headerFields: nil + )! + let responseBody = "Could not verify attestation".data(using: .utf8)! + return AppCheckCoreHTTPError(httpResponse: response, data: responseBody) + } + + func expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested() { + mockAppCheckCoreAppAttestService.isSupportedResult = true + mockStorage.getAppAttestKeyIDResults.append(.success(nil)) + } + + func expectAppAttestKeyGeneratedAndAttested(withKeyID keyID: String, attestationData: Data) { + mockAppCheckCoreAppAttestService.generateKeyResults.append(.success(keyID)) + mockStorage.setAppAttestKeyIDResults.append(.success(keyID)) + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + mockAppCheckCoreAppAttestService.attestKeyResults.append(.success(attestationData)) + } + + func expectAttestationReset() { + mockStorage.setAppAttestKeyIDResults.append(.success(nil)) + mockArtifactStorage.setArtifactResults.append(.success(nil)) + } + + // MARK: - Initial handshake (attestation) + + func testGetTokenWhenAppAttestIsNotSupported() async { + mockAppCheckCoreAppAttestService.isSupportedResult = false + + let expectedError = AppCheckCoreErrorUtil.unsupportedAttestationProvider("AppAttestProvider") + + do { + _ = try await provider.getToken() + XCTFail("Should throw") + } catch { + XCTAssertEqual((error as NSError).code, (expectedError as NSError).code) + } + + XCTAssertEqual(fakeBackoffWrapper.backoffCalledCount, 1) + XCTAssertEqual(mockAppCheckCoreAppAttestService.generateKeyCallCount, 0) + XCTAssertEqual(mockStorage.getAppAttestKeyIDCallCount, 0) + } + + func testGetToken_WhenNoExistingKey_Success() async throws { + try await assertGetToken_WhenNoExistingKey_Success() + } + + func testGetToken_WhenExistingUnregisteredKey_Success() async throws { + mockAppCheckCoreAppAttestService.isSupportedResult = true + + let existingKeyID = "existingKeyID" + mockStorage.getAppAttestKeyIDResults.append(.success(existingKeyID)) + + mockArtifactStorage.getArtifactResults.append(.success(nil)) + + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + + let attestationData = "attestation data".data(using: .utf8)! + mockAppCheckCoreAppAttestService.attestKeyResults.append(.success(attestationData)) + + let facToken = AppCheckCoreToken(token: "FAC token", expirationDate: Date()) + let artifactData = "attestation artifact".data(using: .utf8)! + let attestKeyResponse = AppCheckCoreAppAttestAttestationResponse( + artifact: artifactData, + token: facToken + ) + mockAPIService.attestKeyResults.append(.success(attestKeyResponse)) + + mockArtifactStorage.setArtifactResults.append(.success(artifactData)) + + let token = try await provider.getToken() + + XCTAssertEqual(token.token, facToken.token) + XCTAssertEqual(token.expirationDate, facToken.expirationDate) + + XCTAssertEqual(fakeBackoffWrapper.backoffCalledCount, 1) + XCTAssertEqual(mockAppCheckCoreAppAttestService.generateKeyCallCount, 0) + XCTAssertEqual(mockStorage.setAppAttestKeyIDCallCount, 0) + XCTAssertEqual(mockAppCheckCoreAppAttestService.attestKeyArgs.first?.keyId, existingKeyID) + XCTAssertEqual( + mockAppCheckCoreAppAttestService.attestKeyArgs.first?.clientDataHash, + randomChallengeHash + ) + + XCTAssertEqual(mockAPIService.attestKeyArgs.first?.attestation, attestationData) + XCTAssertEqual(mockAPIService.attestKeyArgs.first?.keyId, existingKeyID) + XCTAssertEqual(mockAPIService.attestKeyArgs.first?.challenge, randomChallenge) + XCTAssertEqual(mockAPIService.attestKeyArgs.first?.limitedUse, false) + XCTAssertEqual(mockArtifactStorage.setArtifactArgs.first?.artifact, artifactData) + XCTAssertEqual(mockArtifactStorage.setArtifactArgs.first?.keyId, existingKeyID) + } + + func testGetToken_WhenUnregisteredKeyAndRandomChallengeError() async throws { + mockAppCheckCoreAppAttestService.isSupportedResult = true + let existingKeyID = "existingKeyID" + mockStorage.getAppAttestKeyIDResults.append(.success(existingKeyID)) + mockArtifactStorage.getArtifactResults.append(.success(nil)) + + let challengeError = NSError(domain: "testGetToken_WhenRandomChallengeError", code: NSNotFound) + mockAPIService.getRandomChallengeResults.append(.failure(challengeError)) + + do { + _ = try await provider.getToken() + XCTFail("Should throw") + } catch { + XCTAssertEqual((error as NSError).domain, challengeError.domain) + } + + XCTAssertEqual(fakeBackoffWrapper.backoffCalledCount, 1) + XCTAssertEqual(mockStorage.setAppAttestKeyIDCallCount, 0) + XCTAssertEqual(mockAppCheckCoreAppAttestService.attestKeyCallCount, 0) + XCTAssertEqual(mockAPIService.attestKeyCallCount, 0) + } + + func testGetToken_WhenUnregisteredKeyAndKeyAttestationError() async throws { + mockAppCheckCoreAppAttestService.isSupportedResult = true + let existingKeyID = "existingKeyID" + mockStorage.getAppAttestKeyIDResults.append(.success(existingKeyID)) + mockArtifactStorage.getArtifactResults.append(.success(nil)) + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + + let attestationError = NSError(domain: "test", code: 0) + let expectedError = AppCheckCoreErrorUtil.appAttestAttestKeyFailed( + with: attestationError, + keyId: existingKeyID, + clientDataHash: randomChallengeHash + ) + mockAppCheckCoreAppAttestService.attestKeyResults.append(.failure(attestationError)) + + do { + _ = try await provider.getToken() + XCTFail("Should throw") + } catch { + XCTAssertEqual((error as NSError).code, (expectedError as NSError).code) + } + + XCTAssertEqual(fakeBackoffWrapper.backoffCalledCount, 1) + XCTAssertEqual(mockAPIService.attestKeyCallCount, 0) + } + + func testGetToken_WhenUnregisteredKeyAndKeyAttestationExchangeError() async throws { + mockAppCheckCoreAppAttestService.isSupportedResult = true + let existingKeyID = "existingKeyID" + mockStorage.getAppAttestKeyIDResults.append(.success(existingKeyID)) + mockArtifactStorage.getArtifactResults.append(.success(nil)) + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + let attestationData = "attestation data".data(using: .utf8)! + mockAppCheckCoreAppAttestService.attestKeyResults.append(.success(attestationData)) + + let exchangeError = NSError(domain: "test", code: 0) + mockAPIService.attestKeyResults.append(.failure(exchangeError)) + + do { + _ = try await provider.getToken() + XCTFail("Should throw") + } catch { + XCTAssertEqual((error as NSError).domain, exchangeError.domain) + } + + XCTAssertEqual(fakeBackoffWrapper.backoffCalledCount, 1) + } + + // MARK: - Rejected Attestation + + func testGetToken_WhenAttestationIsRejected_ThenAttestationIsResetAndRetriedOnceSuccess() async throws { + expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested() + + let keyID1 = "keyID1" + let attestationData1 = UUID().uuidString.data(using: .utf8)! + expectAppAttestKeyGeneratedAndAttested(withKeyID: keyID1, attestationData: attestationData1) + + let apiError = attestationRejectionHTTPError() + mockAPIService.attestKeyResults.append(.failure(apiError)) + + expectAttestationReset() + expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested() + + let keyID2 = "keyID2" + let attestationData2 = UUID().uuidString.data(using: .utf8)! + expectAppAttestKeyGeneratedAndAttested(withKeyID: keyID2, attestationData: attestationData2) + + let facToken = AppCheckCoreToken(token: "FAC token", expirationDate: Date()) + let artifactData = "attestation artifact".data(using: .utf8)! + let attestKeyResponse = AppCheckCoreAppAttestAttestationResponse( + artifact: artifactData, + token: facToken + ) + mockAPIService.attestKeyResults.append(.success(attestKeyResponse)) + + mockArtifactStorage.setArtifactResults.append(.success(artifactData)) + + let token = try await provider.getToken() + + XCTAssertEqual(token.token, facToken.token) + XCTAssertEqual(token.expirationDate, facToken.expirationDate) + } + + func testGetToken_WhenAttestationIsRejected_ThenAttestationIsResetAndRetriedOnceError() async throws { + expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested() + + let keyID1 = "keyID1" + let attestationData1 = UUID().uuidString.data(using: .utf8)! + expectAppAttestKeyGeneratedAndAttested(withKeyID: keyID1, attestationData: attestationData1) + + let apiError = attestationRejectionHTTPError() + mockAPIService.attestKeyResults.append(.failure(apiError)) + + expectAttestationReset() + expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested() + + let keyID2 = "keyID2" + let attestationData2 = UUID().uuidString.data(using: .utf8)! + expectAppAttestKeyGeneratedAndAttested(withKeyID: keyID2, attestationData: attestationData2) + + mockAPIService.attestKeyResults.append(.failure(apiError)) + + expectAttestationReset() + + do { + _ = try await provider.getToken() + XCTFail("Should throw") + } catch { + XCTAssertTrue(error is AppCheckCoreHTTPError) + } + } + + func testGetToken_WhenExistingKeyIsRejectedByApple_ThenAttestationIsResetAndRetriedOnce_Success() async throws { + let invalidKeyError = NSError( + domain: DCErrorDomain, + code: DCError.invalidKey.rawValue, + userInfo: nil + ) + try await assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAttestationError( + invalidKeyError + ) + + resetMocks() + let invalidInputError = NSError( + domain: DCErrorDomain, + code: DCError.invalidInput.rawValue, + userInfo: nil + ) + try await assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAttestationError( + invalidInputError + ) + } + + // MARK: - FAC token refresh (assertion) + + func testGetToken_WhenKeyRegistered_Success() async throws { + try await assertGetToken_WhenKeyRegistered_Success() + } + + func testGetToken_WhenKeyRegisteredAndChallengeRequestError() async throws { + mockAppCheckCoreAppAttestService.isSupportedResult = true + let existingKeyID = "existingKeyID" + mockStorage.getAppAttestKeyIDResults.append(.success(existingKeyID)) + + let storedArtifact = "storedArtifact".data(using: .utf8)! + mockArtifactStorage.getArtifactResults.append(.success(storedArtifact)) + + let challengeError = NSError(domain: "testGetToken_WhenRandomChallengeError", code: NSNotFound) + mockAPIService.getRandomChallengeResults.append(.failure(challengeError)) + + do { + _ = try await provider.getToken() + XCTFail("Should throw") + } catch { + XCTAssertEqual((error as NSError).domain, challengeError.domain) + } + + XCTAssertEqual(mockAppCheckCoreAppAttestService.generateAssertionCallCount, 0) + XCTAssertEqual(mockAPIService.getAppCheckCoreTokenCallCount, 0) + } + + func testGetToken_WhenKeyRegisteredAndGenerateAssertionError() async throws { + mockAppCheckCoreAppAttestService.isSupportedResult = true + let existingKeyID = "existingKeyID" + mockStorage.getAppAttestKeyIDResults.append(.success(existingKeyID)) + + let storedArtifact = "storedArtifact".data(using: .utf8)! + mockArtifactStorage.getArtifactResults.append(.success(storedArtifact)) + + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + + let generateAssertionError = NSError( + domain: "testGetToken_WhenKeyRegisteredAndGenerateAssertionError", + code: 0 + ) + let clientDataHash = dataHashForAssertion(withArtifactData: storedArtifact) + let expectedError = AppCheckCoreErrorUtil.appAttestGenerateAssertionFailed( + with: generateAssertionError, + keyId: existingKeyID, + clientDataHash: clientDataHash + ) + + mockAppCheckCoreAppAttestService.generateAssertionResults + .append(.failure(generateAssertionError)) + + do { + _ = try await provider.getToken() + XCTFail("Should throw") + } catch { + XCTAssertEqual((error as NSError).code, (expectedError as NSError).code) + } + + XCTAssertEqual(mockAPIService.getAppCheckCoreTokenCallCount, 0) + } + + func testGetToken_WhenKeyRegisteredAndTokenExchangeRequestError() async throws { + mockAppCheckCoreAppAttestService.isSupportedResult = true + let existingKeyID = "existingKeyID" + mockStorage.getAppAttestKeyIDResults.append(.success(existingKeyID)) + + let storedArtifact = "storedArtifact".data(using: .utf8)! + mockArtifactStorage.getArtifactResults.append(.success(storedArtifact)) + + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + + let assertion = "generatedAssertion".data(using: .utf8)! + mockAppCheckCoreAppAttestService.generateAssertionResults.append(.success(assertion)) + + let tokenExchangeError = NSError( + domain: "testGetToken_WhenKeyRegisteredAndTokenExchangeRequestError", + code: 0 + ) + mockAPIService.getAppCheckCoreTokenResults.append(.failure(tokenExchangeError)) + + do { + _ = try await provider.getToken() + XCTFail("Should throw") + } catch { + XCTAssertEqual((error as NSError).domain, tokenExchangeError.domain) + } + } + + // MARK: - Rejected Assertion + + func testGetToken_WhenAssertionIsRejectedByApple_ThenResetToAttestationAndRetryOnceSuccess() async throws { + let invalidKeyError = NSError( + domain: DCErrorDomain, + code: DCError.invalidKey.rawValue, + userInfo: nil + ) + try await assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAssertionError( + invalidKeyError + ) + + resetMocks() + let invalidInputError = NSError( + domain: DCErrorDomain, + code: DCError.invalidInput.rawValue, + userInfo: nil + ) + try await assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAssertionError( + invalidInputError + ) + + resetMocks() + let systemFailureError = NSError( + domain: DCErrorDomain, + code: DCError.unknownSystemFailure.rawValue, + userInfo: nil + ) + try await assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAssertionError( + systemFailureError + ) + } + + // MARK: - Request merging + + func testGetToken_WhenCalledSeveralTimesSuccess_ThenThereIsOnlyOneOngoingHandshake() async throws { + mockAppCheckCoreAppAttestService.isSupportedResult = true + let existingKeyID = "existingKeyID" + mockStorage.getAppAttestKeyIDResults.append(.success(existingKeyID)) + + let storedArtifact = "storedArtifact".data(using: .utf8)! + mockArtifactStorage.getArtifactResults.append(.success(storedArtifact)) + + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + + let assertion = "generatedAssertion".data(using: .utf8)! + mockAppCheckCoreAppAttestService.generateAssertionResults.append(.success(assertion)) + + let facToken = AppCheckCoreToken(token: "FAC token", expirationDate: Date()) + mockAPIService.getAppCheckCoreTokenResults.append(.success(facToken)) + + let callsCount = 10 + async let results = withTaskGroup(of: AppCheckCoreToken?.self) { group in + for _ in 0 ..< callsCount { + group.addTask { + try? await self.provider.getToken() + } + } + var tokens = [AppCheckCoreToken?]() + for await token in group { + tokens.append(token) + } + return tokens + } + + let tokens = await results + for token in tokens { + XCTAssertEqual(token?.token, facToken.token) + XCTAssertEqual(token?.expirationDate, facToken.expirationDate) + } + + XCTAssertEqual(mockStorage.getAppAttestKeyIDCallCount, 1) + XCTAssertEqual(mockArtifactStorage.getArtifactCallCount, 1) + XCTAssertEqual(mockAPIService.getRandomChallengeCallCount, 1) + XCTAssertEqual(mockAppCheckCoreAppAttestService.generateAssertionCallCount, 1) + XCTAssertEqual(mockAPIService.getAppCheckCoreTokenCallCount, 1) + } + + func testGetToken_WhenCalledSeveralTimesError_ThenThereIsOnlyOneOngoingHandshake() async throws { + mockAppCheckCoreAppAttestService.isSupportedResult = true + let existingKeyID = "existingKeyID" + mockStorage.getAppAttestKeyIDResults.append(.success(existingKeyID)) + + let storedArtifact = "storedArtifact".data(using: .utf8)! + mockArtifactStorage.getArtifactResults.append(.success(storedArtifact)) + + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + + let assertion = "generatedAssertion".data(using: .utf8)! + mockAppCheckCoreAppAttestService.generateAssertionResults.append(.success(assertion)) + + let assertionRequestError = NSError(domain: "test", code: 0) + mockAPIService.getAppCheckCoreTokenResults.append(.failure(assertionRequestError)) + + let callsCount = 10 + async let results = withTaskGroup(of: Error?.self) { group in + for _ in 0 ..< callsCount { + group.addTask { + do { + _ = try await self.provider.getToken() + return nil + } catch { + return error + } + } + } + var errors = [Error?]() + for await error in group { + errors.append(error) + } + return errors + } + + let errors = await results + for error in errors { + XCTAssertEqual((error as NSError?)?.domain, assertionRequestError.domain) + } + + XCTAssertEqual(mockStorage.getAppAttestKeyIDCallCount, 1) + XCTAssertEqual(mockArtifactStorage.getArtifactCallCount, 1) + XCTAssertEqual(mockAPIService.getRandomChallengeCallCount, 1) + XCTAssertEqual(mockAppCheckCoreAppAttestService.generateAssertionCallCount, 1) + XCTAssertEqual(mockAPIService.getAppCheckCoreTokenCallCount, 1) + } + + // MARK: - Backoff tests + + func testGetTokenBackoff() async { + fakeBackoffWrapper.isNextOperationAllowed = false + + do { + _ = try await provider.getToken() + XCTFail("Should throw") + } catch { + XCTAssertEqual((error as NSError).domain, "FakeBackoff") + } + + XCTAssertEqual(fakeBackoffWrapper.backoffCalledCount, 1) + XCTAssertEqual(mockAppCheckCoreAppAttestService.generateKeyCallCount, 0) + XCTAssertEqual(mockStorage.getAppAttestKeyIDCallCount, 0) + } + + // MARK: - Helpers + + func assertGetToken_WhenNoExistingKey_Success() async throws { + expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested() + + let generatedKeyID = "generatedKeyID" + mockAppCheckCoreAppAttestService.generateKeyResults.append(.success(generatedKeyID)) + + mockStorage.setAppAttestKeyIDResults.append(.success(generatedKeyID)) + + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + + let attestationData = "attestation data".data(using: .utf8)! + mockAppCheckCoreAppAttestService.attestKeyResults.append(.success(attestationData)) + + let facToken = AppCheckCoreToken(token: "FAC token", expirationDate: Date()) + let artifactData = "attestation artifact".data(using: .utf8)! + let attestKeyResponse = AppCheckCoreAppAttestAttestationResponse( + artifact: artifactData, + token: facToken + ) + mockAPIService.attestKeyResults.append(.success(attestKeyResponse)) + + mockArtifactStorage.setArtifactResults.append(.success(artifactData)) + + let token = try await provider.getToken() + + XCTAssertEqual(token.token, facToken.token) + XCTAssertEqual(token.expirationDate, facToken.expirationDate) + } + + func assertGetToken_WhenKeyRegistered_Success() async throws { + mockAppCheckCoreAppAttestService.isSupportedResult = true + let existingKeyID = UUID().uuidString + mockStorage.getAppAttestKeyIDResults.append(.success(existingKeyID)) + + let storedArtifact = UUID().uuidString.data(using: .utf8)! + mockArtifactStorage.getArtifactResults.append(.success(storedArtifact)) + + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + + let assertion = UUID().uuidString.data(using: .utf8)! + mockAppCheckCoreAppAttestService.generateAssertionResults.append(.success(assertion)) + + let facToken = AppCheckCoreToken(token: UUID().uuidString, expirationDate: Date()) + mockAPIService.getAppCheckCoreTokenResults.append(.success(facToken)) + + let token = try await provider.getToken() + + XCTAssertEqual(token.token, facToken.token) + XCTAssertEqual(token.expirationDate, facToken.expirationDate) + } + + func assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAttestationError(_ error: NSError) async throws { + mockAppCheckCoreAppAttestService.isSupportedResult = true + let existingKeyID = "existingKeyID" + mockStorage.getAppAttestKeyIDResults.append(.success(existingKeyID)) + + mockArtifactStorage.getArtifactResults.append(.success(nil)) + + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + + mockAppCheckCoreAppAttestService.attestKeyResults.append(.failure(error)) + + expectAttestationReset() + expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested() + + let newKeyID = "newKeyID" + let attestationData = UUID().uuidString.data(using: .utf8)! + expectAppAttestKeyGeneratedAndAttested(withKeyID: newKeyID, attestationData: attestationData) + + let appCheckToken = AppCheckCoreToken(token: "App Check Token", expirationDate: Date()) + let artifactData = "attestation artifact".data(using: .utf8)! + let attestKeyResponse = AppCheckCoreAppAttestAttestationResponse( + artifact: artifactData, + token: appCheckToken + ) + mockAPIService.attestKeyResults.append(.success(attestKeyResponse)) + + mockArtifactStorage.setArtifactResults.append(.success(artifactData)) + + let token = try await provider.getToken() + + XCTAssertEqual(token.token, appCheckToken.token) + XCTAssertEqual(token.expirationDate, appCheckToken.expirationDate) + } + + func assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAssertionError(_ error: NSError) async throws { + mockAppCheckCoreAppAttestService.isSupportedResult = true + let existingKeyID = "existingKeyID" + mockStorage.getAppAttestKeyIDResults.append(.success(existingKeyID)) + + let storedArtifact = "storedArtifact".data(using: .utf8)! + mockArtifactStorage.getArtifactResults.append(.success(storedArtifact)) + + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + + mockAppCheckCoreAppAttestService.generateAssertionResults.append(.failure(error)) + + expectAttestationReset() + + // Assert that attestation is tried successfully. + try await assertGetToken_WhenNoExistingKey_Success() + } +} diff --git a/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestAPIServiceTests.m b/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestAPIServiceTests.m deleted file mode 100644 index d3157d0e..00000000 --- a/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestAPIServiceTests.m +++ /dev/null @@ -1,682 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "FBLPromise+Testing.h" - -#import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.h" -#import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.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/GACAppCheckAPIServiceFake.h" -#import "AppCheckCore/Tests/Unit/Utils/GACFixtureLoader.h" -#import "AppCheckCore/Tests/Unit/Utils/GACURLSessionFake.h" -#import "AppCheckCore/Tests/Utils/Date/GACDateTestUtils.h" - -static NSString *const kBaseURL = @"https://test.appcheck.url.com/beta"; -static NSString *const kResourceName = @"projects/project_id/apps/app_id"; - -@interface GACAppAttestAPIServiceTests : XCTestCase - -@property(nonatomic) GACAppAttestAPIService *appAttestAPIService; - -@property(nonatomic) GACAppCheckAPIServiceFake *fakeAPIService; - -@end - -@implementation GACAppAttestAPIServiceTests - -- (void)setUp { - [super setUp]; - - self.fakeAPIService = [[GACAppCheckAPIServiceFake alloc] init]; - self.fakeAPIService.baseURL = kBaseURL; - - self.appAttestAPIService = [[GACAppAttestAPIService alloc] initWithAPIService:self.fakeAPIService - resourceName:kResourceName]; -} - -- (void)tearDown { - [super tearDown]; - - self.appAttestAPIService = nil; - self.fakeAPIService = nil; -} - -#pragma mark - Random challenge request - -- (void)testGetRandomChallengeWhenAPIResponseValid { - // 1. Prepare API response. - NSData *responseBody = [GACFixtureLoader loadFixtureNamed:@"AppAttestResponseSuccess.json"]; - _GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; - // 2. Stub API Service Request to return prepared API response. - [self stubMockAPIServiceRequestForChallengeRequestWithResponse:validAPIResponse]; - - // 3. Request the random challenge and verify results. - __auto_type *promise = [self.appAttestAPIService getRandomChallenge]; - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - XCTAssert(promise.isFulfilled); - XCTAssertNotNil(promise.value); - XCTAssertNil(promise.error); - - NSString *challengeString = [[NSString alloc] initWithData:promise.value - encoding:NSUTF8StringEncoding]; - // The challenge stored in `AppAttestResponseSuccess.json` is a valid base64 encoding of - // the string "random_challenge". - XCTAssert([challengeString isEqualToString:@"random_challenge"]); - - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@/%@:%@", [self.fakeAPIService baseURL], kResourceName, - @"generateAppAttestChallenge"]; - XCTAssertEqualObjects(self.fakeAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.fakeAPIService.passedHTTPMethod, @"POST"); -} - -- (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]; - GACAppCheckHTTPError *APIError = - [_GACAppCheckErrorUtil APIErrorWithHTTPResponse:invalidAPIResponse.HTTPResponse - data:invalidAPIResponse.HTTPBody]; - // 2. Stub API Service Request to return prepared API response. - [self stubMockAPIServiceRequestForChallengeRequestWithResponse:APIError]; - - // 3. Request the random challenge and verify results. - __auto_type *promise = [self.appAttestAPIService getRandomChallenge]; - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - XCTAssert(promise.isRejected); - XCTAssertNotNil(promise.error); - XCTAssertNil(promise.value); - - // Assert error is as expected. - XCTAssertEqualObjects(promise.error.domain, GACAppCheckErrorDomain); - XCTAssertEqual(promise.error.code, GACAppCheckErrorCodeUnknown); - - // Expect response body and HTTP status code to be included in the error. - NSString *failureReason = promise.error.userInfo[NSLocalizedFailureReasonErrorKey]; - XCTAssertTrue([failureReason containsString:@"300"]); - XCTAssertTrue([failureReason containsString:responseBodyString]); - - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@/%@:%@", [self.fakeAPIService baseURL], kResourceName, - @"generateAppAttestChallenge"]; - XCTAssertEqualObjects(self.fakeAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.fakeAPIService.passedHTTPMethod, @"POST"); -} - -- (void)testGetRandomChallengeWhenAPIResponseEmpty { - // 1. Prepare API response. - NSData *responseBody = [NSData data]; - _GACURLSessionDataResponse *emptyAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; - // 2. Stub API Service Request to return prepared API response. - [self stubMockAPIServiceRequestForChallengeRequestWithResponse:emptyAPIResponse]; - - // 3. Request the random challenge and verify results. - __auto_type *promise = [self.appAttestAPIService getRandomChallenge]; - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - XCTAssert(promise.isRejected); - XCTAssertNotNil(promise.error); - XCTAssertNil(promise.value); - - // Expect response body and HTTP status code to be included in the error. - NSString *failureReason = promise.error.userInfo[NSLocalizedFailureReasonErrorKey]; - XCTAssertEqualObjects(failureReason, @"Empty server response body."); - - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@/%@:%@", [self.fakeAPIService baseURL], kResourceName, - @"generateAppAttestChallenge"]; - XCTAssertEqualObjects(self.fakeAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.fakeAPIService.passedHTTPMethod, @"POST"); -} - -- (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]; - // 2. Stub API Service Request to return prepared API response. - [self stubMockAPIServiceRequestForChallengeRequestWithResponse:validAPIResponse]; - - // 3. Request the random challenge and verify results. - __auto_type *promise = [self.appAttestAPIService getRandomChallenge]; - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - XCTAssert(promise.isRejected); - XCTAssertNotNil(promise.error); - XCTAssertNil(promise.value); - - // Expect response body and HTTP status code to be included in the error. - NSString *failureReason = promise.error.userInfo[NSLocalizedFailureReasonErrorKey]; - XCTAssertEqualObjects(failureReason, @"JSON serialization error."); - - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@/%@:%@", [self.fakeAPIService baseURL], kResourceName, - @"generateAppAttestChallenge"]; - XCTAssertEqualObjects(self.fakeAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.fakeAPIService.passedHTTPMethod, @"POST"); -} - -- (void)testGetRandomChallengeWhenResponseMissingField { - [self assertMissingFieldErrorWithFixture:@"AppAttestResponseMissingChallenge.json" - missingField:@"challenge"]; -} - -- (void)assertMissingFieldErrorWithFixture:(NSString *)fixtureName - missingField:(NSString *)fieldName { - // 1. Prepare API response. - NSData *missingFieldBody = [GACFixtureLoader loadFixtureNamed:fixtureName]; - _GACURLSessionDataResponse *incompleteAPIResponse = [self APIResponseWithCode:200 - responseBody:missingFieldBody]; - // 2. Stub API Service Request to return prepared API response. - [self stubMockAPIServiceRequestForChallengeRequestWithResponse:incompleteAPIResponse]; - - // 3. Request the random challenge and verify results. - __auto_type *promise = [self.appAttestAPIService getRandomChallenge]; - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - XCTAssert(promise.isRejected); - XCTAssertNotNil(promise.error); - XCTAssertNil(promise.value); - - // Assert error is as expected. - XCTAssertEqualObjects(promise.error.domain, GACAppCheckErrorDomain); - XCTAssertEqual(promise.error.code, GACAppCheckErrorCodeUnknown); - - // Expect missing field name to be included in the error. - NSString *failureReason = promise.error.userInfo[NSLocalizedFailureReasonErrorKey]; - NSString *fieldNameString = [NSString stringWithFormat:@"`%@`", fieldName]; - XCTAssertTrue([failureReason containsString:fieldNameString], - @"Fixture `%@`: expected missing field %@ error not found", fixtureName, - fieldNameString); -} - -#pragma mark - Assertion request - -- (void)testGetAppCheckTokenSuccess { - [self testGetAppCheckTokenSuccessWithLimitedUse:NO]; -} - -- (void)testGetAppCheckTokenSuccessWithLimitedUse { - [self testGetAppCheckTokenSuccessWithLimitedUse:YES]; -} - -- (void)testGetAppCheckTokenSuccessWithLimitedUse:(BOOL)limitedUse { - NSData *artifact = [self generateRandomData]; - NSData *challenge = [self generateRandomData]; - NSData *assertion = [self generateRandomData]; - - // 1. Prepare response. - NSData *responseBody = - [GACFixtureLoader loadFixtureNamed:@"FACTokenExchangeResponseSuccess.json"]; - _GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; - - // 2. Stub API Service - // 2.1. Return prepared response. - [self expectTokenAPIRequestWithArtifact:artifact - challenge:challenge - assertion:assertion - limitedUse:limitedUse - response:validAPIResponse - error:nil]; - // 2.2. Return token from parsed response. - GACAppCheckToken *expectedToken = [[GACAppCheckToken alloc] initWithToken:@"app_check_token" - expirationDate:[NSDate date] - receivedAtDate:[NSDate date]]; - [self expectTokenWithAPIReponse:validAPIResponse toReturnToken:expectedToken]; - - // 3. Send request. - __auto_type promise = [self.appAttestAPIService getAppCheckTokenWithArtifact:artifact - challenge:challenge - assertion:assertion - limitedUse:limitedUse]; - // 4. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(promise.isFulfilled); - XCTAssertNil(promise.error); - - XCTAssertEqualObjects(promise.value, expectedToken); - XCTAssertEqualObjects(promise.value.token, expectedToken.token); - XCTAssertEqualObjects(promise.value.expirationDate, expectedToken.expirationDate); - XCTAssertEqualObjects(promise.value.receivedAtDate, expectedToken.receivedAtDate); - - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@/%@:%@", [self.fakeAPIService baseURL], kResourceName, - @"exchangeAppAttestAssertion"]; - XCTAssertEqualObjects(self.fakeAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.fakeAPIService.passedHTTPMethod, @"POST"); - XCTAssertEqualObjects(self.fakeAPIService.passedAdditionalHeaders[@"Content-Type"], - @"application/json"); - [self assertTokenExchangeBody:self.fakeAPIService.passedBody - artifact:artifact - challenge:challenge - assertion:assertion - limitedUse:limitedUse]; -} - -- (void)testGetAppCheckTokenNetworkError { - NSData *artifact = [self generateRandomData]; - NSData *challenge = [self generateRandomData]; - NSData *assertion = [self generateRandomData]; - - // 1. Prepare response. - NSData *responseBody = - [GACFixtureLoader loadFixtureNamed:@"FACTokenExchangeResponseSuccess.json"]; - _GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; - - // 2. Stub API Service - // 2.1. Return prepared response. - NSError *networkError = [NSError errorWithDomain:self.name code:0 userInfo:nil]; - [self expectTokenAPIRequestWithArtifact:artifact - challenge:challenge - assertion:assertion - limitedUse:NO - response:validAPIResponse - error:networkError]; - - // 3. Send request. - __auto_type promise = [self.appAttestAPIService getAppCheckTokenWithArtifact:artifact - challenge:challenge - assertion:assertion - limitedUse:NO]; - // 4. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(promise.isRejected); - XCTAssertNil(promise.value); - XCTAssertEqualObjects(promise.error, networkError); - - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@/%@:%@", [self.fakeAPIService baseURL], kResourceName, - @"exchangeAppAttestAssertion"]; - XCTAssertEqualObjects(self.fakeAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.fakeAPIService.passedHTTPMethod, @"POST"); - XCTAssertEqualObjects(self.fakeAPIService.passedAdditionalHeaders[@"Content-Type"], - @"application/json"); - [self assertTokenExchangeBody:self.fakeAPIService.passedBody - artifact:artifact - challenge:challenge - assertion:assertion - limitedUse:NO]; -} - -- (void)testGetAppCheckTokenUnexpectedResponse { - NSData *artifact = [self generateRandomData]; - NSData *challenge = [self generateRandomData]; - NSData *assertion = [self generateRandomData]; - - // 1. Prepare response. - NSData *responseBody = - [GACFixtureLoader loadFixtureNamed:@"DeviceCheckResponseMissingToken.json"]; - _GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; - - // 2. Stub API Service - // 2.1. Return prepared response. - [self expectTokenAPIRequestWithArtifact:artifact - challenge:challenge - assertion:assertion - limitedUse:NO - response:validAPIResponse - error:nil]; - // 2.2. Return token from parsed response. - [self expectTokenWithAPIReponse:validAPIResponse toReturnToken:nil]; - - // 3. Send request. - __auto_type promise = [self.appAttestAPIService getAppCheckTokenWithArtifact:artifact - challenge:challenge - assertion:assertion - limitedUse:NO]; - // 4. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(promise.isRejected); - XCTAssertNil(promise.value); - XCTAssertNotNil(promise.error); - - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@/%@:%@", [self.fakeAPIService baseURL], kResourceName, - @"exchangeAppAttestAssertion"]; - XCTAssertEqualObjects(self.fakeAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.fakeAPIService.passedHTTPMethod, @"POST"); - XCTAssertEqualObjects(self.fakeAPIService.passedAdditionalHeaders[@"Content-Type"], - @"application/json"); - [self assertTokenExchangeBody:self.fakeAPIService.passedBody - artifact:artifact - challenge:challenge - assertion:assertion - limitedUse:NO]; -} - -#pragma mark - Attestation request - -- (void)testAttestKeySuccess { - [self testAttestKeySuccessWithLimitedUse:NO]; -} - -- (void)testAttestKeySuccessWithLimitedUse { - [self testAttestKeySuccessWithLimitedUse:YES]; -} - -- (void)testAttestKeySuccessWithLimitedUse:(BOOL)limitedUse { - NSData *attestation = [self generateRandomData]; - NSData *challenge = [self generateRandomData]; - NSString *keyID = [NSUUID UUID].UUIDString; - - // 1. Prepare response. - NSData *responseBody = - [GACFixtureLoader loadFixtureNamed:@"AppAttestAttestationResponseSuccess.json"]; - _GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; - - // 2. Stub API Service - // 2.1. Return prepared response. - [self expectAttestAPIRequestWithAttestation:attestation - keyID:keyID - challenge:challenge - limitedUse:limitedUse - response:validAPIResponse - error:nil]; - - // 3. Send request. - __auto_type promise = [self.appAttestAPIService attestKeyWithAttestation:attestation - keyID:keyID - challenge:challenge - limitedUse:limitedUse]; - - // 4. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(promise.isFulfilled); - XCTAssertNil(promise.error); - - NSData *expectedArtifact = - [@"valid Firebase app attest artifact" dataUsingEncoding:NSUTF8StringEncoding]; - - XCTAssertEqualObjects(promise.value.artifact, expectedArtifact); - XCTAssertEqualObjects(promise.value.token.token, @"valid_app_check_token"); - XCTAssertTrue([GACDateTestUtils isDate:promise.value.token.expirationDate - approximatelyEqualCurrentPlusTimeInterval:1800 - precision:10]); - - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@/%@:%@", [self.fakeAPIService baseURL], kResourceName, - @"exchangeAppAttestAttestation"]; - XCTAssertEqualObjects(self.fakeAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.fakeAPIService.passedHTTPMethod, @"POST"); - XCTAssertEqualObjects(self.fakeAPIService.passedAdditionalHeaders[@"Content-Type"], - @"application/json"); - [self assertAttestKeyBody:self.fakeAPIService.passedBody - attestation:attestation - challenge:challenge - keyID:keyID - limitedUse:limitedUse]; -} - -- (void)testAttestKeyNetworkError { - NSData *attestation = [self generateRandomData]; - NSData *challenge = [self generateRandomData]; - NSString *keyID = [NSUUID UUID].UUIDString; - - // 1. Stub API Service - // 1.1. Return prepared response. - NSError *networkError = [NSError errorWithDomain:self.name code:0 userInfo:nil]; - [self expectAttestAPIRequestWithAttestation:attestation - keyID:keyID - challenge:challenge - limitedUse:NO - response:nil - error:networkError]; - - // 2. Send request. - __auto_type promise = [self.appAttestAPIService attestKeyWithAttestation:attestation - keyID:keyID - challenge:challenge - limitedUse:NO]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(promise.isRejected); - XCTAssertNil(promise.value); - XCTAssertEqualObjects(promise.error, networkError); - - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@/%@:%@", [self.fakeAPIService baseURL], kResourceName, - @"exchangeAppAttestAttestation"]; - XCTAssertEqualObjects(self.fakeAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.fakeAPIService.passedHTTPMethod, @"POST"); - XCTAssertEqualObjects(self.fakeAPIService.passedAdditionalHeaders[@"Content-Type"], - @"application/json"); - [self assertAttestKeyBody:self.fakeAPIService.passedBody - attestation:attestation - challenge:challenge - keyID:keyID - limitedUse:NO]; -} - -- (void)testAttestKeyUnexpectedResponse { - NSData *attestation = [self generateRandomData]; - NSData *challenge = [self generateRandomData]; - NSString *keyID = [NSUUID UUID].UUIDString; - - // 1. Prepare unexpected response. - NSData *responseBody = - [GACFixtureLoader loadFixtureNamed:@"FACTokenExchangeResponseSuccess.json"]; - _GACURLSessionDataResponse *validAPIResponse = [self APIResponseWithCode:200 - responseBody:responseBody]; - - // 2. Stub API Service - // 2.1. Return prepared response. - [self expectAttestAPIRequestWithAttestation:attestation - keyID:keyID - challenge:challenge - limitedUse:NO - response:validAPIResponse - error:nil]; - - // 3. Send request. - __auto_type promise = [self.appAttestAPIService attestKeyWithAttestation:attestation - keyID:keyID - challenge:challenge - limitedUse:NO]; - - // 4. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(promise.isRejected); - XCTAssertNil(promise.value); - XCTAssertNotNil(promise.error); - - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@/%@:%@", [self.fakeAPIService baseURL], kResourceName, - @"exchangeAppAttestAttestation"]; - XCTAssertEqualObjects(self.fakeAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.fakeAPIService.passedHTTPMethod, @"POST"); - XCTAssertEqualObjects(self.fakeAPIService.passedAdditionalHeaders[@"Content-Type"], - @"application/json"); - [self assertAttestKeyBody:self.fakeAPIService.passedBody - attestation:attestation - challenge:challenge - keyID:keyID - limitedUse:NO]; -} - -#pragma mark - Helpers - -- (_GACURLSessionDataResponse *)APIResponseWithCode:(NSInteger)code - responseBody:(NSData *)responseBody { - XCTAssertNotNil(responseBody); - NSHTTPURLResponse *HTTPResponse = [GACURLSessionFake HTTPResponseWithCode:code]; - _GACURLSessionDataResponse *APIResponse = - [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; - return APIResponse; -} - -- (void)stubMockAPIServiceRequestForChallengeRequestWithResponse:(id)response { - FBLPromise *resultPromise = [FBLPromise pendingPromise]; - if ([response isKindOfClass:[NSError class]]) { - [resultPromise reject:response]; - } else { - [resultPromise fulfill:response]; - } - self.fakeAPIService.sendRequestPromise = resultPromise; - self.fakeAPIService.requestValidationBlock = ^{ - XCTAssertFalse([NSThread isMainThread], - @"Network requests must not be made on the main thread."); - }; -} - -- (void)expectTokenAPIRequestWithArtifact:(NSData *)attestation - challenge:(NSData *)challenge - assertion:(NSData *)assertion - limitedUse:(BOOL)limitedUse - response:(nullable _GACURLSessionDataResponse *)response - error:(nullable NSError *)error { - FBLPromise *responsePromise = [FBLPromise pendingPromise]; - if (error) { - [responsePromise reject:error]; - } else { - [responsePromise fulfill:response]; - } - self.fakeAPIService.sendRequestPromise = responsePromise; -} - -- (void)assertTokenExchangeBody:(NSData *)requestBody - artifact:(NSData *)attestation - challenge:(NSData *)challenge - assertion:(NSData *)assertion - limitedUse:(BOOL)limitedUse { - NSDictionary *decodedData = [NSJSONSerialization JSONObjectWithData:requestBody - options:0 - error:nil]; - - XCTAssert([decodedData isKindOfClass:[NSDictionary class]]); - - // Validate artifact field. - NSString *base64EncodedArtifact = decodedData[@"artifact"]; - XCTAssert([base64EncodedArtifact isKindOfClass:[NSString class]]); - - NSData *decodedAttestation = [[NSData alloc] initWithBase64EncodedString:base64EncodedArtifact - options:0]; - XCTAssertEqualObjects(decodedAttestation, attestation); - - // Validate challenge field. - NSString *base64EncodedChallenge = decodedData[@"challenge"]; - XCTAssert([base64EncodedChallenge isKindOfClass:[NSString class]]); - - NSData *decodedChallenge = [[NSData alloc] initWithBase64EncodedString:base64EncodedChallenge - options:0]; - XCTAssertEqualObjects(decodedChallenge, challenge); - - // Validate assertion field. - NSString *base64EncodedAssertion = decodedData[@"assertion"]; - XCTAssert([base64EncodedAssertion isKindOfClass:[NSString class]]); - - // Validate limited-use field. - NSNumber *decodedLimitedUse = decodedData[@"limited_use"]; - XCTAssertNotNil(decodedLimitedUse); - XCTAssertEqualObjects(decodedLimitedUse, @(limitedUse)); - - NSData *decodedAssertion = [[NSData alloc] initWithBase64EncodedString:base64EncodedAssertion - options:0]; - XCTAssertEqualObjects(decodedAssertion, assertion); -} - -- (void)expectTokenWithAPIReponse:(nonnull _GACURLSessionDataResponse *)response - toReturnToken:(nullable GACAppCheckToken *)token { - FBLPromise *tokenPromise = [FBLPromise pendingPromise]; - if (token) { - [tokenPromise fulfill:token]; - } else { - NSError *tokenError = [NSError errorWithDomain:self.name code:0 userInfo:nil]; - [tokenPromise reject:tokenError]; - } - self.fakeAPIService.appCheckTokenPromise = tokenPromise; -} - -- (void)expectAttestAPIRequestWithAttestation:(NSData *)attestation - keyID:(NSString *)keyID - challenge:(NSData *)challenge - limitedUse:(BOOL)limitedUse - response:(nullable _GACURLSessionDataResponse *)response - error:(nullable NSError *)error { - FBLPromise *resultPromise = [FBLPromise pendingPromise]; - if (error) { - [resultPromise reject:error]; - } else { - [resultPromise fulfill:response]; - } - - self.fakeAPIService.sendRequestPromise = resultPromise; -} - -- (void)assertAttestKeyBody:(NSData *)requestBody - attestation:(NSData *)attestation - challenge:(NSData *)challenge - keyID:(NSString *)keyID - limitedUse:(BOOL)limitedUse { - NSDictionary *decodedData = [NSJSONSerialization JSONObjectWithData:requestBody - options:0 - error:nil]; - - XCTAssert([decodedData isKindOfClass:[NSDictionary class]]); - - // Validate attestation field. - NSString *base64EncodedAttestation = decodedData[@"attestation_statement"]; - XCTAssert([base64EncodedAttestation isKindOfClass:[NSString class]]); - - NSData *decodedAttestation = [[NSData alloc] initWithBase64EncodedString:base64EncodedAttestation - options:0]; - XCTAssertEqualObjects(decodedAttestation, attestation); - - // Validate challenge field. - NSString *base64EncodedChallenge = decodedData[@"challenge"]; - XCTAssert([base64EncodedChallenge isKindOfClass:[NSString class]]); - - NSData *decodedChallenge = [[NSData alloc] initWithBase64EncodedString:base64EncodedChallenge - options:0]; - XCTAssertEqualObjects(decodedChallenge, challenge); - - // Validate key ID field. - NSString *keyIDField = decodedData[@"key_id"]; - XCTAssert([keyIDField isKindOfClass:[NSString class]]); - - // Validate limited-use field. - NSNumber *decodedLimitedUse = decodedData[@"limited_use"]; - XCTAssertNotNil(decodedLimitedUse); - XCTAssertEqualObjects(decodedLimitedUse, @(limitedUse)); - - XCTAssertEqualObjects(keyIDField, keyID); -} - -- (NSData *)generateRandomData { - return [[NSUUID UUID].UUIDString dataUsingEncoding:NSUTF8StringEncoding]; -} - -@end diff --git a/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestProviderTests.m b/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestProviderTests.m deleted file mode 100644 index 4054a9e0..00000000 --- a/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestProviderTests.m +++ /dev/null @@ -1,1091 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppAttestProvider.h" - -#import -#import - -#import "FBLPromise+Testing.h" - -#import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.h" -#import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAttestationResponse.h" -#import "AppCheckCore/Sources/AppAttestProvider/GACAppAttestService.h" -#import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.h" -#import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.h" -#import "AppCheckCore/Sources/Core/Utils/GACAppCheckCryptoUtils.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" - -#import "AppCheckCore/Sources/AppAttestProvider/Errors/GACAppAttestRejectionError.h" -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -#import "AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.h" - -#import "AppCheckCore/Tests/Unit/Utils/GACAppAttestAPIServiceFake.h" -#import "AppCheckCore/Tests/Unit/Utils/GACAppAttestArtifactStorageFake.h" -#import "AppCheckCore/Tests/Unit/Utils/GACAppAttestKeyIDStorageFake.h" -#import "AppCheckCore/Tests/Unit/Utils/GACAppAttestServiceFake.h" - -GAC_APP_ATTEST_PROVIDER_AVAILABILITY -@interface GACAppAttestProvider (Tests) -- (instancetype)initWithAppAttestService:(id)appAttestService - APIService:(id)APIService - keyIDStorage:(id)keyIDStorage - artifactStorage:(id)artifactStorage - backoffWrapper:(id<_GACAppCheckBackoffWrapperProtocol>)backoffWrapper; -@end - -GAC_APP_ATTEST_PROVIDER_AVAILABILITY -@interface GACAppAttestProviderTests : XCTestCase - -@property(nonatomic) GACAppAttestProvider *provider; - -@property(nonatomic) GACAppAttestServiceFake *fakeAppAttestService; -@property(nonatomic) GACAppAttestAPIServiceFake *fakeAPIService; -@property(nonatomic) GACAppAttestKeyIDStorageFake *fakeStorage; -@property(nonatomic) GACAppAttestArtifactStorageFake *fakeArtifactStorage; - -@property(nonatomic) NSData *randomChallenge; -@property(nonatomic) NSData *randomChallengeHash; - -@property(nonatomic) GACAppCheckBackoffWrapperFake *fakeBackoffWrapper; - -- (void)assertGetToken_WhenNoExistingKey_Success; -- (void)assertGetToken_WhenKeyRegistered_Success; -- (void)assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAttestationError: - (NSError *)error; -- (void)assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAssertionError: - (NSError *)error; -- (void)expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested; -- (void)expectAppAttestKeyGeneratedAndAttestedWithKeyID:(NSString *)keyID - attestationData:(NSData *)attestationData; -- (void)expectAttestationReset; -- (NSError *)expectRandomChallengeRequestError; -- (FBLPromise *)rejectedPromiseWithError:(NSError *)error; -- (GACAppCheckHTTPError *)attestationRejectionHTTPError; - -@end - -@implementation GACAppAttestProviderTests - -- (void)setUp { - [super setUp]; - - self.fakeAppAttestService = [[GACAppAttestServiceFake alloc] init]; - self.fakeAPIService = [[GACAppAttestAPIServiceFake alloc] init]; - self.fakeStorage = [[GACAppAttestKeyIDStorageFake alloc] init]; - self.fakeArtifactStorage = [[GACAppAttestArtifactStorageFake alloc] init]; - - self.fakeBackoffWrapper = [[GACAppCheckBackoffWrapperFake alloc] init]; - // Don't backoff by default. - self.fakeBackoffWrapper.isNextOperationAllowed = YES; - - self.provider = [[GACAppAttestProvider alloc] initWithAppAttestService:self.fakeAppAttestService - APIService:self.fakeAPIService - keyIDStorage:self.fakeStorage - artifactStorage:self.fakeArtifactStorage - backoffWrapper:self.fakeBackoffWrapper]; - - self.randomChallenge = [@"random challenge" dataUsingEncoding:NSUTF8StringEncoding]; - self.randomChallengeHash = - [[NSData alloc] initWithBase64EncodedString:@"vEq8yE9g+WwfifNqC2wsXN9M3NIDeOKpDBVYLpGbUDY=" - options:0]; -} - -- (void)tearDown { - self.provider = nil; - self.fakeArtifactStorage = nil; - self.fakeStorage = nil; - self.fakeAPIService = nil; - self.fakeAppAttestService = nil; - self.fakeBackoffWrapper = nil; -} - -#pragma mark - Initial handshake (attestation) - -- (void)testGetTokenWhenAppAttestIsNotSupported { - NSError *expectedError = - [_GACAppCheckErrorUtil unsupportedAttestationProvider:@"AppAttestProvider"]; - - // 0.1. Expect backoff wrapper to be used. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 0.2. Expect default error handler to be used. - XCTestExpectation *errorHandlerExpectation = [self expectationWithDescription:@"Error handler"]; - self.fakeBackoffWrapper.defaultErrorHandler = ^GACAppCheckBackoffType(NSError *_Nonnull error) { - XCTAssertEqualObjects(error, expectedError); - [errorHandlerExpectation fulfill]; - return GACAppCheckBackoffType1Day; - }; - - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = NO; - - // 3. Call get token. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertNil(token); - XCTAssertEqualObjects(error, expectedError); - }]; - - [self waitForExpectations:@[ - self.fakeBackoffWrapper.backoffExpectation, errorHandlerExpectation, completionExpectation - ] - timeout:0.5 - enforceOrder:YES]; - - // 4. Verify mocks. - XCTAssertEqual(self.fakeAppAttestService.generateKeyCallCount, 0); - XCTAssertEqual(self.fakeAppAttestService.attestKeyCallCount, 0); - XCTAssertEqual(self.fakeAPIService.getRandomChallengeCallCount, 0); - XCTAssertEqual(self.fakeStorage.setAppAttestKeyIDCallCount, 0); - XCTAssertEqual(self.fakeArtifactStorage.getArtifactCallCount, 0); -} - -- (void)testGetToken_WhenNoExistingKey_Success { - [self assertGetToken_WhenNoExistingKey_Success]; -} - -- (void)testGetToken_WhenExistingUnregisteredKey_Success { - // 0. Expect backoff wrapper to be used. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = YES; - - // 2. Expect storage getAppAttestKeyID. - NSString *existingKeyID = @"existingKeyID"; - self.fakeStorage.getAppAttestKeyIDPromise = [FBLPromise resolvedWith:existingKeyID]; - - // 5. Expect a stored artifact to be requested. - __auto_type rejectedPromise = [self rejectedPromiseWithError:[NSError errorWithDomain:self.name - code:NSNotFound - userInfo:nil]]; - self.fakeArtifactStorage.getArtifactPromise = rejectedPromise; - - // 6. Expect random challenge to be requested. - self.fakeAPIService.getRandomChallengePromise = [FBLPromise resolvedWith:self.randomChallenge]; - - // 7. Expect the key to be attested with the challenge. - NSData *attestationData = [@"attestation data" dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeAppAttestService.attestationToReturn = attestationData; - - // 8. Expect key attestation request to be sent. - GACAppCheckToken *FACToken = [[GACAppCheckToken alloc] initWithToken:@"FAC token" - expirationDate:[NSDate date]]; - NSData *artifactData = [@"attestation artifact" dataUsingEncoding:NSUTF8StringEncoding]; - __auto_type attestKeyResponse = - [[GACAppAttestAttestationResponse alloc] initWithArtifact:artifactData token:FACToken]; - self.fakeAPIService.attestKeyPromise = [FBLPromise resolvedWith:attestKeyResponse]; - - // 9. Expect the artifact received from Firebase backend to be saved. - self.fakeArtifactStorage.setArtifactPromise = [FBLPromise resolvedWith:artifactData]; - - // 10. Call get token. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertEqualObjects(token.token, FACToken.token); - XCTAssertEqualObjects(token.expirationDate, FACToken.expirationDate); - XCTAssertNil(error); - }]; - - [self waitForExpectations:@[ self.fakeBackoffWrapper.backoffExpectation, completionExpectation ] - timeout:0.5 - enforceOrder:YES]; - - // 11. Verify mocks. - XCTAssertEqual(self.fakeStorage.getAppAttestKeyIDCallCount, 1); - XCTAssertEqual(self.fakeArtifactStorage.getArtifactCallCount, 1); - XCTAssertEqual(self.fakeAPIService.getRandomChallengeCallCount, 1); - XCTAssertEqual(self.fakeAppAttestService.attestKeyCallCount, 1); - XCTAssertEqual(self.fakeAPIService.attestKeyCallCount, 1); - XCTAssertEqual(self.fakeArtifactStorage.setArtifactCallCount, 1); - - // 12. Verify backoff result. - XCTAssertEqualObjects(((GACAppCheckToken *)self.fakeBackoffWrapper.operationResult).token, - FACToken.token); -} - -- (void)testGetToken_WhenUnregisteredKeyAndRandomChallengeError { - // 0. Expect backoff wrapper to be used. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = YES; - - // 2. Expect storage getAppAttestKeyID. - NSString *existingKeyID = @"existingKeyID"; - self.fakeStorage.getAppAttestKeyIDPromise = [FBLPromise resolvedWith:existingKeyID]; - - // 3. Expect a stored artifact to be requested. - __auto_type rejectedPromise = [self rejectedPromiseWithError:[NSError errorWithDomain:self.name - code:NSNotFound - userInfo:nil]]; - self.fakeArtifactStorage.getArtifactPromise = rejectedPromise; - - // 4. Expect random challenge to be requested. - NSError *challengeError = [self expectRandomChallengeRequestError]; - - // 6. Call get token. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertNil(token); - XCTAssertEqualObjects(error, challengeError); - }]; - - [self waitForExpectations:@[ self.fakeBackoffWrapper.backoffExpectation, completionExpectation ] - timeout:0.5 - enforceOrder:YES]; - - // 7. Verify mocks. - XCTAssertEqual(self.fakeStorage.getAppAttestKeyIDCallCount, 1); - XCTAssertEqual(self.fakeArtifactStorage.getArtifactCallCount, 1); - XCTAssertEqual(self.fakeAPIService.getRandomChallengeCallCount, 1); - XCTAssertEqual(self.fakeStorage.setAppAttestKeyIDCallCount, 0); - XCTAssertEqual(self.fakeAppAttestService.attestKeyCallCount, 0); - XCTAssertEqual(self.fakeAPIService.attestKeyCallCount, 0); - - // 8. Verify backoff error. - XCTAssertEqualObjects(self.fakeBackoffWrapper.operationError, challengeError); -} - -- (void)testGetToken_WhenUnregisteredKeyAndKeyAttestationError { - // 0. Expect backoff wrapper to be used. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = YES; - - // 2. Expect storage getAppAttestKeyID. - NSString *existingKeyID = @"existingKeyID"; - self.fakeStorage.getAppAttestKeyIDPromise = [FBLPromise resolvedWith:existingKeyID]; - - // 3. Expect a stored artifact to be requested. - __auto_type rejectedPromise = [self rejectedPromiseWithError:[NSError errorWithDomain:self.name - code:NSNotFound - userInfo:nil]]; - self.fakeArtifactStorage.getArtifactPromise = rejectedPromise; - - // 4. Expect random challenge to be requested. - self.fakeAPIService.getRandomChallengePromise = [FBLPromise resolvedWith:self.randomChallenge]; - - // 5. Expect the key to be attested with the challenge. - NSError *attestationError = [NSError errorWithDomain:@"testGetTokenWhenKeyAttestationError" - code:0 - userInfo:nil]; - NSError *expectedError = - [_GACAppCheckErrorUtil appAttestAttestKeyFailedWithError:attestationError - keyId:existingKeyID - clientDataHash:self.randomChallengeHash]; - self.fakeAppAttestService.attestKeyErrorToReturn = attestationError; - - // 7. Call get token. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertNil(token); - XCTAssertEqualObjects(error, expectedError); - }]; - - [self waitForExpectations:@[ self.fakeBackoffWrapper.backoffExpectation, completionExpectation ] - timeout:0.5 - enforceOrder:YES]; - - // 8. Verify mocks. - XCTAssertEqual(self.fakeStorage.getAppAttestKeyIDCallCount, 1); - XCTAssertEqual(self.fakeArtifactStorage.getArtifactCallCount, 1); - XCTAssertEqual(self.fakeAPIService.getRandomChallengeCallCount, 1); - XCTAssertEqual(self.fakeAppAttestService.attestKeyCallCount, 1); - XCTAssertEqual(self.fakeAPIService.attestKeyCallCount, 0); - - // 9. Verify backoff error. - XCTAssertEqualObjects(self.fakeBackoffWrapper.operationError, expectedError); -} - -- (void)testGetToken_WhenUnregisteredKeyAndKeyAttestationExchangeError { - // 0. Expect backoff wrapper to be used. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = YES; - - // 2. Expect storage getAppAttestKeyID. - NSString *existingKeyID = @"existingKeyID"; - self.fakeStorage.getAppAttestKeyIDPromise = [FBLPromise resolvedWith:existingKeyID]; - - // 3. Expect a stored artifact to be requested. - __auto_type rejectedPromise = [self rejectedPromiseWithError:[NSError errorWithDomain:self.name - code:NSNotFound - userInfo:nil]]; - self.fakeArtifactStorage.getArtifactPromise = rejectedPromise; - - // 4. Expect random challenge to be requested. - self.fakeAPIService.getRandomChallengePromise = [FBLPromise resolvedWith:self.randomChallenge]; - - // 5. Expect the key to be attested with the challenge. - NSData *attestationData = [@"attestation data" dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeAppAttestService.attestationToReturn = attestationData; - - // 6. Expect exchange request to be sent. - NSError *exchangeError = [NSError errorWithDomain:@"testGetTokenWhenKeyAttestationExchangeError" - code:0 - userInfo:nil]; - self.fakeAPIService.attestKeyPromise = [self rejectedPromiseWithError:exchangeError]; - - // 7. Call get token. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertNil(token); - XCTAssertEqualObjects(error, exchangeError); - }]; - - [self waitForExpectations:@[ self.fakeBackoffWrapper.backoffExpectation, completionExpectation ] - timeout:0.5 - enforceOrder:YES]; - - // 8. Verify mocks. - XCTAssertEqual(self.fakeStorage.getAppAttestKeyIDCallCount, 1); - XCTAssertEqual(self.fakeArtifactStorage.getArtifactCallCount, 1); - XCTAssertEqual(self.fakeAPIService.getRandomChallengeCallCount, 1); - XCTAssertEqual(self.fakeAppAttestService.attestKeyCallCount, 1); - XCTAssertEqual(self.fakeAPIService.attestKeyCallCount, 1); - - // 9. Verify backoff error. - XCTAssertEqualObjects(self.fakeBackoffWrapper.operationError, exchangeError); -} - -#pragma mark - Rejected Attestation - -- (void)testGetToken_WhenAttestationIsRejected_ThenAttestationIsResetAndRetriedOnceSuccess { - // 1. Expect App Attest availability to be requested and stored key ID request to fail. - [self expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested]; - - // 2. Expect the App Attest key pair to be generated and attested. - NSString *keyID1 = @"keyID1"; - NSData *attestationData1 = [[NSUUID UUID].UUIDString dataUsingEncoding:NSUTF8StringEncoding]; - [self expectAppAttestKeyGeneratedAndAttestedWithKeyID:keyID1 attestationData:attestationData1]; - - // 3. Expect exchange request to be sent. - GACAppCheckHTTPError *APIError = [self attestationRejectionHTTPError]; - self.fakeAPIService.attestKeyPromise = [self rejectedPromiseWithError:APIError]; - - // 4. Stored attestation to be reset. - [self expectAttestationReset]; - - // 5. Assert that attestation is tried successfully. - [self assertGetToken_WhenNoExistingKey_Success]; -} - -- (void)testGetToken_WhenAttestationIsRejected_ThenAttestationIsResetAndRetriedOnceError { - // 1. Expect App Attest availability to be requested and stored key ID request to fail. - [self expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested]; - - // 2. Expect the App Attest key pair to be generated and attested. - NSString *keyID1 = @"keyID1"; - NSData *attestationData1 = [[NSUUID UUID].UUIDString dataUsingEncoding:NSUTF8StringEncoding]; - [self expectAppAttestKeyGeneratedAndAttestedWithKeyID:keyID1 attestationData:attestationData1]; - - // 3. Expect exchange request to be sent. - GACAppCheckHTTPError *APIError = [self attestationRejectionHTTPError]; - self.fakeAPIService.attestKeyPromise = [self rejectedPromiseWithError:APIError]; - - // 4. Stored attestation to be reset. - [self expectAttestationReset]; - - // 5. Expect App Attest availability to be requested and stored key ID request to fail. - [self expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested]; - - // 6. Expect the App Attest key pair to be generated and attested. - NSString *keyID2 = @"keyID2"; - NSData *attestationData2 = [[NSUUID UUID].UUIDString dataUsingEncoding:NSUTF8StringEncoding]; - [self expectAppAttestKeyGeneratedAndAttestedWithKeyID:keyID2 attestationData:attestationData2]; - - // 7. Expect exchange request to be sent. - // fakeAPIService.attestKeyPromise is still rejectedPromiseWithError:APIError. - - // 8. Stored attestation to be reset. - [self expectAttestationReset]; - - // 10. Call get token. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertNil(token); - XCTAssertNotNil(error); - XCTAssert([error isKindOfClass:[GACAppCheckHTTPError class]]); - }]; - - [self waitForExpectations:@[ completionExpectation ] timeout:0.5 enforceOrder:YES]; - - // 11. Verify mocks. - XCTAssertEqual(self.fakeAPIService.attestKeyCallCount, 2); - XCTAssertEqual(self.fakeArtifactStorage.setArtifactCallCount, 2); // 2 resets -} - -- (void)testGetToken_WhenExistingKeyIsRejectedByApple_ThenAttestationIsResetAndRetriedOnce_Success { - NSError *invalidKeyError = [NSError errorWithDomain:DCErrorDomain - code:DCErrorInvalidKey - userInfo:nil]; - [self assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAttestationError: - invalidKeyError]; - NSError *invalidInputError = [NSError errorWithDomain:DCErrorDomain - code:DCErrorInvalidInput - userInfo:nil]; - [self assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAttestationError: - invalidInputError]; -} - -#pragma mark - FAC token refresh (assertion) - -- (void)testGetToken_WhenKeyRegistered_Success { - [self assertGetToken_WhenKeyRegistered_Success]; -} - -- (void)testGetToken_WhenKeyRegisteredAndChallengeRequestError { - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = YES; - - // 2. Expect storage getAppAttestKeyID. - NSString *existingKeyID = @"existingKeyID"; - self.fakeStorage.getAppAttestKeyIDPromise = [FBLPromise resolvedWith:existingKeyID]; - - // 3. Expect a stored artifact to be requested. - NSData *storedArtifact = [@"storedArtifact" dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeArtifactStorage.getArtifactPromise = [FBLPromise resolvedWith:storedArtifact]; - - // 4. Expect random challenge to be requested. - NSError *challengeError = [self expectRandomChallengeRequestError]; - - // 7. Call get token. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertNil(token); - XCTAssertEqualObjects(error, challengeError); - }]; - - [self waitForExpectations:@[ completionExpectation ] timeout:0.5]; - - // 8. Verify mocks. - XCTAssertEqual(self.fakeAppAttestService.generateAssertionCallCount, 0); - XCTAssertEqual(self.fakeAPIService.getAppCheckTokenCallCount, 0); -} - -- (void)testGetToken_WhenKeyRegisteredAndGenerateAssertionError { - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = YES; - - // 2. Expect storage getAppAttestKeyID. - NSString *existingKeyID = @"existingKeyID"; - self.fakeStorage.getAppAttestKeyIDPromise = [FBLPromise resolvedWith:existingKeyID]; - - // 3. Expect a stored artifact to be requested. - NSData *storedArtifact = [@"storedArtifact" dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeArtifactStorage.getArtifactPromise = [FBLPromise resolvedWith:storedArtifact]; - - // 4. Expect random challenge to be requested. - self.fakeAPIService.getRandomChallengePromise = [FBLPromise resolvedWith:self.randomChallenge]; - - // 5. Don't expect assertion to be requested. - NSError *generateAssertionError = - [NSError errorWithDomain:@"testGetToken_WhenKeyRegisteredAndGenerateAssertionError" - code:0 - userInfo:nil]; - - NSMutableData *statementForAssertion = [storedArtifact mutableCopy]; - [statementForAssertion appendData:self.randomChallenge]; - NSData *clientDataHash = [GACAppCheckCryptoUtils sha256HashFromData:[statementForAssertion copy]]; - NSError *expectedError = - [_GACAppCheckErrorUtil appAttestGenerateAssertionFailedWithError:generateAssertionError - keyId:existingKeyID - clientDataHash:clientDataHash]; - self.fakeAppAttestService.generateAssertionErrorToReturn = generateAssertionError; - - // 7. Call get token. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertNil(token); - XCTAssertEqualObjects(error, expectedError); - }]; - - [self waitForExpectations:@[ completionExpectation ] timeout:0.5]; - - // 8. Verify mocks. - XCTAssertEqual(self.fakeAppAttestService.generateAssertionCallCount, 1); - XCTAssertEqual(self.fakeAPIService.getAppCheckTokenCallCount, 0); -} - -- (void)testGetToken_WhenKeyRegisteredAndTokenExchangeRequestError { - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = YES; - - // 2. Expect storage getAppAttestKeyID. - NSString *existingKeyID = @"existingKeyID"; - self.fakeStorage.getAppAttestKeyIDPromise = [FBLPromise resolvedWith:existingKeyID]; - - // 3. Expect a stored artifact to be requested. - NSData *storedArtifact = [@"storedArtifact" dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeArtifactStorage.getArtifactPromise = [FBLPromise resolvedWith:storedArtifact]; - - // 4. Expect random challenge to be requested. - self.fakeAPIService.getRandomChallengePromise = [FBLPromise resolvedWith:self.randomChallenge]; - - // 5. Don't expect assertion to be requested. - NSData *assertion = [@"generatedAssertion" dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeAppAttestService.assertionToReturn = assertion; - - // 6. Expect assertion request to be sent. - NSError *tokenExchangeError = - [NSError errorWithDomain:@"testGetToken_WhenKeyRegisteredAndTokenExchangeRequestError" - code:0 - userInfo:nil]; - self.fakeAPIService.getAppCheckTokenPromise = [self rejectedPromiseWithError:tokenExchangeError]; - - // 7. Call get token. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertNil(token); - XCTAssertEqualObjects(error, tokenExchangeError); - }]; - - [self waitForExpectations:@[ completionExpectation ] timeout:0.5]; - - // 8. Verify mocks. - XCTAssertEqual(self.fakeAppAttestService.generateAssertionCallCount, 1); - XCTAssertEqual(self.fakeAPIService.getAppCheckTokenCallCount, 1); -} - -#pragma mark - Rejected Assertion - -- (void)testGetToken_WhenAssertionIsRejectedByApple_ThenResetToAttestationAndRetryOnceSuccess { - NSError *invalidKeyError = [NSError errorWithDomain:DCErrorDomain - code:DCErrorInvalidKey - userInfo:nil]; - [self assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAssertionError: - invalidKeyError]; - NSError *invalidInputError = [NSError errorWithDomain:DCErrorDomain - code:DCErrorInvalidInput - userInfo:nil]; - [self assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAssertionError: - invalidInputError]; - NSError *systemFailureError = [NSError errorWithDomain:DCErrorDomain - code:DCErrorUnknownSystemFailure - userInfo:nil]; - [self assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAssertionError: - systemFailureError]; -} - -#pragma mark - Request merging - -- (void)testGetToken_WhenCalledSeveralTimesSuccess_ThenThereIsOnlyOneOngoingHandshake { - // 0. Expect backoff wrapper to be used only once. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = YES; - - // 2. Expect storage getAppAttestKeyID. - NSString *existingKeyID = @"existingKeyID"; - self.fakeStorage.getAppAttestKeyIDPromise = [FBLPromise resolvedWith:existingKeyID]; - - // 3. Expect a stored artifact to be requested. - NSData *storedArtifact = [@"storedArtifact" dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeArtifactStorage.getArtifactPromise = [FBLPromise resolvedWith:storedArtifact]; - - // 4. Expect random challenge to be requested. - // 4.1. Create a pending promise to fulfill later. - FBLPromise *challengeRequestPromise = [FBLPromise pendingPromise]; - // 4.2. Stub getRandomChallenge method. - self.fakeAPIService.getRandomChallengePromise = challengeRequestPromise; - - // 5. Expect assertion to be requested. - NSData *assertion = [@"generatedAssertion" dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeAppAttestService.assertionToReturn = assertion; - - // 6. Expect assertion request to be sent. - GACAppCheckToken *FACToken = [[GACAppCheckToken alloc] initWithToken:@"FAC token" - expirationDate:[NSDate date]]; - self.fakeAPIService.getAppCheckTokenPromise = [FBLPromise resolvedWith:FACToken]; - - // 7. Call get token several times. - NSInteger callsCount = 10; - NSMutableArray *completionExpectations = [NSMutableArray arrayWithCapacity:callsCount]; - - for (NSInteger i = 0; i < callsCount; i++) { - // 7.1 Expect the completion to be called for each get token method called. - XCTestExpectation *completionExpectation = [self - expectationWithDescription:[NSString stringWithFormat:@"completionExpectation%@", @(i)]]; - [completionExpectations addObject:completionExpectation]; - - // 7.2. Call get token. - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertEqualObjects(token.token, FACToken.token); - XCTAssertEqualObjects(token.expirationDate, FACToken.expirationDate); - XCTAssertNil(error); - }]; - } - - // 7.3. Resolve get challenge promise to finish the operation. - [challengeRequestPromise fulfill:self.randomChallenge]; - - // 7.4. Wait for all completions to be called. - NSArray *expectations = - [completionExpectations arrayByAddingObject:self.fakeBackoffWrapper.backoffExpectation]; - [self waitForExpectations:expectations timeout:1]; - - // 8. Verify mocks. - XCTAssertEqual(self.fakeAppAttestService.generateAssertionCallCount, 1); - XCTAssertEqual(self.fakeAPIService.getAppCheckTokenCallCount, 1); - - // 9. Check another get token call after. - [self assertGetToken_WhenKeyRegistered_Success]; -} - -- (void)testGetToken_WhenCalledSeveralTimesError_ThenThereIsOnlyOneOngoingHandshake { - // 0. Expect backoff wrapper to be used only once. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = YES; - - // 2. Expect storage getAppAttestKeyID. - NSString *existingKeyID = @"existingKeyID"; - self.fakeStorage.getAppAttestKeyIDPromise = [FBLPromise resolvedWith:existingKeyID]; - - // 3. Expect a stored artifact to be requested. - NSData *storedArtifact = [@"storedArtifact" dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeArtifactStorage.getArtifactPromise = [FBLPromise resolvedWith:storedArtifact]; - - // 4. Expect random challenge to be requested. - self.fakeAPIService.getRandomChallengePromise = [FBLPromise resolvedWith:self.randomChallenge]; - - // 5. Expect assertion to be requested. - NSData *assertion = [@"generatedAssertion" dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeAppAttestService.assertionToReturn = assertion; - - // 6. Expect assertion request to be sent. - // 6.1. Create a pending promise to reject later. - FBLPromise *assertionRequestPromise = [FBLPromise pendingPromise]; - // 6.2. Stub assertion request. - self.fakeAPIService.getAppCheckTokenPromise = assertionRequestPromise; - // 6.3. Create an expected error to be rejected with later. - NSError *assertionRequestError = [NSError errorWithDomain:self.name code:0 userInfo:nil]; - - // 7. Call get token several times. - NSInteger callsCount = 10; - NSMutableArray *completionExpectations = [NSMutableArray arrayWithCapacity:callsCount]; - - for (NSInteger i = 0; i < callsCount; i++) { - // 7.1 Expect the completion to be called for each get token method called. - XCTestExpectation *completionExpectation = [self - expectationWithDescription:[NSString stringWithFormat:@"completionExpectation%@", @(i)]]; - [completionExpectations addObject:completionExpectation]; - - // 7.2. Call get token. - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertEqualObjects(error, assertionRequestError); - XCTAssertNil(token); - }]; - } - - // 7.3. Reject get challenge promise to finish the operation. - [assertionRequestPromise reject:assertionRequestError]; - - // 7.4. Wait for all completions to be called. - NSArray *expectations = - [completionExpectations arrayByAddingObject:self.fakeBackoffWrapper.backoffExpectation]; - [self waitForExpectations:expectations timeout:1]; - - // 8. Verify mocks. - XCTAssertEqual(self.fakeAppAttestService.generateAssertionCallCount, 1); - XCTAssertEqual(self.fakeAPIService.getAppCheckTokenCallCount, 1); - - // 9. Check another get token call after. - [self assertGetToken_WhenKeyRegistered_Success]; -} - -#pragma mark - Backoff tests - -- (void)testGetTokenBackoff { - // 1. Configure backoff. - self.fakeBackoffWrapper.isNextOperationAllowed = NO; - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 3. Call get token. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertNil(token); - XCTAssertEqualObjects(error, self.fakeBackoffWrapper.backoffError); - }]; - - [self waitForExpectations:@[ self.fakeBackoffWrapper.backoffExpectation, completionExpectation ] - timeout:0.5 - enforceOrder:YES]; - - // 4. Verify mocks. - XCTAssertEqual(self.fakeStorage.getAppAttestKeyIDCallCount, 0); - XCTAssertEqual(self.fakeAppAttestService.generateKeyCallCount, 0); - XCTAssertEqual(self.fakeArtifactStorage.getArtifactCallCount, 0); - XCTAssertEqual(self.fakeAPIService.getRandomChallengeCallCount, 0); - XCTAssertEqual(self.fakeStorage.setAppAttestKeyIDCallCount, 0); - XCTAssertEqual(self.fakeAppAttestService.attestKeyCallCount, 0); - XCTAssertEqual(self.fakeAPIService.attestKeyCallCount, 0); -} - -#pragma mark - Helpers - -- (NSData *)dataHashForAssertionWithArtifactData:(NSData *)artifact { - NSMutableData *statement = [artifact mutableCopy]; - [statement appendData:self.randomChallenge]; - return [GACAppCheckCryptoUtils sha256HashFromData:statement]; -} - -- (FBLPromise *)rejectedPromiseWithError:(NSError *)error { - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:error]; - return rejectedPromise; -} - -- (NSError *)expectRandomChallengeRequestError { - NSError *challengeError = [NSError errorWithDomain:@"testGetToken_WhenRandomChallengeError" - code:NSNotFound - userInfo:nil]; - self.fakeAPIService.getRandomChallengePromise = [self rejectedPromiseWithError:challengeError]; - return challengeError; -} - -- (void)resetFakeCallCountsAndErrors { - self.fakeAppAttestService.generateKeyCallCount = 0; - self.fakeAppAttestService.attestKeyCallCount = 0; - self.fakeAppAttestService.generateAssertionCallCount = 0; - self.fakeAPIService.getRandomChallengeCallCount = 0; - self.fakeAPIService.attestKeyCallCount = 0; - self.fakeAPIService.getAppCheckTokenCallCount = 0; - self.fakeStorage.getAppAttestKeyIDCallCount = 0; - self.fakeStorage.setAppAttestKeyIDCallCount = 0; - self.fakeArtifactStorage.getArtifactCallCount = 0; - self.fakeArtifactStorage.setArtifactCallCount = 0; - - self.fakeAppAttestService.generateKeyErrorToReturn = nil; - self.fakeAppAttestService.attestKeyErrorToReturn = nil; - self.fakeAppAttestService.generateAssertionErrorToReturn = nil; - self.fakeAppAttestService.keyIdToReturn = nil; - self.fakeAppAttestService.attestationToReturn = nil; - self.fakeAppAttestService.assertionToReturn = nil; - - self.fakeAPIService.getRandomChallengePromise = nil; - self.fakeAPIService.attestKeyPromise = nil; - self.fakeAPIService.getAppCheckTokenPromise = nil; - - self.fakeStorage.getAppAttestKeyIDPromise = nil; - self.fakeStorage.setAppAttestKeyIDPromise = nil; - - self.fakeArtifactStorage.getArtifactPromise = nil; - self.fakeArtifactStorage.setArtifactPromise = nil; -} - -- (GACAppCheckHTTPError *)attestationRejectionHTTPError { - NSHTTPURLResponse *response = - [[NSHTTPURLResponse alloc] initWithURL:[NSURL URLWithString:@"http://localhost"] - statusCode:403 - HTTPVersion:@"HTTP/1.1" - headerFields:nil]; - NSData *responseBody = [@"Could not verify attestation" dataUsingEncoding:NSUTF8StringEncoding]; - return [[GACAppCheckHTTPError alloc] initWithHTTPResponse:response data:responseBody]; -} - -- (void)assertGetToken_WhenNoExistingKey_Success { - [self resetFakeCallCountsAndErrors]; - - // 0. Expect backoff wrapper to be used. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 1. Expect App Attest availability to be checked and no existing stored key requested. - [self expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested]; - - // 2. Expect App Attest key to be generated. - NSString *generatedKeyID = @"generatedKeyID"; - self.fakeAppAttestService.keyIdToReturn = generatedKeyID; - - // 3. Expect the key ID to be stored. - self.fakeStorage.setAppAttestKeyIDPromise = [FBLPromise resolvedWith:generatedKeyID]; - - // 4. Expect random challenge to be requested. - self.fakeAPIService.getRandomChallengePromise = [FBLPromise resolvedWith:self.randomChallenge]; - - // 5. Expect the key to be attested with the challenge. - NSData *attestationData = [@"attestation data" dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeAppAttestService.attestationToReturn = attestationData; - - // 6. Expect key attestation request to be sent. - GACAppCheckToken *FACToken = [[GACAppCheckToken alloc] initWithToken:@"FAC token" - expirationDate:[NSDate date]]; - NSData *artifactData = [@"attestation artifact" dataUsingEncoding:NSUTF8StringEncoding]; - __auto_type attestKeyResponse = - [[GACAppAttestAttestationResponse alloc] initWithArtifact:artifactData token:FACToken]; - self.fakeAPIService.attestKeyPromise = [FBLPromise resolvedWith:attestKeyResponse]; - - // 7. Expect the artifact received from Firebase backend to be saved. - self.fakeArtifactStorage.setArtifactPromise = [FBLPromise resolvedWith:artifactData]; - - // 8. Call get token. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertEqualObjects(token.token, FACToken.token); - XCTAssertEqualObjects(token.expirationDate, FACToken.expirationDate); - XCTAssertNil(error); - }]; - - [self waitForExpectations:@[ self.fakeBackoffWrapper.backoffExpectation, completionExpectation ] - timeout:0.5 - enforceOrder:YES]; - - // 9. Verify mocks. - XCTAssertEqual(self.fakeAppAttestService.generateKeyCallCount, 1); - XCTAssertEqual(self.fakeStorage.setAppAttestKeyIDCallCount, 1); - XCTAssertEqual(self.fakeAPIService.getRandomChallengeCallCount, 1); - XCTAssertEqual(self.fakeAppAttestService.attestKeyCallCount, 1); - XCTAssertEqual(self.fakeAPIService.attestKeyCallCount, 1); - XCTAssertEqual(self.fakeArtifactStorage.setArtifactCallCount, 1); - - // 10. Verify backoff result. - XCTAssertEqualObjects(((GACAppCheckToken *)self.fakeBackoffWrapper.operationResult).token, - FACToken.token); -} - -- (void)assertGetToken_WhenKeyRegistered_Success { - [self resetFakeCallCountsAndErrors]; - - // 0. Expect backoff wrapper to be used. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = YES; - - // 2. Expect storage getAppAttestKeyID. - NSString *existingKeyID = [NSUUID UUID].UUIDString; - self.fakeStorage.getAppAttestKeyIDPromise = [FBLPromise resolvedWith:existingKeyID]; - - // 3. Expect a stored artifact to be requested. - NSData *storedArtifact = [[NSUUID UUID].UUIDString dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeArtifactStorage.getArtifactPromise = [FBLPromise resolvedWith:storedArtifact]; - - // 4. Expect random challenge to be requested. - self.fakeAPIService.getRandomChallengePromise = [FBLPromise resolvedWith:self.randomChallenge]; - - // 5. Expect assertion to be requested. - NSData *assertion = [[NSUUID UUID].UUIDString dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeAppAttestService.assertionToReturn = assertion; - - // 6. Expect assertion request to be sent. - GACAppCheckToken *FACToken = [[GACAppCheckToken alloc] initWithToken:[NSUUID UUID].UUIDString - expirationDate:[NSDate date]]; - self.fakeAPIService.getAppCheckTokenPromise = [FBLPromise resolvedWith:FACToken]; - - // 7. Call get token. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertEqualObjects(token.token, FACToken.token); - XCTAssertEqualObjects(token.expirationDate, FACToken.expirationDate); - XCTAssertNil(error); - }]; - - [self waitForExpectations:@[ self.fakeBackoffWrapper.backoffExpectation, completionExpectation ] - timeout:0.5]; - - // 8. Verify mocks. - XCTAssertEqual(self.fakeAppAttestService.generateAssertionCallCount, 1); - XCTAssertEqual(self.fakeAPIService.getAppCheckTokenCallCount, 1); - - // 9. Verify backoff result. - XCTAssertEqualObjects(((GACAppCheckToken *)self.fakeBackoffWrapper.operationResult).token, - FACToken.token); -} - -- (void)assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAttestationError: - (NSError *)error { - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = YES; - - // 2. Expect storage getAppAttestKeyID. - NSString *existingKeyID = @"existingKeyID"; - self.fakeStorage.getAppAttestKeyIDPromise = [FBLPromise resolvedWith:existingKeyID]; - - // 3. Expect a stored artifact to be requested. - __auto_type rejectedPromise = [self rejectedPromiseWithError:[NSError errorWithDomain:self.name - code:NSNotFound - userInfo:nil]]; - self.fakeArtifactStorage.getArtifactPromise = rejectedPromise; - - // 4. Expect random challenge to be requested. - self.fakeAPIService.getRandomChallengePromise = [FBLPromise resolvedWith:self.randomChallenge]; - - // 5. Expect the key to be attested with the challenge. - self.fakeAppAttestService.attestKeyErrorToReturn = error; - - // 6. Stored attestation to be reset. - [self expectAttestationReset]; - - // 7. Expect App Attest availability to be requested and stored key ID request to fail. - [self expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested]; - - // 8. Expect the App Attest key pair to be generated and attested. - NSString *newKeyID = @"newKeyID"; - NSData *attestationData = [[NSUUID UUID].UUIDString dataUsingEncoding:NSUTF8StringEncoding]; - [self expectAppAttestKeyGeneratedAndAttestedWithKeyID:newKeyID attestationData:attestationData]; - - // 9. Expect exchange request to be sent. - GACAppCheckToken *appCheckToken = [[GACAppCheckToken alloc] initWithToken:@"App Check Token" - expirationDate:[NSDate date]]; - NSData *artifactData = [@"attestation artifact" dataUsingEncoding:NSUTF8StringEncoding]; - __auto_type attestKeyResponse = - [[GACAppAttestAttestationResponse alloc] initWithArtifact:artifactData token:appCheckToken]; - self.fakeAPIService.attestKeyPromise = [FBLPromise resolvedWith:attestKeyResponse]; - - // 10. Expect the artifact received from Firebase backend to be saved. - self.fakeArtifactStorage.setArtifactPromise = [FBLPromise resolvedWith:artifactData]; - - // 11. Call get token. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - - XCTAssertEqualObjects(token.token, appCheckToken.token); - XCTAssertEqualObjects(token.expirationDate, appCheckToken.expirationDate); - XCTAssertNil(error); - }]; - - [self waitForExpectations:@[ completionExpectation ] timeout:0.5 enforceOrder:YES]; -} - -- (void)assertAttestationResetAndGetTokenRetryWhenExistingKeyIsRejectedWithAssertionError: - (NSError *)error { - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = YES; - - // 2. Expect storage getAppAttestKeyID. - NSString *existingKeyID = @"existingKeyID"; - self.fakeStorage.getAppAttestKeyIDPromise = [FBLPromise resolvedWith:existingKeyID]; - - // 3. Expect a stored artifact to be requested. - NSData *storedArtifact = [@"storedArtifact" dataUsingEncoding:NSUTF8StringEncoding]; - self.fakeArtifactStorage.getArtifactPromise = [FBLPromise resolvedWith:storedArtifact]; - - // 4. Expect random challenge to be requested. - self.fakeAPIService.getRandomChallengePromise = [FBLPromise resolvedWith:self.randomChallenge]; - - // 5. Don't expect assertion to be requested. - self.fakeAppAttestService.generateAssertionErrorToReturn = error; - - // 6. Stored attestation to be reset. - [self expectAttestationReset]; - - // 7. Assert that attestation is tried successfully. - [self assertGetToken_WhenNoExistingKey_Success]; -} - -- (void)expectAppAttestAvailabilityToBeCheckedAndNotExistingStoredKeyRequested { - // 1. Expect GACAppAttestService.isSupported. - self.fakeAppAttestService.supported = YES; - - // 2. Expect storage getAppAttestKeyID. - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - NSError *error = [NSError errorWithDomain:@"testGetToken_WhenNoExistingKey_Success" - code:NSNotFound - userInfo:nil]; - [rejectedPromise reject:error]; - self.fakeStorage.getAppAttestKeyIDPromise = rejectedPromise; -} - -- (void)expectAppAttestKeyGeneratedAndAttestedWithKeyID:(NSString *)keyID - attestationData:(NSData *)attestationData { - // 1. Expect App Attest key to be generated. - self.fakeAppAttestService.keyIdToReturn = keyID; - - // 2. Expect the key ID to be stored. - self.fakeStorage.setAppAttestKeyIDPromise = [FBLPromise resolvedWith:keyID]; - - // 3. Expect random challenge to be requested. - self.fakeAPIService.getRandomChallengePromise = [FBLPromise resolvedWith:self.randomChallenge]; - - // 4. Expect the key to be attested with the challenge. - self.fakeAppAttestService.attestKeyErrorToReturn = nil; - self.fakeAppAttestService.attestationToReturn = attestationData; -} - -- (void)expectAttestationReset { - // 1. Expect stored key ID to be reset. - self.fakeStorage.setAppAttestKeyIDPromise = [FBLPromise resolvedWith:nil]; - - // 2. Expect stored attestation artifact to be reset. - self.fakeArtifactStorage.setArtifactPromise = [FBLPromise resolvedWith:nil]; -} - -@end diff --git a/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/AppCheckCoreAppAttestArtifactStorageTests.swift b/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/AppCheckCoreAppAttestArtifactStorageTests.swift new file mode 100644 index 00000000..10a087e4 --- /dev/null +++ b/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/AppCheckCoreAppAttestArtifactStorageTests.swift @@ -0,0 +1,295 @@ +// 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 +import XCTest + +private let kAppName = "AppCheckCoreAppAttestArtifactStorageTests" +private let kAppID = "1:100000000000:ios:aaaaaaaaaaaaaaaaaaaaaaaa" + +// Tests that use the Keychain require a host app and Swift Package Manager +// does not support adding a host app to test targets. +#if !SWIFT_PACKAGE + + // Skip keychain tests on Catalyst and macOS. Tests are skipped because they + // involve interactions with the keychain that require a provisioning profile. + // See go/firebase-macos-keychain-popups for more details. + #if !targetEnvironment(macCatalyst) && !os(macOS) + + class AppCheckCoreAppAttestArtifactStorageTests: XCTestCase { + var keySuffix: String! + var storage: AppCheckCoreAppAttestArtifactStorage! + + override func setUp() { + super.setUp() + + keySuffix = AppCheckCoreAppAttestArtifactStorageTests.artifactKeySuffix( + appName: kAppName, + appID: kAppID + ) + storage = AppCheckCoreAppAttestArtifactStorage(keySuffix: keySuffix, accessGroup: nil) + } + + override func tearDown() { + storage = nil + super.tearDown() + } + + func testSetAndGetArtifact() async throws { + _ = try await assertSetGetForStorage() + } + + func testRemoveArtifact() async throws { + let keyID = UUID().uuidString + + // 1. Save an artifact to storage and check it is stored. + _ = try await assertSetGetForStorage() + + // 2. Remove artifact. + let setArtifact = try await storage.setArtifact(nil, forKey: keyID) + XCTAssertNil(setArtifact) + + // 3. Check it has been removed. + let getArtifact = try await storage.getArtifact(forKey: keyID) + XCTAssertNil(getArtifact) + } + + func testSetAndGetPerApp() async throws { + // Assert storages for apps with the same name can independently set/get artifact. + try await assertIndependentSetGetForStorages( + appName1: kAppName, + appID1: "app_id_1", + appName2: kAppName, + appID2: "app_id_2" + ) + // Assert storages for apps with the same app ID can independently set/get artifact. + try await assertIndependentSetGetForStorages( + appName1: "app_1", + appID1: kAppID, + appName2: "app_2", + appID2: kAppID + ) + // Assert storages for apps with different info can independently set/get artifact. + try await assertIndependentSetGetForStorages( + appName1: "app_1", + appID1: "app_id_1", + appName2: "app_2", + appID2: "app_id_2" + ) + } + + func testSetArtifactForOneKeyGetForAnotherKey() async throws { + // Set an artifact for a key. + _ = try await assertSetGetForStorage() + + // Try to get artifact for a different key. + let keyID = UUID().uuidString + let getArtifact = try await storage.getArtifact(forKey: keyID) + XCTAssertNil(getArtifact) + } + + func testSetArtifactForNewKeyRemovesArtifactForOldKey() async throws { + // 1. Store an artifact. + let oldKeyID = try await assertSetGetForStorage() + + // 2. Replace the artifact. + let newKeyID = try await assertSetGetForStorage() + XCTAssertNotEqual(oldKeyID, newKeyID) + + // 3. Check old artifact was removed. + let getArtifact = try await storage.getArtifact(forKey: oldKeyID) + XCTAssertNil(getArtifact) + } + + func testGetArtifact_KeychainError() async { + // 1. Set up storage mock. + let fakeKeychainStorage = AppCheckCoreKeychainStorageFake() + let artifactStorage = AppCheckCoreAppAttestArtifactStorage( + keySuffix: keySuffix, + keychainStorage: fakeKeychainStorage, + accessGroup: nil + ) + + // 2. Create and expect keychain error. + let gulsKeychainError = NSError( + domain: "com.google.utilities.keychain", + code: -1, + userInfo: nil + ) + fakeKeychainStorage.keychainError = gulsKeychainError + + // 3. Get artifact and verify results. + do { + _ = try await artifactStorage.getArtifact(forKey: "key") + XCTFail("Expected error to be thrown") + } catch { + let nsError = error as NSError + let expectedError = AppCheckCoreErrorUtil + .keychainError(with: gulsKeychainError) as NSError + XCTAssertEqual(nsError.domain, expectedError.domain) + XCTAssertEqual(nsError.code, expectedError.code) + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, + let expectedUnderlyingError = expectedError + .userInfo[NSUnderlyingErrorKey] as? NSError { + XCTAssertEqual(underlyingError.domain, expectedUnderlyingError.domain) + XCTAssertEqual(underlyingError.code, expectedUnderlyingError.code) + } + } + } + + func testSetArtifact_KeychainError() async { + // 1. Set up storage mock. + let fakeKeychainStorage = AppCheckCoreKeychainStorageFake() + let artifactStorage = AppCheckCoreAppAttestArtifactStorage( + keySuffix: keySuffix, + keychainStorage: fakeKeychainStorage, + accessGroup: nil + ) + + // 2. Create and expect keychain error. + let gulsKeychainError = NSError( + domain: "com.google.utilities.keychain", + code: -1, + userInfo: nil + ) + fakeKeychainStorage.keychainError = gulsKeychainError + + // 3. Set artifact and verify results. + let artifact = "artifact".data(using: .utf8) + do { + _ = try await artifactStorage.setArtifact(artifact, forKey: "key") + XCTFail("Expected error to be thrown") + } catch { + let nsError = error as NSError + let expectedError = AppCheckCoreErrorUtil + .keychainError(with: gulsKeychainError) as NSError + XCTAssertEqual(nsError.domain, expectedError.domain) + XCTAssertEqual(nsError.code, expectedError.code) + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, + let expectedUnderlyingError = expectedError + .userInfo[NSUnderlyingErrorKey] as? NSError { + XCTAssertEqual(underlyingError.domain, expectedUnderlyingError.domain) + XCTAssertEqual(underlyingError.code, expectedUnderlyingError.code) + } + } + } + + func testRemoveArtifact_KeychainError() async { + // 1. Set up storage mock. + let fakeKeychainStorage = AppCheckCoreKeychainStorageFake() + let artifactStorage = AppCheckCoreAppAttestArtifactStorage( + keySuffix: keySuffix, + keychainStorage: fakeKeychainStorage, + accessGroup: nil + ) + + // 2. Create and expect keychain error. + let gulsKeychainError = NSError( + domain: "com.google.utilities.keychain", + code: -1, + userInfo: nil + ) + fakeKeychainStorage.keychainError = gulsKeychainError + + // 3. Remove artifact and verify results. + do { + _ = try await artifactStorage.setArtifact(nil, forKey: "key") + XCTFail("Expected error to be thrown") + } catch { + let nsError = error as NSError + let expectedError = AppCheckCoreErrorUtil + .keychainError(with: gulsKeychainError) as NSError + XCTAssertEqual(nsError.domain, expectedError.domain) + XCTAssertEqual(nsError.code, expectedError.code) + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, + let expectedUnderlyingError = expectedError + .userInfo[NSUnderlyingErrorKey] as? NSError { + XCTAssertEqual(underlyingError.domain, expectedUnderlyingError.domain) + XCTAssertEqual(underlyingError.code, expectedUnderlyingError.code) + } + } + } + + // MARK: - Helpers + + /// Sets a random artifact for a random key and asserts it can be read. + /// - Returns: The random key ID used to set and get the artifact. + private func assertSetGetForStorage() async throws -> String { + let artifactToSet = UUID().uuidString.data(using: .utf8) + let keyID = UUID().uuidString + + let setArtifact = try await storage.setArtifact(artifactToSet, forKey: keyID) + XCTAssertEqual(setArtifact, artifactToSet) + + let getArtifact = try await storage.getArtifact(forKey: keyID) + XCTAssertEqual(getArtifact, artifactToSet) + + addTeardownBlock { [weak self] in + // Cleanup storage. + _ = try? await self?.storage.setArtifact(nil, forKey: keyID) + } + + return keyID + } + + private func assertIndependentSetGetForStorages(appName1: String, + appID1: String, + appName2: String, + appID2: String) async throws { + let keyID = UUID().uuidString + let keySuffix1 = AppCheckCoreAppAttestArtifactStorageTests.artifactKeySuffix( + appName: appName1, + appID: appID1 + ) + let keySuffix2 = AppCheckCoreAppAttestArtifactStorageTests.artifactKeySuffix( + appName: appName2, + appID: appID2 + ) + + // Create two storages. + let storage1 = AppCheckCoreAppAttestArtifactStorage(keySuffix: keySuffix1, accessGroup: nil) + let storage2 = AppCheckCoreAppAttestArtifactStorage(keySuffix: keySuffix2, accessGroup: nil) + + // 1. Independently set artifacts for the two storages. + let artifact1 = "app_attest_artifact1".data(using: .utf8) + let setArtifact1 = try await storage1.setArtifact(artifact1, forKey: keyID) + XCTAssertEqual(setArtifact1, artifact1) + + let artifact2 = "app_attest_artifact2".data(using: .utf8) + let setArtifact2 = try await storage2.setArtifact(artifact2, forKey: keyID) + XCTAssertEqual(setArtifact2, artifact2) + + // 2. Get artifacts for the two storages. + let getArtifact1 = try await storage1.getArtifact(forKey: keyID) + XCTAssertEqual(getArtifact1, artifact1) + + let getArtifact2 = try await storage2.getArtifact(forKey: keyID) + XCTAssertEqual(getArtifact2, artifact2) + + // 3. Assert that artifacts were set and retrieved independently of one another. + XCTAssertNotEqual(getArtifact1, getArtifact2) + + // Cleanup storages. + _ = try await storage1.setArtifact(nil, forKey: keyID) + _ = try await storage2.setArtifact(nil, forKey: keyID) + } + + static func artifactKeySuffix(appName: String, appID: String) -> String { + return "\(appName).\(appID)" + } + } + + #endif // !targetEnvironment(macCatalyst) && !os(macOS) + +#endif // !SWIFT_PACKAGE diff --git a/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/AppCheckCoreAppAttestKeyIDStorageTests.swift b/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/AppCheckCoreAppAttestKeyIDStorageTests.swift new file mode 100644 index 00000000..f207d494 --- /dev/null +++ b/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/AppCheckCoreAppAttestKeyIDStorageTests.swift @@ -0,0 +1,139 @@ +// 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 +import XCTest + +private let kAppName = "AppCheckCoreAppAttestKeyIDStorageTestsApp" +private let kAppID = "app_id" + +class AppCheckCoreAppAttestKeyIDStorageTests: XCTestCase { + var keySuffix: String! + var storage: AppCheckCoreAppAttestKeyIDStorage! + + override func setUp() { + super.setUp() + keySuffix = "\(kAppName).\(kAppID)" + storage = AppCheckCoreAppAttestKeyIDStorage(keySuffix: keySuffix) + } + + override func tearDown() async throws { + // Remove the app attest key ID from storage. + _ = try? await storage.setAppAttestKeyID(nil) + storage = nil + try await super.tearDown() + } + + func testInitWithApp() { + XCTAssertNotNil(AppCheckCoreAppAttestKeyIDStorage(keySuffix: keySuffix)) + } + + func testSetAndGetAppAttestKeyID() async throws { + let appAttestKeyID = "app_attest_key_ID" + + let setKeyID = try await storage.setAppAttestKeyID(appAttestKeyID) + XCTAssertEqual(setKeyID, appAttestKeyID) + + let getKeyID = try await storage.getAppAttestKeyID() + XCTAssertEqual(getKeyID, appAttestKeyID) + } + + func testRemoveAppAttestKeyID() async throws { + let setKeyID = try await storage.setAppAttestKeyID(nil) + XCTAssertNil(setKeyID) + } + + func testGetAppAttestKeyID_WhenAppAttestKeyIDNotFoundError() async { + do { + _ = try await storage.getAppAttestKeyID() + XCTFail("Expected getAppAttestKeyID to throw.") + } catch { + let nsError = error as NSError + let expectedError = AppCheckCoreErrorUtil.appAttestKeyIDNotFound() as NSError + XCTAssertEqual(nsError.domain, expectedError.domain) + XCTAssertEqual(nsError.code, expectedError.code) + } + } + + func testSetGetAppAttestKeyIDPerApp() async throws { + // Assert storages for apps with the same name can independently set/get app attest key ID. + try await assertIndependentSetGetForStorages( + appName1: kAppName, + appID1: "app_id_1", + appName2: kAppName, + appID2: "app_id_2" + ) + // Assert storages for apps with the same app ID can independently set/get app attest key ID. + try await assertIndependentSetGetForStorages( + appName1: "app_1", + appID1: kAppID, + appName2: "app_2", + appID2: kAppID + ) + // Assert storages for apps with different info can independently set/get app attest key ID. + try await assertIndependentSetGetForStorages( + appName1: "app_1", + appID1: "app_id_1", + appName2: "app_2", + appID2: "app_id_2" + ) + } + + // MARK: - Helpers + + func assertIndependentSetGetForStorages(appName1: String, + appID1: String, + appName2: String, + appID2: String) async throws { + let keySuffix1 = AppCheckCoreAppAttestKeyIDStorageTests.storageKeySuffix( + appName: appName1, + appID: appID1 + ) + let keySuffix2 = AppCheckCoreAppAttestKeyIDStorageTests.storageKeySuffix( + appName: appName2, + appID: appID2 + ) + + // Create two storages. + let storage1 = AppCheckCoreAppAttestKeyIDStorage(keySuffix: keySuffix1) + let storage2 = AppCheckCoreAppAttestKeyIDStorage(keySuffix: keySuffix2) + + // 1. Independently set app attest key IDs for the two storages. + let appAttestKeyID1 = "app_attest_key_ID1" + let setKeyID1 = try await storage1.setAppAttestKeyID(appAttestKeyID1) + XCTAssertEqual(setKeyID1, appAttestKeyID1) + + let appAttestKeyID2 = "app_attest_key_ID2" + let setKeyID2 = try await storage2.setAppAttestKeyID(appAttestKeyID2) + XCTAssertEqual(setKeyID2, appAttestKeyID2) + + // 2. Get app attest key IDs for the two storages. + let getKeyID1 = try await storage1.getAppAttestKeyID() + XCTAssertEqual(getKeyID1, appAttestKeyID1) + + let getKeyID2 = try await storage2.getAppAttestKeyID() + XCTAssertEqual(getKeyID2, appAttestKeyID2) + + // 3. Assert that the app attest key IDs were set and retrieved independently of one another. + XCTAssertNotEqual(getKeyID1, getKeyID2) + + // Cleanup storages. + _ = try await storage1.setAppAttestKeyID(nil) + _ = try await storage2.setAppAttestKeyID(nil) + } + + static func storageKeySuffix(appName: String, appID: String) -> String { + return "\(appName).\(appID)" + } +} diff --git a/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestArtifactStorageTests.m b/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestArtifactStorageTests.m deleted file mode 100644 index 8e8867aa..00000000 --- a/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestArtifactStorageTests.m +++ /dev/null @@ -1,281 +0,0 @@ -/* - * Copyright 2021 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 - -// Tests that use the Keychain require a host app and Swift Package Manager -// does not support adding a host app to test targets. -#if !SWIFT_PACKAGE - -// Skip keychain tests on Catalyst and macOS. Tests are skipped because they -// involve interactions with the keychain that require a provisioning profile. -// See go/firebase-macos-keychain-popups for more details. -#if !TARGET_OS_MACCATALYST && !TARGET_OS_OSX - -#import - -#import "AppCheckCore/Tests/Unit/Utils/GACKeychainStorageFake.h" - -#import "FBLPromise+Testing.h" - -#import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -static NSString *const kAppName = @"GACAppAttestArtifactStorageTests"; -static NSString *const kAppID = @"1:100000000000:ios:aaaaaaaaaaaaaaaaaaaaaaaa"; - -@interface GACAppAttestArtifactStorageTests : XCTestCase - -@property(nonatomic) NSString *keySuffix; -@property(nonatomic) GACAppAttestArtifactStorage *storage; - -@end - -@implementation GACAppAttestArtifactStorageTests - -- (void)setUp { - [super setUp]; - - self.keySuffix = [GACAppAttestArtifactStorageTests artifactKeySuffixForAppName:kAppName - appID:kAppID]; - - self.storage = [[GACAppAttestArtifactStorage alloc] initWithKeySuffix:self.keySuffix - accessGroup:nil]; -} - -- (void)tearDown { - self.storage = nil; - [super tearDown]; -} - -- (void)testSetAndGetArtifact { - [self assertSetGetForStorage]; -} - -- (void)testRemoveArtifact { - NSString *keyID = [NSUUID UUID].UUIDString; - - // 1. Save an artifact to storage and check it is stored. - [self assertSetGetForStorage]; - - // 2. Remove artifact. - __auto_type setPromise = [self.storage setArtifact:nil forKey:keyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertNil(setPromise.value); - XCTAssertNil(setPromise.error); - - // 3. Check it has been removed. - __auto_type getPromise = [self.storage getArtifactForKey:keyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertNil(getPromise.value); - XCTAssertNil(getPromise.error); -} - -- (void)testSetAndGetPerApp { - // Assert storages for apps with the same name can independently set/get artifact. - [self assertIndependentSetGetForStoragesWithAppName1:kAppName - appID1:@"app_id_1" - appName2:kAppName - appID2:@"app_id_2"]; - // Assert storages for apps with the same app ID can independently set/get artifact. - [self assertIndependentSetGetForStoragesWithAppName1:@"app_1" - appID1:kAppID - appName2:@"app_2" - appID2:kAppID]; - // Assert storages for apps with different info can independently set/get artifact. - [self assertIndependentSetGetForStoragesWithAppName1:@"app_1" - appID1:@"app_id_1" - appName2:@"app_2" - appID2:@"app_id_2"]; -} - -- (void)testSetArtifactForOneKeyGetForAnotherKey { - // Set an artifact for a key. - [self assertSetGetForStorage]; - - // Try to get artifact for a different key. - NSString *keyID = [NSUUID UUID].UUIDString; - __auto_type getPromise = [self.storage getArtifactForKey:keyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertNil(getPromise.value); - XCTAssertNil(getPromise.error); -} - -- (void)testSetArtifactForNewKeyRemovesArtifactForOldKey { - // 1. Store an artifact. - NSString *oldKeyID = [self assertSetGetForStorage]; - - // 2. Replace the artifact. - NSString *newKeyID = [self assertSetGetForStorage]; - XCTAssertNotEqualObjects(oldKeyID, newKeyID); - - // 3. Check old artifact was removed. - __auto_type getPromise = [self.storage getArtifactForKey:oldKeyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertNil(getPromise.value); - XCTAssertNil(getPromise.error); -} - -- (void)testGetArtifact_KeychainError { - // 1. Set up storage mock. - GACKeychainStorageFake *fakeKeychainStorage = [[GACKeychainStorageFake alloc] init]; - GACAppAttestArtifactStorage *artifactStorage = - [[GACAppAttestArtifactStorage alloc] initWithKeySuffix:self.keySuffix - keychainStorage:fakeKeychainStorage - accessGroup:nil]; - - // 2. Create and expect keychain error. - NSError *gulsKeychainError = [NSError errorWithDomain:@"com.guls.keychain" code:-1 userInfo:nil]; - fakeKeychainStorage.keychainError = gulsKeychainError; - - // 3. Get artifact and verify results. - __auto_type getPromise = [artifactStorage getArtifactForKey:@"key"]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertNotNil(getPromise.error); - XCTAssertEqualObjects(getPromise.error, - [_GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); -} - -- (void)testSetArtifact_KeychainError { - // 1. Set up storage mock. - GACKeychainStorageFake *fakeKeychainStorage = [[GACKeychainStorageFake alloc] init]; - GACAppAttestArtifactStorage *artifactStorage = - [[GACAppAttestArtifactStorage alloc] initWithKeySuffix:self.keySuffix - keychainStorage:fakeKeychainStorage - accessGroup:nil]; - // 2. Create and expect keychain error. - NSError *gulsKeychainError = [NSError errorWithDomain:@"com.guls.keychain" code:-1 userInfo:nil]; - fakeKeychainStorage.keychainError = gulsKeychainError; - - // 3. Set artifact and verify results. - NSData *artifact = [@"artifact" dataUsingEncoding:NSUTF8StringEncoding]; - __auto_type setPromise = [artifactStorage setArtifact:artifact forKey:@"key"]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertNotNil(setPromise.error); - XCTAssertEqualObjects(setPromise.error, - [_GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); -} - -- (void)testRemoveArtifact_KeychainError { - // 1. Set up storage mock. - GACKeychainStorageFake *fakeKeychainStorage = [[GACKeychainStorageFake alloc] init]; - GACAppAttestArtifactStorage *artifactStorage = - [[GACAppAttestArtifactStorage alloc] initWithKeySuffix:self.keySuffix - keychainStorage:fakeKeychainStorage - accessGroup:nil]; - - // 2. Create and expect keychain error. - NSError *gulsKeychainError = [NSError errorWithDomain:@"com.guls.keychain" code:-1 userInfo:nil]; - fakeKeychainStorage.keychainError = gulsKeychainError; - - // 3. Remove artifact and verify results. - __auto_type setPromise = [artifactStorage setArtifact:nil forKey:@"key"]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertNotNil(setPromise.error); - XCTAssertEqualObjects(setPromise.error, - [_GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); -} - -#pragma mark - Helpers - -/// Sets a random artifact for a random key and asserts it can be read. -/// @return The random key ID used to set and get the artifact. -- (NSString *)assertSetGetForStorage { - NSData *artifactToSet = [[NSUUID UUID].UUIDString dataUsingEncoding:NSUTF8StringEncoding]; - NSString *keyID = [NSUUID UUID].UUIDString; - - __auto_type setPromise = [self.storage setArtifact:artifactToSet forKey:keyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(setPromise.value, artifactToSet); - XCTAssertNil(setPromise.error); - - __auto_type getPromise = [self.storage getArtifactForKey:keyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(getPromise.value, artifactToSet); - XCTAssertNil(getPromise.error); - - __weak __auto_type weakSelf = self; - [self addTeardownBlock:^{ - // Cleanup storage. - [weakSelf.storage setArtifact:nil forKey:keyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - }]; - - return keyID; -} - -- (void)assertIndependentSetGetForStoragesWithAppName1:(NSString *)appName1 - appID1:(NSString *)appID1 - appName2:(NSString *)appName2 - appID2:(NSString *)appID2 { - NSString *keyID = [NSUUID UUID].UUIDString; - NSString *keySuffix1 = [GACAppAttestArtifactStorageTests artifactKeySuffixForAppName:appName1 - appID:appID1]; - NSString *keySuffix2 = [GACAppAttestArtifactStorageTests artifactKeySuffixForAppName:appName2 - appID:appID2]; - - // Create two storages. - GACAppAttestArtifactStorage *storage1 = - [[GACAppAttestArtifactStorage alloc] initWithKeySuffix:keySuffix1 accessGroup:nil]; - GACAppAttestArtifactStorage *storage2 = - [[GACAppAttestArtifactStorage alloc] initWithKeySuffix:keySuffix2 accessGroup:nil]; - // 1. Independently set artifacts for the two storages. - NSData *artifact1 = [@"app_attest_artifact1" dataUsingEncoding:NSUTF8StringEncoding]; - FBLPromise *setPromise1 = [storage1 setArtifact:artifact1 forKey:keyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(setPromise1.value, artifact1); - XCTAssertNil(setPromise1.error); - - NSData *artifact2 = [@"app_attest_artifact2" dataUsingEncoding:NSUTF8StringEncoding]; - __auto_type setPromise2 = [storage2 setArtifact:artifact2 forKey:keyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(setPromise2.value, artifact2); - XCTAssertNil(setPromise2.error); - - // 2. Get artifacts for the two storages. - __auto_type getPromise1 = [storage1 getArtifactForKey:keyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(getPromise1.value, artifact1); - XCTAssertNil(getPromise1.error); - - __auto_type getPromise2 = [storage2 getArtifactForKey:keyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(getPromise2.value, artifact2); - XCTAssertNil(getPromise2.error); - - // 3. Assert that artifacts were set and retrieved independently of one another. - XCTAssertNotEqualObjects(getPromise1.value, getPromise2.value); - - // Cleanup storages. - [storage1 setArtifact:nil forKey:keyID]; - [storage2 setArtifact:nil forKey:keyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); -} - -// TODO(andrewheard): Remove from generic App Check SDK. -// FIREBASE_APP_CHECK_ONLY_BEGIN - -+ (NSString *)artifactKeySuffixForAppName:(NSString *)appName appID:(NSString *)appID { - return [NSString stringWithFormat:@"%@.%@", appName, appID]; -} - -// FIREBASE_APP_CHECK_ONLY_END - -@end - -#endif // !TARGET_OS_MACCATALYST && !TARGET_OS_OSX - -#endif // !SWIFT_PACKAGE diff --git a/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestKeyIDStorageTests.m b/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestKeyIDStorageTests.m deleted file mode 100644 index 17cd5c2e..00000000 --- a/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/GACAppAttestKeyIDStorageTests.m +++ /dev/null @@ -1,160 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "FBLPromise+Testing.h" - -#import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.h" - -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckErrorUtil.h" - -static NSString *const kAppName = @"GACAppAttestKeyIDStorageTestsApp"; -static NSString *const kAppID = @"app_id"; - -@interface GACAppAttestKeyIDStorageTests : XCTestCase -@property(nonatomic) NSString *keySuffix; -@property(nonatomic) GACAppAttestKeyIDStorage *storage; -@end - -@implementation GACAppAttestKeyIDStorageTests - -- (void)setUp { - [super setUp]; - - self.keySuffix = [NSString stringWithFormat:@"%@.%@", kAppName, kAppID]; - self.storage = [[GACAppAttestKeyIDStorage alloc] initWithKeySuffix:self.keySuffix]; -} - -- (void)tearDown { - // Remove the app attest key ID from storage. - [self.storage setAppAttestKeyID:nil]; - FBLWaitForPromisesWithTimeout(1.0); - self.storage = nil; - - [super tearDown]; -} - -- (void)testInitWithApp { - XCTAssertNotNil([[GACAppAttestKeyIDStorage alloc] initWithKeySuffix:self.keySuffix]); -} - -- (void)testSetAndGetAppAttestKeyID { - NSString *appAttestKeyID = @"app_attest_key_ID"; - - FBLPromise *setPromise = [self.storage setAppAttestKeyID:appAttestKeyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(setPromise.value, appAttestKeyID); - XCTAssertNil(setPromise.error); - - __auto_type getPromise = [self.storage getAppAttestKeyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(getPromise.value, appAttestKeyID); - XCTAssertNil(getPromise.error); -} - -- (void)testRemoveAppAttestKeyID { - FBLPromise *setPromise = [self.storage setAppAttestKeyID:nil]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(setPromise.value, nil); - XCTAssertNil(setPromise.error); -} - -- (void)testGetAppAttestKeyID_WhenAppAttestKeyIDNotFoundError { - __auto_type getPromise = [self.storage getAppAttestKeyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertNotNil(getPromise.error); - XCTAssertEqualObjects(getPromise.error, [_GACAppCheckErrorUtil appAttestKeyIDNotFound]); -} - -- (void)testSetGetAppAttestKeyIDPerApp { - // Assert storages for apps with the same name can independently set/get app attest key ID. - [self assertIndependentSetGetForStoragesWithAppName1:kAppName - appID1:@"app_id_1" - appName2:kAppName - appID2:@"app_id_2"]; - // Assert storages for apps with the same app ID can independently set/get app attest key ID. - [self assertIndependentSetGetForStoragesWithAppName1:@"app_1" - appID1:kAppID - appName2:@"app_2" - appID2:kAppID]; - // Assert storages for apps with different info can independently set/get app attest key ID. - [self assertIndependentSetGetForStoragesWithAppName1:@"app_1" - appID1:@"app_id_1" - appName2:@"app_2" - appID2:@"app_id_2"]; -} - -#pragma mark - Helpers - -- (void)assertIndependentSetGetForStoragesWithAppName1:(NSString *)appName1 - appID1:(NSString *)appID1 - appName2:(NSString *)appName2 - appID2:(NSString *)appID2 { - NSString *keySuffix1 = [GACAppAttestKeyIDStorageTests storageKeySuffixForAppName:appName1 - appID:appID1]; - NSString *keySuffix2 = [GACAppAttestKeyIDStorageTests storageKeySuffixForAppName:appName2 - appID:appID2]; - - // Create two storages. - GACAppAttestKeyIDStorage *storage1 = - [[GACAppAttestKeyIDStorage alloc] initWithKeySuffix:keySuffix1]; - GACAppAttestKeyIDStorage *storage2 = - [[GACAppAttestKeyIDStorage alloc] initWithKeySuffix:keySuffix2]; - // 1. Independently set app attest key IDs for the two storages. - NSString *appAttestKeyID1 = @"app_attest_key_ID1"; - FBLPromise *setPromise1 = [storage1 setAppAttestKeyID:appAttestKeyID1]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(setPromise1.value, appAttestKeyID1); - XCTAssertNil(setPromise1.error); - - NSString *appAttestKeyID2 = @"app_attest_key_ID2"; - __auto_type setPromise2 = [storage2 setAppAttestKeyID:appAttestKeyID2]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(setPromise2.value, appAttestKeyID2); - XCTAssertNil(setPromise2.error); - - // 2. Get app attest key IDs for the two storages. - __auto_type getPromise1 = [storage1 getAppAttestKeyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(getPromise1.value, appAttestKeyID1); - XCTAssertNil(getPromise1.error); - - __auto_type getPromise2 = [storage2 getAppAttestKeyID]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(getPromise2.value, appAttestKeyID2); - XCTAssertNil(getPromise2.error); - - // 3. Assert that the app attest key IDs were set and retrieved independently of one another. - XCTAssertNotEqualObjects(getPromise1.value, getPromise2.value); - - // Cleanup storages. - [storage1 setAppAttestKeyID:nil]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - [storage2 setAppAttestKeyID:nil]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); -} - -// TODO(andrewheard): Remove from generic App Check SDK. -// FIREBASE_APP_CHECK_ONLY_BEGIN - -+ (NSString *)storageKeySuffixForAppName:(NSString *)appName appID:(NSString *)appID { - return [NSString stringWithFormat:@"%@.%@", appName, appID]; -} - -// FIREBASE_APP_CHECK_ONLY_END - -@end diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreAPIServiceTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreAPIServiceTests.swift new file mode 100644 index 00000000..72fe657c --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreAPIServiceTests.swift @@ -0,0 +1,404 @@ +// 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 +import XCTest + +private let kAPIKeyHeaderKey = "X-Goog-Api-Key" +private let kAPIKeyHeaderValue = "Test-API-Key" +private let kBundleIDHeaderKey = "X-Ios-Bundle-Identifier" +private let kTestHeaderKey = "X-test-header" +private let kTestHeaderValue = "TEST_HEADER_VALUE" + +class AppCheckCoreAPIServiceTests: XCTestCase { + var apiService: AppCheckCoreAPIService! + var fakeURLSession: AppCheckCoreURLSessionFake! + var expectedHTTPHeaderFields: [String: String]! + + override func setUp() { + super.setUp() + + fakeURLSession = AppCheckCoreURLSessionFake() + + if let bundleID = Bundle.main.bundleIdentifier { + expectedHTTPHeaderFields = [kBundleIDHeaderKey: bundleID] + } else { + expectedHTTPHeaderFields = [:] + } + + apiService = AppCheckCoreAPIService( + urlSession: fakeURLSession.session, + baseURL: nil, + apiKey: nil, + requestHooks: nil, + environment: [:] + ) + } + + override func tearDown() { + apiService = nil + fakeURLSession = nil + expectedHTTPHeaderFields = nil + super.tearDown() + } + + // MARK: - Init + + func testInitDefaultBaseURL() { + let service = AppCheckCoreAPIService( + urlSession: fakeURLSession.session, + baseURL: nil, + apiKey: nil, + requestHooks: nil, + environment: [:] + ) + XCTAssertNotNil(service) + XCTAssertEqual(service.baseURL, "https://firebaseappcheck.googleapis.com/v1") + } + + func testInitCustomBaseURL() { + let customBaseURL = "https://custom.example.com/v1beta" + let service = AppCheckCoreAPIService( + urlSession: fakeURLSession.session, + baseURL: customBaseURL, + apiKey: nil, + requestHooks: nil, + environment: [:] + ) + XCTAssertNotNil(service) + XCTAssertEqual(service.baseURL, customBaseURL) + } + + func testInitBaseURLStagingTriggeredByEnvVar() { + let stagingBaseURL = "https://staging-firebaseappcheck.sandbox.googleapis.com/v1" + let service = AppCheckCoreAPIService( + urlSession: fakeURLSession.session, + baseURL: nil, + apiKey: nil, + requestHooks: nil, + environment: ["_AppCheckUseStaging": "YES"] + ) + XCTAssertNotNil(service) + XCTAssertEqual(service.baseURL, stagingBaseURL) + } + + func testInitBaseURLStagingNotTriggeredWhenEnvVarIsNo() { + let prodBaseURL = "https://firebaseappcheck.googleapis.com/v1" + let service = AppCheckCoreAPIService( + urlSession: fakeURLSession.session, + baseURL: nil, + apiKey: nil, + requestHooks: nil, + environment: ["_AppCheckUseStaging": "NO"] + ) + XCTAssertNotNil(service) + XCTAssertEqual(service.baseURL, prodBaseURL) + } + + // MARK: - Send Requests + + func testDataRequestNetworkError() async { + let url = URL(string: "https://some.url.com")! + let additionalHeaders = ["header1": "value1"] + let requestBody = "Request body".data(using: .utf8)! + + // 1. Stub URL session. + let networkError = NSError(domain: "testDataRequestNetworkError", code: -1, userInfo: nil) + stubURLSessionDataTask(response: nil, body: nil, error: networkError) + + // 2. Send request & 3. Verify. + do { + _ = try await apiService.sendRequest(withURL: url, + httpMethod: "POST", + body: requestBody, + additionalHeaders: additionalHeaders) + XCTFail("Expected error to be thrown") + } catch { + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain) + XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.serverUnreachable.rawValue) + let underlying = nsError.userInfo[NSUnderlyingErrorKey] as? NSError + XCTAssertEqual(underlying?.domain, networkError.domain) + XCTAssertEqual(underlying?.code, networkError.code) + } + + XCTAssertTrue(fakeURLSession.isInvoked) + } + + func testDataRequestNot2xxHTTPStatusCode() async { + let url = URL(string: "https://some.url.com")! + let requestBody = "Request body".data(using: .utf8)! + let responseBodyString = "Token verification failed." + let httpResponseBody = responseBodyString.data(using: .utf8)! + let httpResponse = AppCheckCoreURLSessionFake.httpResponse(withCode: 300) + + stubURLSessionDataTask(response: httpResponse, body: httpResponseBody, error: nil) + + do { + _ = try await apiService.sendRequest(withURL: url, + httpMethod: "POST", + body: requestBody, + additionalHeaders: nil) + XCTFail("Expected error to be thrown") + } catch { + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain) + XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.unknown.rawValue) + + let failureReason = nsError.userInfo[NSLocalizedFailureReasonErrorKey] as? String + XCTAssertNotNil(failureReason) + XCTAssertTrue(failureReason?.contains("300") ?? false) + XCTAssertTrue(failureReason?.contains(responseBodyString) ?? false) + } + + XCTAssertTrue(fakeURLSession.isInvoked) + } + + func testDataRequestWithRequestHooks() async throws { + let url = URL(string: "https://some.url.com")! + let httpMethod = "POST" + let requestBody = "Request body".data(using: .utf8)! + let requestTimeout: TimeInterval = 5.0 + expectedHTTPHeaderFields[kTestHeaderKey] = kTestHeaderValue + + let headerRequestHook: AppCheckCoreAPIRequestHook = { request in + request.addValue(kTestHeaderValue, forHTTPHeaderField: kTestHeaderKey) + } + let timeoutRequestHook: AppCheckCoreAPIRequestHook = { request in + request.timeoutInterval = requestTimeout + } + let cellularAccessRequestHook: AppCheckCoreAPIRequestHook = { request in + request.allowsCellularAccess = false + } + + apiService = AppCheckCoreAPIService( + urlSession: fakeURLSession.session, + baseURL: nil, + apiKey: nil, + requestHooks: [headerRequestHook, timeoutRequestHook, cellularAccessRequestHook], + environment: [:] + ) + + let requestValidation: (URLRequest) -> Bool = { request in + XCTAssertEqual(request.url, url) + XCTAssertEqual(request.httpMethod, httpMethod) + XCTAssertEqual(request.httpBody, requestBody) + var actualHeaders = request + .allHTTPHeaderFields; actualHeaders?["Content-Length"] = nil; XCTAssertEqual(actualHeaders, + self + .expectedHTTPHeaderFields) + XCTAssertEqual(request.timeoutInterval, requestTimeout) + XCTAssertEqual(request.allowsCellularAccess, false) + return true + } + + let httpResponseBody = "A response".data(using: .utf8)! + let httpResponse = AppCheckCoreURLSessionFake.httpResponse(withCode: 200) + stubURLSessionDataTask( + response: httpResponse, + body: httpResponseBody, + error: nil, + requestValidationBlock: requestValidation + ) + + let result = try await apiService.sendRequest(withURL: url, + httpMethod: httpMethod, + body: requestBody, + additionalHeaders: nil) + + XCTAssertEqual(result.httpResponse.statusCode, httpResponse.statusCode) + XCTAssertEqual(result.httpBody, httpResponseBody) + XCTAssertTrue(fakeURLSession.isInvoked) + } + + func testDataRequestWithAdditionalHeaders() async throws { + let url = URL(string: "https://some.url.com")! + let httpMethod = "POST" + let requestBody = "Request body".data(using: .utf8)! + let additionalHeaders = [kTestHeaderKey: kTestHeaderValue] + + for (k, v) in additionalHeaders { + expectedHTTPHeaderFields[k] = v + } + + let requestValidation: (URLRequest) -> Bool = { request in + XCTAssertEqual(request.url, url) + XCTAssertEqual(request.httpMethod, httpMethod) + XCTAssertEqual(request.httpBody, requestBody) + var actualHeaders = request + .allHTTPHeaderFields; actualHeaders?["Content-Length"] = nil; XCTAssertEqual(actualHeaders, + self + .expectedHTTPHeaderFields) + return true + } + + let httpResponseBody = "A response".data(using: .utf8)! + let httpResponse = AppCheckCoreURLSessionFake.httpResponse(withCode: 200) + stubURLSessionDataTask( + response: httpResponse, + body: httpResponseBody, + error: nil, + requestValidationBlock: requestValidation + ) + + let result = try await apiService.sendRequest(withURL: url, + httpMethod: httpMethod, + body: requestBody, + additionalHeaders: additionalHeaders) + + XCTAssertEqual(result.httpResponse.statusCode, httpResponse.statusCode) + XCTAssertEqual(result.httpBody, httpResponseBody) + XCTAssertTrue(fakeURLSession.isInvoked) + } + + func testDataRequestWithAPIKey() async throws { + let url = URL(string: "https://some.url.com")! + let httpMethod = "POST" + let requestBody = "Request body".data(using: .utf8)! + expectedHTTPHeaderFields[kAPIKeyHeaderKey] = kAPIKeyHeaderValue + + apiService = AppCheckCoreAPIService( + urlSession: fakeURLSession.session, + baseURL: nil, + apiKey: kAPIKeyHeaderValue, + requestHooks: nil, + environment: [:] + ) + + let requestValidation: (URLRequest) -> Bool = { request in + XCTAssertEqual(request.url, url) + XCTAssertEqual(request.httpMethod, httpMethod) + XCTAssertEqual(request.httpBody, requestBody) + var actualHeaders = request + .allHTTPHeaderFields; actualHeaders?["Content-Length"] = nil; XCTAssertEqual(actualHeaders, + self + .expectedHTTPHeaderFields) + return true + } + + let httpResponseBody = "A response".data(using: .utf8)! + let httpResponse = AppCheckCoreURLSessionFake.httpResponse(withCode: 200) + stubURLSessionDataTask( + response: httpResponse, + body: httpResponseBody, + error: nil, + requestValidationBlock: requestValidation + ) + + let result = try await apiService.sendRequest(withURL: url, + httpMethod: httpMethod, + body: requestBody, + additionalHeaders: nil) + + XCTAssertEqual(result.httpResponse.statusCode, httpResponse.statusCode) + XCTAssertEqual(result.httpBody, httpResponseBody) + XCTAssertTrue(fakeURLSession.isInvoked) + } + + // MARK: - Token Exchange API response + + func testAppCheckTokenWithAPIResponseValidResponse() async throws { + let responseBody = try AppCheckCoreFixtureLoader + .loadFixture(named: "FACTokenExchangeResponseSuccess.json") + XCTAssertNotNil(responseBody) + + let httpResponse = AppCheckCoreURLSessionFake.httpResponse(withCode: 200) + let apiResponse = AppCheckCoreURLSessionDataResponse( + response: httpResponse, + httpBody: responseBody + ) + + let expectedFACToken = "valid_app_check_token" + + let token = try await apiService.appCheckToken(withAPIResponse: apiResponse) + + XCTAssertEqual(token.token, expectedFACToken) + XCTAssertEqual(token.expirationDate.timeIntervalSinceNow, 1800, accuracy: 10) + } + + func testAppCheckTokenWithAPIResponseInvalidFormat() async { + let responseBodyString = "Token verification failed." + let responseBody = responseBodyString.data(using: .utf8)! + let httpResponse = AppCheckCoreURLSessionFake.httpResponse(withCode: 200) + let apiResponse = AppCheckCoreURLSessionDataResponse( + response: httpResponse, + httpBody: responseBody + ) + + do { + _ = try await apiService.appCheckToken(withAPIResponse: apiResponse) + XCTFail("Expected error to be thrown") + } catch { + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain) + XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.unknown.rawValue) + let failureReason = nsError.userInfo[NSLocalizedFailureReasonErrorKey] as? String + XCTAssertEqual(failureReason, "JSON serialization error.") + } + } + + func testAppCheckTokenResponseMissingFields() async throws { + try await assertMissingFieldError( + fixtureName: "DeviceCheckResponseMissingToken.json", + missingField: "token" + ) + try await assertMissingFieldError( + fixtureName: "DeviceCheckResponseMissingTimeToLive.json", + missingField: "ttl" + ) + } + + func assertMissingFieldError(fixtureName: String, missingField: String) async throws { + let missingFieldBody = try AppCheckCoreFixtureLoader.loadFixture(named: fixtureName) + XCTAssertNotNil(missingFieldBody) + + let httpResponse = AppCheckCoreURLSessionFake.httpResponse(withCode: 200) + let apiResponse = AppCheckCoreURLSessionDataResponse( + response: httpResponse, + httpBody: missingFieldBody + ) + + do { + _ = try await apiService.appCheckToken(withAPIResponse: apiResponse) + XCTFail("Expected error to be thrown") + } catch { + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain) + XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.unknown.rawValue) + let failureReason = nsError.userInfo[NSLocalizedFailureReasonErrorKey] as? String + XCTAssertTrue( + failureReason?.contains("`\(missingField)`") ?? false, + "Fixture `\(fixtureName)`: expected missing field \(missingField) error not found" + ) + } + } + + // MARK: - Helpers + + private func stubURLSessionDataTask(response: HTTPURLResponse?, + body: Data?, + error: Error?, + requestValidationBlock: ((URLRequest) -> Bool)? = nil) { + fakeURLSession.requestValidationBlock = requestValidationBlock + fakeURLSession.resultError = error + if error == nil { + fakeURLSession.resultResponse = AppCheckCoreURLSessionDataResponse( + response: response!, + httpBody: body + ) + } else { + fakeURLSession.resultResponse = nil + } + } +} diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreBackoffWrapperTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreBackoffWrapperTests.swift new file mode 100644 index 00000000..6e058245 --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreBackoffWrapperTests.swift @@ -0,0 +1,299 @@ +// 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 +import XCTest + +class AppCheckCoreBackoffWrapperTests: XCTestCase { + var backoffWrapper: AppCheckCoreBackoffWrapper! + var currentDate: Date! + + var operationResult: Any? + var operationProvider: (() async throws -> Any)! + var operationFinishExpectation: XCTestExpectation! + + var errorHandler: AppCheckCoreBackoffErrorHandler! + var errorHandlerExpectation: XCTestExpectation! + + override func setUp() { + super.setUp() + + currentDate = Date() + backoffWrapper = AppCheckCoreBackoffWrapper(dateProvider: { [weak self] in + return self?.currentDate ?? Date() + }) + } + + override func tearDown() { + backoffWrapper = nil + operationProvider = nil + currentDate = nil + super.tearDown() + } + + func testBackoffFirstOperationAlwaysExecuted() async throws { + setUpOperationSuccess() + setUpErrorHandler(with: .none) + errorHandlerExpectation.isInverted = true + + let result = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0) + + XCTAssertEqual(result as? NSObject, operationResult as? NSObject) + } + + func testBackoff1DayBackoffAfterFailure() async { + currentDate = Date() + + setUpOperationError() + setUpErrorHandler(with: .oneDay) + + do { + _ = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + XCTFail("Expected error") + } catch { + XCTAssertEqual(error as NSError, operationResult as? NSError) + } + + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0) + + // Check backoff in 12 hours + setUpOperationError() + setUpErrorHandler(with: .oneDay) + operationFinishExpectation.isInverted = true + errorHandlerExpectation.isInverted = true + + currentDate = currentDate.addingTimeInterval(12 * 60 * 60) + + do { + _ = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + XCTFail("Expected error") + } catch { + XCTAssertTrue(isBackoffError(error as NSError)) + } + + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0) + + // Check backoff one minute before allowing retry + setUpOperationError() + setUpErrorHandler(with: .oneDay) + operationFinishExpectation.isInverted = true + errorHandlerExpectation.isInverted = true + + currentDate = currentDate.addingTimeInterval(11 * 60 * 60 + 59 * 60) + + do { + _ = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + XCTFail("Expected error") + } catch { + XCTAssertTrue(isBackoffError(error as NSError)) + } + + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0) + + // Check backoff one minute after allowing retry + setUpOperationError() + setUpErrorHandler(with: .oneDay) + + currentDate = currentDate.addingTimeInterval(12 * 60 * 60 + 1 * 60) + + do { + _ = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + XCTFail("Expected error") + } catch { + XCTAssertEqual(error as NSError, operationResult as? NSError) + } + + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0) + } + + func testExponentialBackoff() async throws { + currentDate = Date() + + setUpOperationError() + setUpErrorHandler(with: .exponential) + + do { + _ = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + XCTFail("Expected error") + } catch { + XCTAssertEqual(error as NSError, operationResult as? NSError) + } + + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0) + + let numberOfAttempts = 20 + let maximumBackoff: TimeInterval = 4 * 60 * 60 + let maxJitterPortion = 0.5 + + for attempt in 0 ..< numberOfAttempts { + let expectedMinBackoff = min(pow(2.0, Double(attempt)), maximumBackoff) + let expectedMaxBackoff = min(expectedMinBackoff * (1 + maxJitterPortion), maximumBackoff) + + await assertBackoffInterval(isAtLeast: expectedMinBackoff, andAtMost: expectedMaxBackoff) + } + + // Test recovery after success + currentDate = currentDate.addingTimeInterval(maximumBackoff) + + setUpOperationSuccess() + setUpErrorHandler(with: .none) + errorHandlerExpectation.isInverted = true + + let result = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0) + XCTAssertEqual(result as? NSObject, operationResult as? NSObject) + + // Set up operation failure (no backoff after success) + setUpOperationError() + setUpErrorHandler(with: .exponential) + + do { + _ = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + XCTFail("Expected error") + } catch { + XCTAssertEqual(error as NSError, operationResult as? NSError) + } + + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0) + } + + func testDefaultAppCheckProviderErrorHandler() { + let handler = backoffWrapper.defaultAppCheckProviderErrorHandler() + + let nonHTTPError = NSError(domain: "test", code: 1, userInfo: nil) + XCTAssertEqual(handler(nonHTTPError), .none) + + XCTAssertEqual(handler(httpError(withStatusCode: 400)), .oneDay) + XCTAssertEqual(handler(httpError(withStatusCode: 403)), .exponential) + XCTAssertEqual(handler(httpError(withStatusCode: 404)), .oneDay) + XCTAssertEqual(handler(httpError(withStatusCode: 429)), .exponential) + XCTAssertEqual(handler(httpError(withStatusCode: 503)), .exponential) + + for statusCode in 400 ..< 600 { + if statusCode == 400 || statusCode == 404 { continue } + XCTAssertEqual(handler(httpError(withStatusCode: statusCode)), .exponential) + } + } + + // MARK: - Helpers + + private func setUpErrorHandler(with backoffType: AppCheckBackoffType) { + errorHandlerExpectation = expectation(description: "Error handler") + errorHandler = { [weak self] error in + self?.errorHandlerExpectation.fulfill() + return backoffType + } + } + + private func setUpOperationSuccess() { + operationFinishExpectation = expectation(description: "Operation performed") + operationResult = NSObject() + operationProvider = { [weak self] in + self?.operationFinishExpectation.fulfill() + return self?.operationResult as Any + } + } + + private func setUpOperationError() { + operationFinishExpectation = expectation(description: "Operation performed") + operationResult = NSError(domain: name, code: -1, userInfo: nil) + operationProvider = { [weak self] in + self?.operationFinishExpectation.fulfill() + throw (self?.operationResult as! Error) + } + } + + private func isBackoffError(_ error: NSError) -> Bool { + return error.localizedDescription.contains("Too many attempts. Underlying error:") + } + + private func httpError(withStatusCode statusCode: Int) -> AppCheckCoreHTTPError { + let httpResponse = HTTPURLResponse(url: URL(string: "https://localhost")!, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil)! + return AppCheckCoreHTTPError(httpResponse: httpResponse, data: nil) + } + + private func assertBackoffInterval( + isAtLeast minBackoff: TimeInterval, + andAtMost maxBackoff: TimeInterval + ) async { + let lastFailureDate = currentDate! + + // Test backoff before min interval + currentDate = lastFailureDate.addingTimeInterval(minBackoff - 0.5) + + setUpOperationError() + setUpErrorHandler(with: .exponential) + operationFinishExpectation.isInverted = true + errorHandlerExpectation.isInverted = true + + do { + _ = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + XCTFail("Expected error") + } catch { + XCTAssertTrue(isBackoffError(error as NSError)) + } + + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0) + + // Test backoff after max interval + currentDate = lastFailureDate.addingTimeInterval(maxBackoff + 0.5) + + setUpOperationError() + setUpErrorHandler(with: .exponential) + + do { + _ = try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) + XCTFail("Expected error") + } catch { + XCTAssertFalse(isBackoffError(error as NSError)) + } + + await fulfillment(of: [operationFinishExpectation, errorHandlerExpectation], timeout: 5.0) + } +} diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreCryptoUtilsTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreCryptoUtilsTests.swift new file mode 100644 index 00000000..0a2c97e4 --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreCryptoUtilsTests.swift @@ -0,0 +1,32 @@ +// 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 +import XCTest + +class AppCheckCoreCryptoUtilsTests: XCTestCase { + func testSHA256HashFromData() { + let dataToHash = "some data to hash".data(using: .utf8)! + + let hashData = AppCheckCoreCryptoUtils.sha256Hash(from: dataToHash) + + // Convert to a base64 encoded string to compare. + let base64EncodedHashString = hashData.base64EncodedString() + + // Base64 encoded hash of UTF8 encoded string "some data to hash". + let expectedHashString = "ai2iCUOTHpg0/BLP5btHu9muQ0iaMHJpYrV29OOZPlA=" + + XCTAssertEqual(base64EncodedHashString, expectedHashString) + } +} diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreLoggerTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreLoggerTests.swift new file mode 100644 index 00000000..6edd6a34 --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreLoggerTests.swift @@ -0,0 +1,32 @@ +// 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 +import XCTest + +class AppCheckCoreLoggerTests: XCTestCase { + func testDefaultLogLevel() { + let defaultLogLevel = AppCheckCoreLogger.logLevel + + XCTAssertEqual(defaultLogLevel, .warning) + } + + func testSetLogLevel() { + let expectedLogLevel: AppCheckCoreLogLevel = .debug + + AppCheckCoreLogger.logLevel = expectedLogLevel + + XCTAssertEqual(AppCheckCoreLogger.logLevel, expectedLogLevel) + } +} diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreStorageTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreStorageTests.swift new file mode 100644 index 00000000..080caa4c --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreStorageTests.swift @@ -0,0 +1,198 @@ +// 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 +import XCTest + +private let kAppName = "AppCheckCoreStorageTestsApp" +private let kGoogleAppID = "1:100000000000:ios:aaaaaaaaaaaaaaaaaaaaaaaa" + +// Tests that use the Keychain require a host app and Swift Package Manager +// does not support adding a host app to test targets. +#if !SWIFT_PACKAGE + + // Skip keychain tests on Catalyst and macOS. Tests are skipped because they + // involve interactions with the keychain that require a provisioning profile. + // See go/firebase-macos-keychain-popups for more details. + #if !targetEnvironment(macCatalyst) && !os(macOS) + + class AppCheckCoreStorageTests: XCTestCase { + var tokenKey: String! + var storage: AppCheckCoreStorage! + + override func setUp() { + super.setUp() + + tokenKey = tokenKey(withGoogleAppID: kGoogleAppID) + storage = AppCheckCoreStorage(tokenKey: tokenKey, accessGroup: nil) + } + + override func tearDown() { + storage = nil + super.tearDown() + } + + func testSetAndGetToken() async throws { + let tokenToStore = AppCheckCoreToken(token: "token", + expirationDate: Date.distantPast, + receivedAt: Date()) + + let storedToken = try await storage.setToken(tokenToStore) + XCTAssertEqual(storedToken, tokenToStore) + + let retrievedToken = try await storage.getToken() + XCTAssertEqual(retrievedToken?.token, tokenToStore.token) + XCTAssertEqual(retrievedToken?.expirationDate, tokenToStore.expirationDate) + XCTAssertEqual(retrievedToken?.receivedAtDate, tokenToStore.receivedAtDate) + } + + func testRemoveToken() async throws { + let removedToken = try await storage.setToken(nil as AppCheckCoreToken?) + XCTAssertNil(removedToken) + + let retrievedToken = try await storage.getToken() + XCTAssertNil(retrievedToken) + } + + func testGetToken_KeychainError() async { + // 1. Set up storage mock. + let fakeKeychainStorage = AppCheckCoreKeychainStorageFake() + let storage = AppCheckCoreStorage(tokenKey: tokenKey, + keychainStorage: fakeKeychainStorage, + accessGroup: nil) + + // 2. Create and expect keychain error. + let gulsKeychainError = NSError( + domain: "com.google.utilities.keychain", + code: -1, + userInfo: nil + ) + fakeKeychainStorage.keychainError = gulsKeychainError + + // 3. Get token and verify results. + do { + _ = try await storage.getToken() + XCTFail("Expected error to be thrown") + } catch { + let nsError = error as NSError + let expectedError = AppCheckCoreErrorUtil + .keychainError(with: gulsKeychainError) as NSError + XCTAssertEqual(nsError.domain, expectedError.domain) + XCTAssertEqual(nsError.code, expectedError.code) + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, + let expectedUnderlyingError = expectedError + .userInfo[NSUnderlyingErrorKey] as? NSError { + XCTAssertEqual(underlyingError.domain, expectedUnderlyingError.domain) + XCTAssertEqual(underlyingError.code, expectedUnderlyingError.code) + } + } + } + + func testSetToken_KeychainError() async { + // 1. Set up storage mock. + let fakeKeychainStorage = AppCheckCoreKeychainStorageFake() + let storage = AppCheckCoreStorage(tokenKey: tokenKey, + keychainStorage: fakeKeychainStorage, + accessGroup: nil) + + // 2. Create and expect keychain error. + let gulsKeychainError = NSError( + domain: "com.google.utilities.keychain", + code: -1, + userInfo: nil + ) + fakeKeychainStorage.keychainError = gulsKeychainError + + // 3. Set token and verify results. + let tokenToStore = AppCheckCoreToken(token: "token", + expirationDate: Date.distantPast, + receivedAt: Date()) + do { + _ = try await storage.setToken(tokenToStore) + XCTFail("Expected error to be thrown") + } catch { + let nsError = error as NSError + let expectedError = AppCheckCoreErrorUtil + .keychainError(with: gulsKeychainError) as NSError + XCTAssertEqual(nsError.domain, expectedError.domain) + XCTAssertEqual(nsError.code, expectedError.code) + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, + let expectedUnderlyingError = expectedError + .userInfo[NSUnderlyingErrorKey] as? NSError { + XCTAssertEqual(underlyingError.domain, expectedUnderlyingError.domain) + XCTAssertEqual(underlyingError.code, expectedUnderlyingError.code) + } + } + } + + func testRemoveToken_KeychainError() async { + // 1. Set up storage mock. + let fakeKeychainStorage = AppCheckCoreKeychainStorageFake() + let storage = AppCheckCoreStorage(tokenKey: tokenKey, + keychainStorage: fakeKeychainStorage, + accessGroup: nil) + + // 2. Create and expect keychain error. + let gulsKeychainError = NSError( + domain: "com.google.utilities.keychain", + code: -1, + userInfo: nil + ) + fakeKeychainStorage.keychainError = gulsKeychainError + + // 3. Remove token and verify results. + do { + _ = try await storage.setToken(nil as AppCheckCoreToken?) + XCTFail("Expected error to be thrown") + } catch { + let nsError = error as NSError + let expectedError = AppCheckCoreErrorUtil + .keychainError(with: gulsKeychainError) as NSError + XCTAssertEqual(nsError.domain, expectedError.domain) + XCTAssertEqual(nsError.code, expectedError.code) + if let underlyingError = nsError.userInfo[NSUnderlyingErrorKey] as? NSError, + let expectedUnderlyingError = expectedError + .userInfo[NSUnderlyingErrorKey] as? NSError { + XCTAssertEqual(underlyingError.domain, expectedUnderlyingError.domain) + XCTAssertEqual(underlyingError.code, expectedUnderlyingError.code) + } + } + } + + func testSetTokenPerApp() async throws { + // 1. Set token with a storage. + let tokenToStore = AppCheckCoreToken(token: "token", + expirationDate: Date.distantPast, + receivedAt: Date()) + + let storedToken = try await storage.setToken(tokenToStore) + XCTAssertEqual(storedToken, tokenToStore) + + // 2. Try to read the token with another storage. + let tokenKey2 = tokenKey(withGoogleAppID: "1:200000000000:ios:aaaaaaaaaaaaaaaaaaaaaaaa") + let storage2 = AppCheckCoreStorage(tokenKey: tokenKey2, accessGroup: nil) + + let retrievedToken = try await storage2.getToken() + XCTAssertNil(retrievedToken) + } + + // MARK: - Private Helpers + + private func tokenKey(withGoogleAppID googleAppID: String) -> String { + return "app_check_token.\(kAppName).\(googleAppID)" + } + } + + #endif // !targetEnvironment(macCatalyst) && !os(macOS) +#endif // !SWIFT_PACKAGE diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreStoredTokenTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreStoredTokenTests.swift new file mode 100644 index 00000000..4807c69f --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreStoredTokenTests.swift @@ -0,0 +1,58 @@ +// 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 +import XCTest + +class AppCheckCoreStoredTokenTests: XCTestCase { + func testSecureCoding() throws { + let tokenToArchive = AppCheckCoreStoredToken() + tokenToArchive.token = "some_token" + tokenToArchive.expirationDate = Date() + tokenToArchive.receivedAtDate = tokenToArchive.expirationDate?.addingTimeInterval(-10) + + let archivedToken = try NSKeyedArchiver.archivedData(withRootObject: tokenToArchive, + requiringSecureCoding: true) + XCTAssertNotNil(archivedToken) + + let unarchivedToken = try NSKeyedUnarchiver.unarchivedObject( + ofClass: AppCheckCoreStoredToken.self, + from: archivedToken + ) + XCTAssertNotNil(unarchivedToken) + XCTAssertEqual(unarchivedToken?.token, tokenToArchive.token) + XCTAssertEqual(unarchivedToken?.expirationDate, tokenToArchive.expirationDate) + XCTAssertEqual(unarchivedToken?.receivedAtDate, tokenToArchive.receivedAtDate) + XCTAssertEqual(unarchivedToken?.storageVersion, tokenToArchive.storageVersion) + } + + func testConvertingToAndFromAppCheckCoreToken() { + let date = Date() + let originalToken = AppCheckCoreToken(token: "___", + expirationDate: date, + receivedAt: date) + + let storedToken = AppCheckCoreStoredToken() + storedToken.update(with: originalToken) + XCTAssertEqual(originalToken.token, storedToken.token) + XCTAssertEqual(originalToken.expirationDate, storedToken.expirationDate) + XCTAssertEqual(originalToken.receivedAtDate, storedToken.receivedAtDate) + + let recoveredToken = storedToken.appCheckToken() + XCTAssertNotNil(recoveredToken) + XCTAssertEqual(recoveredToken?.token, storedToken.token) + XCTAssertEqual(recoveredToken?.expirationDate, storedToken.expirationDate) + XCTAssertEqual(recoveredToken?.receivedAtDate, storedToken.receivedAtDate) + } +} diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreTests.swift new file mode 100644 index 00000000..d361b44f --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreTests.swift @@ -0,0 +1,374 @@ +// 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 +import XCTest + +private let kPlaceholderTokenValue = "eyJlcnJvciI6IlVOS05PV05fRVJST1IifQ==" +private let kResourceName = "projects/test_project_id/apps/test_app_id" +private let kAppName = "AppCheckCoreTests" +private let kAppGroupID = "app_group_id" + +class AppCheckCoreTests: XCTestCase { + var fakeStorage: AppCheckCoreStorageFake! + var fakeAppCheckProvider: AppCheckCoreProviderFake! + var fakeTokenRefresher: AppCheckCoreTokenRefresherFake! + var fakeSettings: AppCheckCoreSettingsFake! + var fakeTokenDelegate: AppCheckCoreTokenDelegateFake! + var appCheck: AppCheckCore! + + override func setUp() { + super.setUp() + + fakeStorage = AppCheckCoreStorageFake() + fakeAppCheckProvider = AppCheckCoreProviderFake() + fakeTokenRefresher = AppCheckCoreTokenRefresherFake() + fakeSettings = AppCheckCoreSettingsFake() + fakeTokenDelegate = AppCheckCoreTokenDelegateFake() + + appCheck = AppCheckCore(serviceName: kAppName, + appCheckProvider: fakeAppCheckProvider, + storage: fakeStorage, + tokenRefresher: fakeTokenRefresher, + settings: fakeSettings, + tokenDelegate: fakeTokenDelegate) + } + + override func tearDown() { + appCheck = nil + fakeAppCheckProvider = nil + fakeStorage = nil + fakeTokenRefresher = nil + fakeSettings = nil + fakeTokenDelegate = nil + super.tearDown() + } + + // MARK: - Public Get Token + + func testGetToken_WhenNoCache_Success() async { + let expectedToken = validToken() + configuredExpectations_GetTokenWhenNoCache(expectedToken: expectedToken) + + do { + let result = try await appCheck.token(forcingRefresh: false) + XCTAssertEqual(result, expectedToken) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(fakeAppCheckProvider.getTokenCallCount, 1) + XCTAssertEqual(fakeStorage.lastSetToken, expectedToken) + XCTAssertEqual(fakeTokenRefresher.updateWithRefreshResultCallCount, 1) + XCTAssertEqual(fakeTokenDelegate.lastToken, expectedToken) + } + + func testGetToken_WhenCachedTokenIsValid_Success() async { + await assertGetToken_WhenCachedTokenIsValid_Success() + } + + func testGetTokenForcingRefresh_WhenCachedTokenIsValid_Success() async { + let expectedToken = validToken() + configuredExpectations_GetTokenForcingRefreshWhenCacheIsValid(expectedToken: expectedToken) + + do { + let result = try await appCheck.token(forcingRefresh: true) + XCTAssertEqual(result, expectedToken) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(fakeAppCheckProvider.getTokenCallCount, 1) + XCTAssertEqual(fakeStorage.lastSetToken, expectedToken) + XCTAssertEqual(fakeTokenRefresher.updateWithRefreshResultCallCount, 1) + XCTAssertEqual(fakeTokenDelegate.lastToken, expectedToken) + } + + func testGetToken_WhenCachedTokenExpired_Success() async { + let expectedToken = validToken() + configuredExpectations_GetTokenWhenCachedTokenExpired(expectedToken: expectedToken) + + do { + let result = try await appCheck.token(forcingRefresh: false) + XCTAssertEqual(result, expectedToken) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(fakeAppCheckProvider.getTokenCallCount, 1) + XCTAssertEqual(fakeStorage.lastSetToken, expectedToken) + XCTAssertEqual(fakeTokenRefresher.updateWithRefreshResultCallCount, 1) + XCTAssertEqual(fakeTokenDelegate.lastToken, expectedToken) + } + + func testGetToken_AppCheckProviderError() async { + let cachedToken = soonExpiringToken() + let providerError = NSError(domain: "AppCheckCoreTests", code: -1, userInfo: nil) + + configuredExpectations_GetTokenWhenError(error: providerError, token: cachedToken) + + do { + _ = try await appCheck.token(forcingRefresh: false) + XCTFail("Expected error") + } catch { + XCTAssertEqual(error as NSError, providerError) + XCTAssertNotEqual((error as NSError).domain, AppCheckCoreErrorDomain) + } + + XCTAssertEqual(fakeAppCheckProvider.getTokenCallCount, 1) + XCTAssertEqual(fakeTokenDelegate.tokenDidUpdateCallCount, 0) + XCTAssertNil(fakeStorage.lastSetToken) + XCTAssertEqual(fakeTokenRefresher.updateWithRefreshResultCallCount, 0) + } + + // MARK: - Token refresher + + func testTokenRefreshTriggeredAndRefreshSuccess() async { + fakeStorage.getTokenHandler = { nil } + + let expirationDate = Date(timeIntervalSinceNow: 10000) + let tokenToReturn = AppCheckCoreToken(token: "valid", expirationDate: expirationDate) + fakeAppCheckProvider.tokenToReturn = tokenToReturn + + fakeStorage.setTokenHandler = { token in tokenToReturn } + + guard let handler = fakeTokenRefresher.tokenRefreshHandler else { + XCTFail("`tokenRefreshHandler` must be not `nil`.") + return + } + + let completionExpectation = expectation(description: "completion") + handler { refreshResult in + XCTAssertEqual(refreshResult.tokenExpirationDate, expirationDate) + XCTAssertEqual(refreshResult.status, .success) + completionExpectation.fulfill() + } + + await fulfillment(of: [completionExpectation], timeout: 0.5) + + XCTAssertEqual(fakeAppCheckProvider.getTokenCallCount, 1) + XCTAssertEqual(fakeStorage.lastSetToken, tokenToReturn) + XCTAssertEqual(fakeTokenRefresher.updateWithRefreshResultCallCount, 1) + XCTAssertEqual(fakeTokenDelegate.tokenDidUpdateCallCount, 1) + XCTAssertEqual(fakeTokenDelegate.lastToken, tokenToReturn) + } + + func testTokenRefreshTriggeredAndRefreshError() async { + fakeStorage.getTokenHandler = { nil } + + let providerError = internalError() + fakeAppCheckProvider.errorToReturn = providerError + + guard let handler = fakeTokenRefresher.tokenRefreshHandler else { + XCTFail("`tokenRefreshHandler` must be not `nil`.") + return + } + + let completionExpectation = expectation(description: "completion") + handler { refreshResult in + XCTAssertEqual(refreshResult.status, .failure) + XCTAssertNil(refreshResult.tokenExpirationDate) + XCTAssertNil(refreshResult.tokenReceivedAtDate) + completionExpectation.fulfill() + } + + await fulfillment(of: [completionExpectation], timeout: 0.5) + + XCTAssertEqual(fakeAppCheckProvider.getTokenCallCount, 1) + XCTAssertEqual(fakeTokenDelegate.tokenDidUpdateCallCount, 0) + XCTAssertNil(fakeStorage.lastSetToken) + XCTAssertEqual(fakeTokenRefresher.updateWithRefreshResultCallCount, 0) + } + + func testLimitedUseTokenWithSuccess() async { + let expectedToken = validToken() + fakeAppCheckProvider.limitedUseTokenToReturn = expectedToken + + do { + let result = try await appCheck.limitedUseToken() + XCTAssertEqual(result, expectedToken) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(fakeAppCheckProvider.getLimitedUseTokenCallCount, 1) + XCTAssertEqual(fakeStorage.lastSetToken, nil) + XCTAssertEqual(fakeTokenDelegate.tokenDidUpdateCallCount, 0) + } + + func testLimitedUseToken_WhenTokenGenerationErrors() async { + let providerError = AppCheckCoreErrorUtil.keychainError(with: internalError()) as NSError + fakeAppCheckProvider.limitedUseErrorToReturn = providerError + + do { + _ = try await appCheck.limitedUseToken() + XCTFail("Expected error") + } catch { + XCTAssertEqual(error as NSError, providerError) + XCTAssertEqual((error as NSError).domain, AppCheckCoreErrorDomain) + } + + XCTAssertEqual(fakeAppCheckProvider.getLimitedUseTokenCallCount, 1) + XCTAssertEqual(fakeAppCheckProvider.getTokenCallCount, 0) + XCTAssertNil(fakeStorage.lastSetToken) + XCTAssertEqual(fakeTokenDelegate.tokenDidUpdateCallCount, 0) + XCTAssertEqual(fakeTokenRefresher.updateWithRefreshResultCallCount, 0) + } + + // MARK: - Merging multiple get token requests + + func testGetToken_WhenCalledSeveralTimesSuccess_ThenThereIsOnlyOneOperation() async { + fakeStorage.getTokenHandler = { nil } + + let expectedToken = validToken() + fakeAppCheckProvider.tokenToReturn = expectedToken + + // Create a continuation we can resume later + var storeTokenContinuation: CheckedContinuation? + fakeStorage.setTokenHandler = { token in + try await withCheckedThrowingContinuation { continuation in + storeTokenContinuation = continuation + } + } + + let getTokenCallsCount = 10 + + // Request token several times concurrently + Task { + // Delay so the task group launches before we resume the continuation + try? await Task.sleep(nanoseconds: 100_000_000) + storeTokenContinuation?.resume(returning: expectedToken) + } + + await withTaskGroup(of: Void.self) { group in + for _ in 0 ..< getTokenCallsCount { + group.addTask { + do { + let result = try await self.appCheck.token(forcingRefresh: false) + XCTAssertEqual(result, expectedToken) + } catch { + XCTFail("Unexpected error") + } + } + } + } + + XCTAssertEqual(fakeAppCheckProvider.getTokenCallCount, 1) + XCTAssertEqual(fakeTokenRefresher.updateWithRefreshResultCallCount, 1) + XCTAssertEqual(fakeTokenDelegate.tokenDidUpdateCallCount, 1) + + await assertGetToken_WhenCachedTokenIsValid_Success() + } + + func testGetToken_WhenCalledSeveralTimesError_ThenThereIsOnlyOneOperation() async { + fakeStorage.getTokenHandler = { nil } + + let expectedToken = validToken() + fakeAppCheckProvider.tokenToReturn = expectedToken + + var storeTokenContinuation: CheckedContinuation? + fakeStorage.setTokenHandler = { token in + try await withCheckedThrowingContinuation { continuation in + storeTokenContinuation = continuation + } + } + + let storageError = NSError(domain: name, code: 0, userInfo: nil) + let getTokenCallsCount = 10 + + Task { + try? await Task.sleep(nanoseconds: 100_000_000) + storeTokenContinuation?.resume(throwing: storageError) + } + + await withTaskGroup(of: Void.self) { group in + for _ in 0 ..< getTokenCallsCount { + group.addTask { + do { + _ = try await self.appCheck.token(forcingRefresh: false) + XCTFail("Expected error") + } catch { + XCTAssertEqual(error as NSError, storageError) + } + } + } + } + + XCTAssertEqual(fakeAppCheckProvider.getTokenCallCount, 1) + XCTAssertEqual(fakeTokenDelegate.tokenDidUpdateCallCount, 0) + XCTAssertEqual(fakeStorage.lastSetToken, expectedToken) + XCTAssertEqual(fakeTokenRefresher.updateWithRefreshResultCallCount, 0) + + await assertGetToken_WhenCachedTokenIsValid_Success() + } + + // MARK: - Helpers + + private func internalError() -> NSError { + return NSError(domain: "com.internal.error", code: -1, userInfo: nil) + } + + private func validToken() -> AppCheckCoreToken { + return AppCheckCoreToken(token: UUID().uuidString, expirationDate: Date.distantFuture) + } + + private func soonExpiringToken() -> AppCheckCoreToken { + let date = Date(timeIntervalSinceNow: 4.5 * 60) + return AppCheckCoreToken(token: "valid", expirationDate: date) + } + + private func assertGetToken_WhenCachedTokenIsValid_Success() async { + let initialCallCount = fakeAppCheckProvider.getTokenCallCount + let cachedToken = validToken() + + configuredExpectation_GetTokenWhenCacheTokenIsValid(expectedToken: cachedToken) + + do { + let result = try await appCheck.token(forcingRefresh: false) + XCTAssertEqual(result, cachedToken) + } catch { + XCTFail("Unexpected error: \(error)") + } + + XCTAssertEqual(fakeAppCheckProvider.getTokenCallCount, initialCallCount) + } + + private func configuredExpectations_GetTokenWhenNoCache(expectedToken: AppCheckCoreToken) { + fakeStorage.getTokenHandler = { nil } + fakeAppCheckProvider.tokenToReturn = expectedToken + fakeStorage.setTokenHandler = { token in expectedToken } + } + + private func configuredExpectation_GetTokenWhenCacheTokenIsValid(expectedToken: AppCheckCoreToken) { + fakeStorage.getTokenHandler = { expectedToken } + } + + private func configuredExpectations_GetTokenForcingRefreshWhenCacheIsValid(expectedToken: AppCheckCoreToken) { + fakeAppCheckProvider.tokenToReturn = expectedToken + fakeStorage.setTokenHandler = { token in expectedToken } + } + + private func configuredExpectations_GetTokenWhenCachedTokenExpired(expectedToken: AppCheckCoreToken) { + let cachedToken = AppCheckCoreToken(token: "expired", expirationDate: Date()) + fakeStorage.getTokenHandler = { cachedToken } + + fakeAppCheckProvider.tokenToReturn = expectedToken + fakeStorage.setTokenHandler = { token in expectedToken } + } + + private func configuredExpectations_GetTokenWhenError(error: Error, token: AppCheckCoreToken?) { + fakeStorage.getTokenHandler = { token } + fakeAppCheckProvider.errorToReturn = error + } +} diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreTimerTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreTimerTests.swift new file mode 100644 index 00000000..fa82c34f --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreTimerTests.swift @@ -0,0 +1,60 @@ +// 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 +import XCTest + +class AppCheckCoreTimerTests: XCTestCase { + func testTimerProvider() { + let queue = DispatchQueue(label: "AppCheckCoreTimerTests.testInit", qos: .default) + let fireTimerIn: TimeInterval = 1 + let startTime = Date() + let fireDate = Date(timeIntervalSinceNow: fireTimerIn) + + let timerProvider = AppCheckCoreTimer.timerProvider() + + let timerExpectation = expectation(description: "timer") + let timer = timerProvider(fireDate, queue) { + let actuallyFiredIn = Date().timeIntervalSince(startTime) + // Check that fired at proper time (allowing some timer drift). + XCTAssertLessThan(abs(actuallyFiredIn - fireTimerIn), 0.5) + + timerExpectation.fulfill() + } + + XCTAssertNotNil(timer) + + waitForExpectations(timeout: fireTimerIn + 1) + } + + func testInit() { + let queue = DispatchQueue(label: "AppCheckCoreTimerTests.testInit", qos: .default) + let fireTimerIn: TimeInterval = 2 + let startTime = Date() + let fireDate = Date(timeIntervalSinceNow: fireTimerIn) + + let timerExpectation = expectation(description: "timer") + let timer = AppCheckCoreTimer(fireDate: fireDate, dispatchQueue: queue) { + let actuallyFiredIn = Date().timeIntervalSince(startTime) + // Check that fired at proper time (allowing some timer drift). + XCTAssertLessThan(abs(actuallyFiredIn - fireTimerIn), 0.5) + + timerExpectation.fulfill() + } + + XCTAssertNotNil(timer) + + waitForExpectations(timeout: fireTimerIn + 1) + } +} diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreTokenRefresherTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreTokenRefresherTests.swift new file mode 100644 index 00000000..b0106441 --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreTokenRefresherTests.swift @@ -0,0 +1,388 @@ +// 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 +import XCTest + +class AppCheckCoreTokenRefresherTests: XCTestCase { + var fakeTimer: AppCheckCoreFakeTimer! + var settings: AppCheckCoreSettings! + var initialTokenRefreshResult: AppCheckCoreTokenRefreshResult! + + override func setUp() { + super.setUp() + + settings = AppCheckCoreSettings() + fakeTimer = AppCheckCoreFakeTimer() + + let receivedAtDate = Date() + initialTokenRefreshResult = AppCheckCoreTokenRefreshResult(status: .success, + expirationDate: receivedAtDate + .addingTimeInterval(1000), + receivedAtDate: receivedAtDate) + } + + override func tearDown() { + fakeTimer = nil + settings = nil + super.tearDown() + } + + // MARK: - Auto refresh is allowed + + func testInitialRefreshWhenAutoRefreshAllowed() { + initialTokenRefreshResult = AppCheckCoreTokenRefreshResult(status: .never, + expirationDate: nil, + receivedAtDate: nil) + let refresher = createRefresher() + + settings.isTokenAutoRefreshEnabled = true + + let initialTimerCreatedExpectation = expectation(description: "initial refresh timer created") + initialTimerCreatedExpectation.isInverted = true + fakeTimer.createHandler = { [weak self] fireDate in + self?.fakeTimer.createHandler = nil + initialTimerCreatedExpectation.fulfill() + } + + settings.isTokenAutoRefreshEnabled = true + + var initialRefreshCompletion: AppCheckCoreTokenRefreshCompletion? + let initialRefreshExpectation = expectation(description: "initial refresh") + refresher.tokenRefreshHandler = { completion in + initialRefreshCompletion = completion + initialRefreshExpectation.fulfill() + } + + let initialTokenExpirationDate = Date(timeIntervalSinceNow: 60 * 60) + let initialTokenReceivedDate = Date() + let initialRefreshResult = AppCheckCoreTokenRefreshResult(status: .success, + expirationDate: initialTokenExpirationDate, + receivedAtDate: initialTokenReceivedDate) + + wait(for: [initialTimerCreatedExpectation, initialRefreshExpectation], timeout: 1) + + settings.isTokenAutoRefreshEnabled = true + + let expectedRefreshDate = self.expectedRefreshDate( + receivedDate: initialTokenReceivedDate, + expirationDate: initialTokenExpirationDate + ) + let nextTimerCreateExpectation = expectation(description: "next refresh create timer") + fakeTimer.createHandler = { [weak self] fireDate in + self?.fakeTimer.createHandler = nil + XCTAssertEqual(fireDate, expectedRefreshDate) + nextTimerCreateExpectation.fulfill() + } + + initialRefreshCompletion?(initialRefreshResult) + wait(for: [nextTimerCreateExpectation], timeout: 0.5) + + settings.isTokenAutoRefreshEnabled = true + + let nextRefreshResult = AppCheckCoreTokenRefreshResult(status: .success, + expirationDate: expectedRefreshDate + .addingTimeInterval(60 * 60), + receivedAtDate: expectedRefreshDate) + let nextRefreshExpectation = expectation(description: "next refresh") + refresher.tokenRefreshHandler = { completion in + nextRefreshExpectation.fulfill() + completion(nextRefreshResult) + } + + fireTimer() + + wait(for: [nextRefreshExpectation], timeout: 1) + } + + func testNoTimeScheduledUntilHandlerSet() { + let timerCreateExpectation1 = expectation(description: "create timer 1") + timerCreateExpectation1.isInverted = true + fakeTimer.createHandler = { fireDate in + timerCreateExpectation1.fulfill() + } + + let refresher = createRefresher() + XCTAssertNotNil(refresher) + + wait(for: [timerCreateExpectation1], timeout: 0.5) + + settings.isTokenAutoRefreshEnabled = true + + let timerCreateExpectation2 = expectation(description: "create timer 2") + fakeTimer.createHandler = { fireDate in + timerCreateExpectation2.fulfill() + } + + refresher.tokenRefreshHandler = { completion in } + + wait(for: [timerCreateExpectation2], timeout: 0.5) + } + + func testNextRefreshOnRefreshSuccess() { + let refresher = createRefresher() + + let refreshedTokenExpirationDate = initialTokenRefreshResult.tokenExpirationDate! + .addingTimeInterval(60 * 60) + let refreshResult = AppCheckCoreTokenRefreshResult(status: .success, + expirationDate: refreshedTokenExpirationDate, + receivedAtDate: initialTokenRefreshResult + .tokenExpirationDate!) + + settings.isTokenAutoRefreshEnabled = true + settings.isTokenAutoRefreshEnabled = true + + let initialRefreshExpectation = expectation(description: "initial refresh") + refresher.tokenRefreshHandler = { completion in + initialRefreshExpectation.fulfill() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + completion(refreshResult) + } + } + + let expectedFireDate = expectedRefreshDate( + receivedDate: refreshResult.tokenReceivedAtDate!, + expirationDate: refreshResult.tokenExpirationDate! + ) + let createTimerExpectation = expectation(description: "create timer") + fakeTimer.createHandler = { fireDate in + createTimerExpectation.fulfill() + XCTAssertEqual(fireDate, expectedFireDate) + } + + settings.isTokenAutoRefreshEnabled = true + + fireTimer() + + wait(for: [initialRefreshExpectation, createTimerExpectation], timeout: 1, enforceOrder: true) + } + + func testBackoff() { + let refresher = createRefresher() + + var expectedBackoffTime: TimeInterval = 0 + let maximumBackoffTime: TimeInterval = 16 * 60 + + settings.isTokenAutoRefreshEnabled = true + + for _ in 0 ..< 10 { + settings.isTokenAutoRefreshEnabled = true + + let initialRefreshExpectation = expectation(description: "initial refresh") + refresher.tokenRefreshHandler = { completion in + initialRefreshExpectation.fulfill() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + let refreshFailure = AppCheckCoreTokenRefreshResult( + status: .failure, + expirationDate: nil, + receivedAtDate: nil + ) + completion(refreshFailure) + } + } + + expectedBackoffTime = expectedBackoffTime == 0 ? 30 : expectedBackoffTime * 2 + expectedBackoffTime = min(expectedBackoffTime, maximumBackoffTime) + let expectedFireDate = Date().addingTimeInterval(expectedBackoffTime) + + let createTimerExpectation = expectation(description: "create timer") + fakeTimer.createHandler = { fireDate in + createTimerExpectation.fulfill() + XCTAssertLessThan(abs(expectedFireDate.timeIntervalSince(fireDate)), 2) + } + + settings.isTokenAutoRefreshEnabled = true + + fireTimer() + + wait(for: [initialRefreshExpectation, createTimerExpectation], timeout: 1, enforceOrder: true) + } + } + + // MARK: - Auto refresh is not allowed + + func testNoInitialRefreshWhenAutoRefreshIsNotAllowed() { + let refresher = createRefresher() + + settings.isTokenAutoRefreshEnabled = false + + let timerCreateExpectation = expectation(description: "create timer") + timerCreateExpectation.isInverted = true + + fakeTimer.createHandler = { [weak self] fireDate in + self?.fakeTimer.createHandler = nil + timerCreateExpectation.fulfill() + } + + let refreshResult = AppCheckCoreTokenRefreshResult(status: .success, + expirationDate: Date(timeIntervalSinceNow: 60 * + 60), + receivedAtDate: Date()) + let refreshExpectation = expectation(description: "refresh") + refreshExpectation.isInverted = true + + refresher.tokenRefreshHandler = { completion in + refreshExpectation.fulfill() + completion(refreshResult) + } + + wait(for: [timerCreateExpectation, refreshExpectation], timeout: 1) + } + + func testNoRefreshWhenAutoRefreshWasDisabledAfterInit() { + let refresher = createRefresher() + + settings.isTokenAutoRefreshEnabled = true + + let expectedTimerFireDate = expectedRefreshDate( + receivedDate: initialTokenRefreshResult.tokenReceivedAtDate!, + expirationDate: initialTokenRefreshResult.tokenExpirationDate! + ) + let timerCreateExpectation = expectation(description: "create timer") + + fakeTimer.createHandler = { [weak self] fireDate in + self?.fakeTimer.createHandler = nil + XCTAssertEqual(fireDate, expectedTimerFireDate) + timerCreateExpectation.fulfill() + } + + let refreshResult = AppCheckCoreTokenRefreshResult(status: .success, + expirationDate: expectedTimerFireDate + .addingTimeInterval(60 * 60), + receivedAtDate: expectedTimerFireDate) + let noRefreshExpectation = expectation(description: "initial refresh") + noRefreshExpectation.isInverted = true + refresher.tokenRefreshHandler = { completion in + noRefreshExpectation.fulfill() + completion(refreshResult) + } + + wait(for: [timerCreateExpectation], timeout: 1) + + settings.isTokenAutoRefreshEnabled = false + + fireTimer() + + wait(for: [noRefreshExpectation], timeout: 1) + } + + // MARK: - Update token expiration + + func testUpdateWithRefreshResultWhenAutoRefreshIsAllowed() { + let refresher = createRefresher() + + let newExpirationDate = initialTokenRefreshResult.tokenExpirationDate! + .addingTimeInterval(10 * 60) + let newRefreshResult = AppCheckCoreTokenRefreshResult(status: .success, + expirationDate: newExpirationDate, + receivedAtDate: initialTokenRefreshResult + .tokenExpirationDate!) + + settings.isTokenAutoRefreshEnabled = true + + let expectedTimerFireDate = expectedRefreshDate( + receivedDate: newRefreshResult.tokenReceivedAtDate!, + expirationDate: newRefreshResult.tokenExpirationDate! + ) + let timerCreateExpectation = expectation(description: "create timer") + + fakeTimer.createHandler = { [weak self] fireDate in + self?.fakeTimer.createHandler = nil + XCTAssertEqual(fireDate, expectedTimerFireDate) + timerCreateExpectation.fulfill() + } + + refresher.updateWithRefreshResult(newRefreshResult) + + wait(for: [timerCreateExpectation], timeout: 1) + } + + func testUpdateWithRefreshResultWhenAutoRefreshIsNotAllowed() { + let refresher = createRefresher() + + let newRefreshResult = AppCheckCoreTokenRefreshResult(status: .success, + expirationDate: Date(timeIntervalSinceNow: 60 * + 60), + receivedAtDate: initialTokenRefreshResult + .tokenExpirationDate!) + + settings.isTokenAutoRefreshEnabled = false + + let timerCreateExpectation = expectation(description: "create timer") + timerCreateExpectation.isInverted = true + + fakeTimer.createHandler = { [weak self] fireDate in + self?.fakeTimer.createHandler = nil + timerCreateExpectation.fulfill() + } + + refresher.updateWithRefreshResult(newRefreshResult) + + wait(for: [timerCreateExpectation], timeout: 1) + } + + func testUpdateWithRefreshResult_WhenTokenExpiresLessThanIn1Minute() { + let refresher = createRefresher() + + let newExpirationDate = Date(timeIntervalSinceNow: 0.5 * 60) + let newRefreshResult = AppCheckCoreTokenRefreshResult(status: .success, + expirationDate: newExpirationDate, + receivedAtDate: Date()) + + settings.isTokenAutoRefreshEnabled = true + + let timerCreateExpectation = expectation(description: "create timer") + + fakeTimer.createHandler = { [weak self] fireDate in + self?.fakeTimer.createHandler = nil + XCTAssertEqual(fireDate.timeIntervalSinceNow, 60, accuracy: 1) + timerCreateExpectation.fulfill() + } + + refresher.updateWithRefreshResult(newRefreshResult) + + wait(for: [timerCreateExpectation], timeout: 1) + } + + // MARK: - Helpers + + private func fireTimer() { + if let handler = fakeTimer.handler { + handler() + } else { + XCTFail("handler must not be nil!") + } + } + + private func createRefresher() -> AppCheckCoreTokenRefresher { + return AppCheckCoreTokenRefresher(refreshResult: initialTokenRefreshResult, + timerProvider: fakeTimer.fakeTimerProvider(), + settings: settings) + } + + private func expectedRefreshDate(receivedDate: Date, expirationDate: Date) -> Date { + let timeToLive = expirationDate.timeIntervalSince(receivedDate) + XCTAssertGreaterThanOrEqual(timeToLive, 0) + + var timeToRefresh = timeToLive / 2 + 5 * 60 + + let minimalAutoRefreshInterval: TimeInterval = 60 + timeToRefresh = max(timeToRefresh, minimalAutoRefreshInterval) + + let refreshDate = receivedDate.addingTimeInterval(timeToRefresh) + let now = Date() + + return max(refreshDate, now) + } +} diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreTokenResultTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreTokenResultTests.swift new file mode 100644 index 00000000..21710c24 --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreTokenResultTests.swift @@ -0,0 +1,78 @@ +// 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 +import XCTest + +private let kTestTokenValue = "test-token" +/// Placeholder value that indicates failure: `{"error":"UNKNOWN_ERROR"}` encoded as base64 +private let kPlaceholderTokenValue = "eyJlcnJvciI6IlVOS05PV05fRVJST1IifQ==" +private let kTestErrorDomain = "TestErrorDomain" +private let kTestErrorCode = 42 + +class AppCheckCoreTokenResultTests: XCTestCase { + func testInitWithToken() { + let expectedExpirationDate = Date(timeIntervalSince1970: 1_693_314_000.0) + let expectedReceivedAtDate = Date(timeIntervalSince1970: 1_693_317_600.0) + let expectedToken = AppCheckCoreToken(token: kTestTokenValue, + expirationDate: expectedExpirationDate, + receivedAt: expectedReceivedAtDate) + + let tokenResult = AppCheckCoreTokenResult(token: expectedToken) + + XCTAssertEqual(tokenResult.token, expectedToken) + XCTAssertNil(tokenResult.error) + } + + func testInitWithError() { + let expectedError = NSError(domain: kTestErrorDomain, + code: kTestErrorCode, + userInfo: nil) + + let tokenResult = AppCheckCoreTokenResult(error: expectedError) + + XCTAssertEqual(tokenResult.token.token, kPlaceholderTokenValue) + XCTAssertNotNil(tokenResult.error) + XCTAssertEqual(tokenResult.error as NSError?, expectedError) + } + + func testInitWithTokenAndError() { + let placeholderToken = AppCheckCoreTokenResult.placeholderToken() + let expectedError = NSError(domain: kTestErrorDomain, + code: kTestErrorCode, + userInfo: nil) + + let tokenResult = AppCheckCoreTokenResult(token: placeholderToken, error: expectedError) + + XCTAssertEqual(tokenResult.token, placeholderToken) + XCTAssertNotNil(tokenResult.error) + XCTAssertEqual(tokenResult.error as NSError?, expectedError) + } + + func testPlaceholderToken() { + let expectedExpirationDate = Date.distantPast + let expectedReceivedAtDate = Date() // Current time + + let placeholderToken = AppCheckCoreTokenResult.placeholderToken() + + XCTAssertEqual(placeholderToken.token, kPlaceholderTokenValue) + // Verify that the placeholder token's received at time is approximately equal to current time. + XCTAssertEqual( + placeholderToken.receivedAtDate.timeIntervalSince(expectedReceivedAtDate), + 0, + accuracy: 5.0 + ) + XCTAssertEqual(placeholderToken.expirationDate, expectedExpirationDate) + } +} diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckAPIServiceTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckAPIServiceTests.m deleted file mode 100644 index 4d2db9ff..00000000 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckAPIServiceTests.m +++ /dev/null @@ -1,478 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "FBLPromise+Testing.h" - -#import "AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.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/Core/_GACAppCheckAPIService+Internal.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/Unit/Utils/GACURLSessionFake.h" -#import "AppCheckCore/Tests/Utils/Date/GACDateTestUtils.h" - -static NSString *const kAPIKeyHeaderKey = @"X-Goog-Api-Key"; -static NSString *const kAPIKeyHeaderValue = @"Test-API-Key"; -static NSString *const kBundleIDHeaderKey = @"X-Ios-Bundle-Identifier"; -static NSString *const kTestHeaderKey = @"X-test-header"; -static NSString *const kTestHeaderValue = @"TEST_HEADER_VALUE"; - -#pragma mark - _GACAppCheckAPIServiceTests - -@interface _GACAppCheckAPIServiceTests : XCTestCase - -@property(nonatomic) _GACAppCheckAPIService *APIService; - -@property(nonatomic) GACURLSessionFake *fakeURLSession; - -@property(nonatomic) NSMutableDictionary *expectedHTTPHeaderFields; - -@end - -@implementation _GACAppCheckAPIServiceTests - -- (void)setUp { - [super setUp]; - - self.fakeURLSession = [[GACURLSessionFake alloc] init]; - - self.expectedHTTPHeaderFields = [NSMutableDictionary - dictionaryWithDictionary:@{kBundleIDHeaderKey : [[NSBundle mainBundle] bundleIdentifier]}]; - - self.APIService = - [[_GACAppCheckAPIService alloc] initWithURLSession:(NSURLSession *)self.fakeURLSession - baseURL:nil - APIKey:nil - requestHooks:nil - environment:@{}]; -} - -- (void)tearDown { - [super tearDown]; - - self.APIService = nil; - self.fakeURLSession = nil; -} - -#pragma mark - Init - -- (void)testInitDefaultBaseURL { - _GACAppCheckAPIService *APIService = - [[_GACAppCheckAPIService alloc] initWithURLSession:(NSURLSession *)self.fakeURLSession - baseURL:nil - APIKey:nil - requestHooks:nil - environment:@{}]; - - XCTAssertNotNil(APIService); - XCTAssertEqualObjects(APIService.baseURL, @"https://firebaseappcheck.googleapis.com/v1"); -} - -- (void)testInitCustomBaseURL { - NSString *customBaseURL = @"https://custom.example.com/v1beta"; - - _GACAppCheckAPIService *APIService = - [[_GACAppCheckAPIService alloc] initWithURLSession:(NSURLSession *)self.fakeURLSession - baseURL:customBaseURL - APIKey:nil - requestHooks:nil - environment:@{}]; - - XCTAssertNotNil(APIService); - XCTAssertEqualObjects(APIService.baseURL, customBaseURL); -} - -- (void)testInitBaseURLStagingTriggeredByEnvVar { - NSString *stagingBaseURL = @"https://staging-firebaseappcheck.sandbox.googleapis.com/v1"; - - _GACAppCheckAPIService *APIService = - [[_GACAppCheckAPIService alloc] initWithURLSession:(NSURLSession *)self.fakeURLSession - baseURL:nil - APIKey:nil - requestHooks:nil - environment:@{@"_AppCheckUseStaging" : @"YES"}]; - - XCTAssertNotNil(APIService); - XCTAssertEqualObjects(APIService.baseURL, stagingBaseURL); -} - -- (void)testInitBaseURLStagingNotTriggeredWhenEnvVarIsNo { - NSString *prodBaseURL = @"https://firebaseappcheck.googleapis.com/v1"; - - _GACAppCheckAPIService *APIService = - [[_GACAppCheckAPIService alloc] initWithURLSession:(NSURLSession *)self.fakeURLSession - baseURL:nil - APIKey:nil - requestHooks:nil - environment:@{@"_AppCheckUseStaging" : @"NO"}]; - - XCTAssertNotNil(APIService); - XCTAssertEqualObjects(APIService.baseURL, prodBaseURL); -} - -#pragma mark - Send Requests - -- (void)testDataRequestNetworkError { - NSURL *URL = [NSURL URLWithString:@"https://some.url.com"]; - NSDictionary *additionalHeaders = @{@"header1" : @"value1"}; - NSData *requestBody = [@"Request body" dataUsingEncoding:NSUTF8StringEncoding]; - - // 1. Stub URL session. - NSError *networkError = [NSError errorWithDomain:self.name code:-1 userInfo:nil]; - - [self stubURLSessionDataTaskPromiseWithResponse:nil - body:nil - error:networkError - URLSessionMock:self.fakeURLSession - requestValidationBlock:nil]; - - // 2. Send request. - __auto_type requestPromise = [self.APIService sendRequestWithURL:URL - HTTPMethod:@"POST" - body:requestBody - additionalHeaders:additionalHeaders]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(requestPromise.isRejected); - XCTAssertNotNil(requestPromise.error); - XCTAssertEqualObjects(requestPromise.error.domain, GACAppCheckErrorDomain); - XCTAssertEqual(requestPromise.error.code, GACAppCheckErrorCodeServerUnreachable); - XCTAssertEqualObjects(requestPromise.error.userInfo[NSUnderlyingErrorKey], networkError); - - XCTAssertTrue(self.fakeURLSession.isInvoked); -} - -- (void)testDataRequestNot2xxHTTPStatusCode { - NSURL *URL = [NSURL URLWithString:@"https://some.url.com"]; - NSData *requestBody = [@"Request body" dataUsingEncoding:NSUTF8StringEncoding]; - NSString *responseBodyString = @"Token verification failed."; - - NSData *HTTPResponseBody = [responseBodyString dataUsingEncoding:NSUTF8StringEncoding]; - NSHTTPURLResponse *HTTPResponse = [GACURLSessionFake HTTPResponseWithCode:300]; - [self stubURLSessionDataTaskPromiseWithResponse:HTTPResponse - body:HTTPResponseBody - error:nil - URLSessionMock:self.fakeURLSession - requestValidationBlock:nil]; - - // 2. Send request. - __auto_type requestPromise = [self.APIService sendRequestWithURL:URL - HTTPMethod:@"POST" - body:requestBody - additionalHeaders:nil]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(requestPromise.isRejected); - XCTAssertNil(requestPromise.value); - - XCTAssertNotNil(requestPromise.error); - XCTAssertEqualObjects(requestPromise.error.domain, GACAppCheckErrorDomain); - XCTAssertEqual(requestPromise.error.code, GACAppCheckErrorCodeUnknown); - - // Expect response body and HTTP status code to be included in the error. - NSString *failureReason = requestPromise.error.userInfo[NSLocalizedFailureReasonErrorKey]; - XCTAssertNotNil(failureReason); - XCTAssertTrue([failureReason containsString:@"300"]); - XCTAssertTrue([failureReason containsString:responseBodyString]); - - XCTAssertTrue(self.fakeURLSession.isInvoked); -} - -- (void)testDataRequestWithRequestHooks { - NSURL *URL = [NSURL URLWithString:@"https://some.url.com"]; - NSString *HTTPMethod = @"POST"; - NSData *requestBody = [@"Request body" dataUsingEncoding:NSUTF8StringEncoding]; - NSTimeInterval requestTimeout = 5.0; - [self.expectedHTTPHeaderFields setObject:kTestHeaderValue forKey:kTestHeaderKey]; - - GACAppCheckAPIRequestHook headerRequestHook = ^(NSMutableURLRequest *request) { - [request addValue:kTestHeaderValue forHTTPHeaderField:kTestHeaderKey]; - }; - GACAppCheckAPIRequestHook timeoutRequestHook = ^(NSMutableURLRequest *request) { - request.timeoutInterval = requestTimeout; - }; - GACAppCheckAPIRequestHook cellularAccessRequestHook = ^(NSMutableURLRequest *request) { - request.allowsCellularAccess = NO; - }; - - self.APIService = [[_GACAppCheckAPIService alloc] - initWithURLSession:(NSURLSession *)self.fakeURLSession - baseURL:nil - APIKey:nil - requestHooks:@[ headerRequestHook, timeoutRequestHook, cellularAccessRequestHook ] - environment:@{}]; - - // 1. Stub URL session. - FIRRequestValidationBlock requestValidation = ^BOOL(NSURLRequest *request) { - XCTAssertEqualObjects(request.URL, URL); - XCTAssertEqualObjects(request.HTTPMethod, HTTPMethod); - XCTAssertEqualObjects(request.HTTPBody, requestBody); - - XCTAssertEqualObjects(request.allHTTPHeaderFields, self.expectedHTTPHeaderFields); - XCTAssertEqual(request.timeoutInterval, requestTimeout); - XCTAssertEqual(request.allowsCellularAccess, NO); - - return YES; - }; - - NSData *HTTPResponseBody = [@"A response" dataUsingEncoding:NSUTF8StringEncoding]; - NSHTTPURLResponse *HTTPResponse = [GACURLSessionFake HTTPResponseWithCode:200]; - [self stubURLSessionDataTaskPromiseWithResponse:HTTPResponse - body:HTTPResponseBody - error:nil - URLSessionMock:self.fakeURLSession - requestValidationBlock:requestValidation]; - - // 2. Send request. - __auto_type requestPromise = [self.APIService sendRequestWithURL:URL - HTTPMethod:HTTPMethod - body:requestBody - additionalHeaders:nil]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(requestPromise.isFulfilled); - XCTAssertNil(requestPromise.error); - - XCTAssertEqualObjects(requestPromise.value.HTTPResponse, HTTPResponse); - XCTAssertEqualObjects(requestPromise.value.HTTPBody, HTTPResponseBody); - - XCTAssertTrue(self.fakeURLSession.isInvoked); -} - -- (void)testDataRequestWithAdditionalHeaders { - NSURL *URL = [NSURL URLWithString:@"https://some.url.com"]; - NSString *HTTPMethod = @"POST"; - NSData *requestBody = [@"Request body" dataUsingEncoding:NSUTF8StringEncoding]; - NSDictionary *additionalHeaders = @{kTestHeaderKey : kTestHeaderValue}; - [self.expectedHTTPHeaderFields addEntriesFromDictionary:additionalHeaders]; - - // 1. Stub URL session. - FIRRequestValidationBlock requestValidation = ^BOOL(NSURLRequest *request) { - XCTAssertEqualObjects(request.URL, URL); - XCTAssertEqualObjects(request.HTTPMethod, HTTPMethod); - XCTAssertEqualObjects(request.HTTPBody, requestBody); - - XCTAssertEqualObjects(request.allHTTPHeaderFields, self.expectedHTTPHeaderFields); - - return YES; - }; - - NSData *HTTPResponseBody = [@"A response" dataUsingEncoding:NSUTF8StringEncoding]; - NSHTTPURLResponse *HTTPResponse = [GACURLSessionFake HTTPResponseWithCode:200]; - [self stubURLSessionDataTaskPromiseWithResponse:HTTPResponse - body:HTTPResponseBody - error:nil - URLSessionMock:self.fakeURLSession - requestValidationBlock:requestValidation]; - - // 2. Send request. - __auto_type requestPromise = [self.APIService sendRequestWithURL:URL - HTTPMethod:HTTPMethod - body:requestBody - additionalHeaders:additionalHeaders]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(requestPromise.isFulfilled); - XCTAssertNil(requestPromise.error); - - XCTAssertEqualObjects(requestPromise.value.HTTPResponse, HTTPResponse); - XCTAssertEqualObjects(requestPromise.value.HTTPBody, HTTPResponseBody); - - XCTAssertTrue(self.fakeURLSession.isInvoked); -} - -- (void)testDataRequestWithAPIKey { - NSURL *URL = [NSURL URLWithString:@"https://some.url.com"]; - NSString *HTTPMethod = @"POST"; - NSData *requestBody = [@"Request body" dataUsingEncoding:NSUTF8StringEncoding]; - [self.expectedHTTPHeaderFields setObject:kAPIKeyHeaderValue forKey:kAPIKeyHeaderKey]; - - self.APIService = - [[_GACAppCheckAPIService alloc] initWithURLSession:(NSURLSession *)self.fakeURLSession - baseURL:nil - APIKey:kAPIKeyHeaderValue - requestHooks:nil - environment:@{}]; - - // 1. Stub URL session. - FIRRequestValidationBlock requestValidation = ^BOOL(NSURLRequest *request) { - XCTAssertEqualObjects(request.URL, URL); - XCTAssertEqualObjects(request.HTTPMethod, HTTPMethod); - XCTAssertEqualObjects(request.HTTPBody, requestBody); - - XCTAssertEqualObjects(request.allHTTPHeaderFields, self.expectedHTTPHeaderFields); - - return YES; - }; - - NSData *HTTPResponseBody = [@"A response" dataUsingEncoding:NSUTF8StringEncoding]; - NSHTTPURLResponse *HTTPResponse = [GACURLSessionFake HTTPResponseWithCode:200]; - [self stubURLSessionDataTaskPromiseWithResponse:HTTPResponse - body:HTTPResponseBody - error:nil - URLSessionMock:self.fakeURLSession - requestValidationBlock:requestValidation]; - - // 2. Send request. - __auto_type requestPromise = [self.APIService sendRequestWithURL:URL - HTTPMethod:HTTPMethod - body:requestBody - additionalHeaders:nil]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(requestPromise.isFulfilled); - XCTAssertNil(requestPromise.error); - - XCTAssertEqualObjects(requestPromise.value.HTTPResponse, HTTPResponse); - XCTAssertEqualObjects(requestPromise.value.HTTPBody, HTTPResponseBody); - - XCTAssertTrue(self.fakeURLSession.isInvoked); -} - -#pragma mark - Token Exchange API response - -- (void)testAppCheckTokenWithAPIResponseValidResponse { - // 1. Prepare input parameters. - NSData *responseBody = - [GACFixtureLoader loadFixtureNamed:@"FACTokenExchangeResponseSuccess.json"]; - XCTAssertNotNil(responseBody); - NSHTTPURLResponse *HTTPResponse = [GACURLSessionFake HTTPResponseWithCode:200]; - _GACURLSessionDataResponse *APIResponse = - [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; - - // 2. Expected result. - NSString *expectedFACToken = @"valid_app_check_token"; - - // 3. Parse API response. - __auto_type tokenPromise = [self.APIService appCheckTokenWithAPIResponse:APIResponse]; - - // 4. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(tokenPromise.isFulfilled); - XCTAssertNil(tokenPromise.error); - - XCTAssertEqualObjects(tokenPromise.value.token, expectedFACToken); - XCTAssertTrue([GACDateTestUtils isDate:tokenPromise.value.expirationDate - approximatelyEqualCurrentPlusTimeInterval:1800 - precision:10]); -} - -- (void)testAppCheckTokenWithAPIResponseInvalidFormat { - // 1. Prepare input parameters. - NSString *responseBodyString = @"Token verification failed."; - NSData *responseBody = [responseBodyString dataUsingEncoding:NSUTF8StringEncoding]; - NSHTTPURLResponse *HTTPResponse = [GACURLSessionFake HTTPResponseWithCode:200]; - _GACURLSessionDataResponse *APIResponse = - [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; - - // 2. Parse API response. - __auto_type tokenPromise = [self.APIService appCheckTokenWithAPIResponse:APIResponse]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(tokenPromise.isRejected); - XCTAssertNil(tokenPromise.value); - - XCTAssertNotNil(tokenPromise.error); - XCTAssertEqualObjects(tokenPromise.error.domain, GACAppCheckErrorDomain); - XCTAssertEqual(tokenPromise.error.code, GACAppCheckErrorCodeUnknown); - - // Expect response body and HTTP status code to be included in the error. - NSString *failureReason = tokenPromise.error.userInfo[NSLocalizedFailureReasonErrorKey]; - XCTAssertEqualObjects(failureReason, @"JSON serialization error."); -} - -- (void)testAppCheckTokenResponseMissingFields { - [self assertMissingFieldErrorWithFixture:@"DeviceCheckResponseMissingToken.json" - missingField:@"token"]; - [self assertMissingFieldErrorWithFixture:@"DeviceCheckResponseMissingTimeToLive.json" - missingField:@"ttl"]; -} - -- (void)assertMissingFieldErrorWithFixture:(NSString *)fixtureName - missingField:(NSString *)fieldName { - // 1. Parse API response. - NSData *missingFiledBody = [GACFixtureLoader loadFixtureNamed:fixtureName]; - XCTAssertNotNil(missingFiledBody); - - NSHTTPURLResponse *HTTPResponse = [GACURLSessionFake HTTPResponseWithCode:200]; - _GACURLSessionDataResponse *APIResponse = - [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:missingFiledBody]; - - // 2. Parse API response. - __auto_type tokenPromise = [self.APIService appCheckTokenWithAPIResponse:APIResponse]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(tokenPromise.isRejected); - XCTAssertNil(tokenPromise.value); - - XCTAssertNotNil(tokenPromise.error); - XCTAssertEqualObjects(tokenPromise.error.domain, GACAppCheckErrorDomain); - XCTAssertEqual(tokenPromise.error.code, GACAppCheckErrorCodeUnknown); - - // Expect missing field name to be included in the error. - NSString *failureReason = tokenPromise.error.userInfo[NSLocalizedFailureReasonErrorKey]; - NSString *fieldNameString = [NSString stringWithFormat:@"`%@`", fieldName]; - XCTAssertTrue([failureReason containsString:fieldNameString], - @"Fixture `%@`: expected missing field %@ error not found", fixtureName, - fieldNameString); -} - -#pragma mark - Helpers - -- (void)stubURLSessionDataTaskPromiseWithResponse:(NSHTTPURLResponse *)HTTPResponse - body:(NSData *)body - error:(NSError *)error - URLSessionMock:(GACURLSessionFake *)URLSessionMock - requestValidationBlock: - (FIRRequestValidationBlock)requestValidationBlock { - URLSessionMock.requestValidationBlock = requestValidationBlock; - - // Result promise. - FBLPromise<_GACURLSessionDataResponse *> *result = [FBLPromise pendingPromise]; - if (error == nil) { - _GACURLSessionDataResponse *response = - [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:body]; - [result fulfill:response]; - } else { - [result reject:error]; - } - - URLSessionMock.resultPromise = result; -} - -@end diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckBackoffWrapperTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckBackoffWrapperTests.m deleted file mode 100644 index 2bad0e00..00000000 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckBackoffWrapperTests.m +++ /dev/null @@ -1,392 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "FBLPromise+Testing.h" -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import - -#import "AppCheckCore/Sources/Core/Errors/GACAppCheckHTTPError.h" - -@interface _GACAppCheckBackoffWrapperTests : XCTestCase - -@property(nonatomic, nullable) _GACAppCheckBackoffWrapper *backoffWrapper; - -@property(nonatomic) NSDate *currentDate; - -/// `NSObject` subclass for resolve the `self.operation` with in the case of success or `NSError` -/// for a failure. -@property(nonatomic) id operationResult; -/// Operation to apply backoff to. It configure with the helper methods during tests. -@property(nonatomic) GACAppCheckBackoffOperationProvider operationProvider; -/// Expectation to fulfill when operation is completed. It is configured with the `self.operation` -/// in setup helpers. -@property(nonatomic) XCTestExpectation *operationFinishExpectation; - -/// Test error handler that returns `self.errorHandlerResult` and fulfills -/// `self.errorHandlerExpectation`. -@property(nonatomic, copy) GACAppCheckBackoffErrorHandler errorHandler; -/// Expectation to fulfill when error handlers is executed. -@property(nonatomic) XCTestExpectation *errorHandlerExpectation; - -@end - -@implementation _GACAppCheckBackoffWrapperTests - -- (void)setUp { - [super setUp]; - - __auto_type __weak weakSelf = self; - self.backoffWrapper = [[_GACAppCheckBackoffWrapper alloc] initWithDateProvider:^NSDate *_Nonnull { - return weakSelf.currentDate ?: [NSDate date]; - }]; -} - -- (void)tearDown { - self.backoffWrapper = nil; - self.operationProvider = nil; - - [super tearDown]; -} - -- (void)testBackoffFirstOperationAlwaysExecuted { - // 1. Set up operation success. - [self setUpOperationSuccess]; - [self setUpErrorHandlerWithBackoffType:GACAppCheckBackoffTypeNone]; - self.errorHandlerExpectation.inverted = YES; - - // 2. Compose operation with backoff. - __auto_type operationWithBackoff = - [self.backoffWrapper applyBackoffToOperation:self.operationProvider - errorHandler:self.errorHandler]; - - // 3. Wait for operation to complete and check. - [self waitForExpectationsWithTimeout:5.0 handler:NULL]; - XCTAssert(FBLWaitForPromisesWithTimeout(5.0)); - - XCTAssertEqualObjects(operationWithBackoff.value, self.operationResult); -} - -- (void)testBackoff1DayBackoffAfterFailure { - // 0. Set current date. - self.currentDate = [NSDate date]; - - // 1. Check initial failure. - // 1.1. Set up operation failure. - [self setUpOperationError]; - [self setUpErrorHandlerWithBackoffType:GACAppCheckBackoffType1Day]; - - // 1.2. Compose operation with backoff. - __auto_type operationWithBackoff = - [self.backoffWrapper applyBackoffToOperation:self.operationProvider - errorHandler:self.errorHandler]; - - // 1.3. Wait for operation to complete. - [self waitForExpectationsWithTimeout:5.0 handler:NULL]; - XCTAssert(FBLWaitForPromisesWithTimeout(5.0)); - - // 1.4. Expect the promise to be rejected with the operation error. - XCTAssertEqualObjects(operationWithBackoff.error, self.operationResult); - - // 2. Check backoff in 12 hours. - // 2.1. Set up another operation. - [self setUpOperationError]; - [self setUpErrorHandlerWithBackoffType:GACAppCheckBackoffType1Day]; - - // Don't expect operation to be called. - self.operationFinishExpectation.inverted = YES; - // Don't expect error handler to be called. - self.errorHandlerExpectation.inverted = YES; - - // 2.2. Move current date. - self.currentDate = [self.currentDate dateByAddingTimeInterval:12 * 60 * 60]; - - // 2.3. Compose operation with backoff. - operationWithBackoff = [self.backoffWrapper applyBackoffToOperation:self.operationProvider - errorHandler:self.errorHandler]; - - // 2.4. Wait for operation to complete. - [self waitForExpectationsWithTimeout:5.0 handler:NULL]; - XCTAssert(FBLWaitForPromisesWithTimeout(5.0)); - - // 2.5. Expect the promise to be rejected with a backoff error. - XCTAssertTrue(operationWithBackoff.isRejected); - XCTAssertTrue([self isBackoffError:operationWithBackoff.error]); - - // 3. Check backoff one minute before allowing retry. - // 3.1. Set up another operation. - [self setUpOperationError]; - [self setUpErrorHandlerWithBackoffType:GACAppCheckBackoffType1Day]; - - // Don't expect operation to be called. - self.operationFinishExpectation.inverted = YES; - // Don't expect error handler to be called. - self.errorHandlerExpectation.inverted = YES; - - // 3.2. Move current date. - self.currentDate = [self.currentDate dateByAddingTimeInterval:11 * 60 * 60 + 59 * 60]; - - // 3.3. Compose operation with backoff. - operationWithBackoff = [self.backoffWrapper applyBackoffToOperation:self.operationProvider - errorHandler:self.errorHandler]; - - // 3.4. Wait for operation to complete. - [self waitForExpectationsWithTimeout:5.0 handler:NULL]; - XCTAssert(FBLWaitForPromisesWithTimeout(5.0)); - - // 3.5. Expect the promise to be rejected with a backoff error. - XCTAssertTrue(operationWithBackoff.isRejected); - XCTAssertTrue([self isBackoffError:operationWithBackoff.error]); - - // 4. Check backoff one minute after allowing retry. - // 4.1. Set up another operation. - [self setUpOperationError]; - [self setUpErrorHandlerWithBackoffType:GACAppCheckBackoffType1Day]; - - // 4.2. Move current date. - self.currentDate = [self.currentDate dateByAddingTimeInterval:12 * 60 * 60 + 1 * 60]; - - // 4.3. Compose operation with backoff. - operationWithBackoff = [self.backoffWrapper applyBackoffToOperation:self.operationProvider - errorHandler:self.errorHandler]; - - // 4.4. Wait for operation to complete and check failure. - [self waitForExpectationsWithTimeout:5.0 handler:NULL]; - XCTAssert(FBLWaitForPromisesWithTimeout(5.0)); - - // 4.5. Expect the promise to be rejected with the operation error. - XCTAssertEqualObjects(operationWithBackoff.error, self.operationResult); -} - -#pragma mark - Exponential backoff - -- (void)testExponentialBackoff { - // 0. Set current date. - self.currentDate = [NSDate date]; - - // 1. Check initial failure. - // 1.1. Set up operation failure. - [self setUpOperationError]; - [self setUpErrorHandlerWithBackoffType:GACAppCheckBackoffTypeExponential]; - - // 1.2. Compose operation with backoff. - __auto_type operationWithBackoff = - [self.backoffWrapper applyBackoffToOperation:self.operationProvider - errorHandler:self.errorHandler]; - - // 1.4. Wait for operation to complete. - [self waitForExpectationsWithTimeout:5.0 handler:NULL]; - XCTAssert(FBLWaitForPromisesWithTimeout(5.0)); - - // 1.5. Expect the promise to be rejected with the operation error. - XCTAssertEqualObjects(operationWithBackoff.error, self.operationResult); - - // 2. Check exponential backoff. - NSUInteger numberOfAttempts = 20; - NSTimeInterval maximumBackoff = 4 * 60 * 60; // 4 hours. - // The maximum of original backoff interval that can be added. - double maxJitterPortion = 0.5; // Backoff is up to 50% longer. - - for (NSUInteger attempt = 0; attempt < numberOfAttempts; attempt++) { - NSTimeInterval expectedMinBackoff = MIN(pow(2, attempt), maximumBackoff); - NSTimeInterval expectedMaxBackoff = - MIN(expectedMinBackoff * (1 + maxJitterPortion), maximumBackoff); - - [self assertBackoffIntervalIsAtLeast:expectedMinBackoff andAtMost:expectedMaxBackoff]; - } - - // 3. Test recovery after success. - // 3.1. Set time after max backoff. - self.currentDate = [self.currentDate dateByAddingTimeInterval:maximumBackoff]; - - // 3.2. Set up operation success. - [self setUpOperationSuccess]; - [self setUpErrorHandlerWithBackoffType:GACAppCheckBackoffTypeNone]; - self.errorHandlerExpectation.inverted = YES; - - // 3.3. Compose operation with backoff. - operationWithBackoff = [self.backoffWrapper applyBackoffToOperation:self.operationProvider - errorHandler:self.errorHandler]; - - // 3.4. Wait for operation to complete. - [self waitForExpectationsWithTimeout:5.0 handler:NULL]; - XCTAssert(FBLWaitForPromisesWithTimeout(5.0)); - - // 3.5. Expect the promise to be rejected with the operation error. - XCTAssertEqualObjects(operationWithBackoff.value, self.operationResult); - - // 3.6. Set up operation failure. - // We expect an operation to be executed with no backoff after a success. - [self setUpOperationError]; - [self setUpErrorHandlerWithBackoffType:GACAppCheckBackoffTypeExponential]; - - // 3.7. Compose operation with backoff. - operationWithBackoff = [self.backoffWrapper applyBackoffToOperation:self.operationProvider - errorHandler:self.errorHandler]; - - // 3.8. Wait for operation to complete. - [self waitForExpectationsWithTimeout:5.0 handler:NULL]; - XCTAssert(FBLWaitForPromisesWithTimeout(5.0)); - - // 3.9. Expect the promise to be rejected with the operation error. - XCTAssertEqualObjects(operationWithBackoff.error, self.operationResult); -} - -#pragma mark - Error handling - -- (void)testDefaultAppCheckProviderErrorHandler { - __auto_type errorHandler = [self.backoffWrapper defaultAppCheckProviderErrorHandler]; - - NSError *nonHTTPError = [NSError errorWithDomain:self.name code:1 userInfo:nil]; - XCTAssertEqual(errorHandler(nonHTTPError), GACAppCheckBackoffTypeNone); - - GACAppCheckHTTPError *HTTP400Error = [self httpErrorWithStatusCode:400]; - XCTAssertEqual(errorHandler(HTTP400Error), GACAppCheckBackoffType1Day); - - GACAppCheckHTTPError *HTTP403Error = [self httpErrorWithStatusCode:403]; - XCTAssertEqual(errorHandler(HTTP403Error), GACAppCheckBackoffTypeExponential); - - GACAppCheckHTTPError *HTTP404Error = [self httpErrorWithStatusCode:404]; - XCTAssertEqual(errorHandler(HTTP404Error), GACAppCheckBackoffType1Day); - - GACAppCheckHTTPError *HTTP429Error = [self httpErrorWithStatusCode:429]; - XCTAssertEqual(errorHandler(HTTP429Error), GACAppCheckBackoffTypeExponential); - - GACAppCheckHTTPError *HTTP503Error = [self httpErrorWithStatusCode:503]; - XCTAssertEqual(errorHandler(HTTP503Error), GACAppCheckBackoffTypeExponential); - - // Test all other codes from 400 to 599. - for (NSInteger statusCode = 400; statusCode < 600; statusCode++) { - if (statusCode == 400 || statusCode == 404) { - // Skip status codes with non-exponential backoff. - continue; - } - - GACAppCheckHTTPError *HTTPError = [self httpErrorWithStatusCode:statusCode]; - XCTAssertEqual(errorHandler(HTTPError), GACAppCheckBackoffTypeExponential); - } -} - -#pragma mark - Helpers - -- (void)setUpErrorHandlerWithBackoffType:(GACAppCheckBackoffType)backoffType { - __auto_type __weak weakSelf = self; - self.errorHandlerExpectation = [self expectationWithDescription:@"Error handler"]; - self.errorHandler = ^GACAppCheckBackoffType(NSError *_Nonnull error) { - [weakSelf.errorHandlerExpectation fulfill]; - return backoffType; - }; -} - -- (void)setUpOperationSuccess { - self.operationFinishExpectation = [self expectationWithDescription:@"Operation performed"]; - self.operationResult = [[NSObject alloc] init]; - __auto_type __weak weakSelf = self; - self.operationProvider = ^FBLPromise *() { - return [FBLPromise do:^id(void) { - [weakSelf.operationFinishExpectation fulfill]; - return weakSelf.operationResult; - }]; - }; -} - -- (void)setUpOperationError { - self.operationFinishExpectation = [self expectationWithDescription:@"Operation performed"]; - self.operationResult = [NSError errorWithDomain:self.name code:-1 userInfo:nil]; - __auto_type __weak weakSelf = self; - self.operationProvider = ^FBLPromise *() { - return [FBLPromise do:^id(void) { - [weakSelf.operationFinishExpectation fulfill]; - return weakSelf.operationResult; - }]; - }; -} - -- (BOOL)isBackoffError:(NSError *)error { - return [error.localizedDescription containsString:@"Too many attempts. Underlying error:"]; -} - -- (GACAppCheckHTTPError *)httpErrorWithStatusCode:(NSInteger)statusCode { - NSHTTPURLResponse *httpResponse = - [[NSHTTPURLResponse alloc] initWithURL:[NSURL URLWithString:@"https://localhost"] - statusCode:statusCode - HTTPVersion:nil - headerFields:nil]; - GACAppCheckHTTPError *error = [[GACAppCheckHTTPError alloc] initWithHTTPResponse:httpResponse - data:nil]; - return error; -} - -// Asserts that the backoff interval is within the provided range. -// Assumes that `self.currentDate` contains the last failure date. -// Sets `self.currentDate` to the date when the most recent retry happened. -- (void)assertBackoffIntervalIsAtLeast:(NSTimeInterval)minBackoff - andAtMost:(NSTimeInterval)maxBackoff { - NSDate *lastFailureDate = self.currentDate; - - // 1. Test backoff before min interval. - // 1.1 Move the date 0.5 sec before the minimum backoff date. - self.currentDate = [lastFailureDate dateByAddingTimeInterval:minBackoff - 0.5]; - - // 1.2 Set up operation failure. - [self setUpOperationError]; - [self setUpErrorHandlerWithBackoffType:GACAppCheckBackoffTypeExponential]; - - // 1.3 Don't expect operation to be executed. - self.operationFinishExpectation.inverted = YES; - self.errorHandlerExpectation.inverted = YES; - - // 1.4 Compose operation with backoff. - __auto_type operationWithBackoff = - [self.backoffWrapper applyBackoffToOperation:self.operationProvider - errorHandler:self.errorHandler]; - - // 1.5 Wait for operation to complete. - [self waitForExpectationsWithTimeout:5.0 handler:NULL]; - XCTAssert(FBLWaitForPromisesWithTimeout(5.0)); - - // 1.6 Expect the promise to be rejected with a backoff error. - XCTAssertTrue(operationWithBackoff.isRejected); - XCTAssertTrue([self isBackoffError:operationWithBackoff.error]); - - // 2. Test backoff after max interval. - // 2.1 Move the date 0.5 sec before the minimum backoff date. - self.currentDate = [lastFailureDate dateByAddingTimeInterval:maxBackoff + 0.5]; - - // 2.2. Set up operation failure and expect it to be completed. - [self setUpOperationError]; - [self setUpErrorHandlerWithBackoffType:GACAppCheckBackoffTypeExponential]; - - // 2.3 Compose operation with backoff. - operationWithBackoff = [self.backoffWrapper applyBackoffToOperation:self.operationProvider - errorHandler:self.errorHandler]; - - // 2.4 Wait for operation to complete. - [self waitForExpectationsWithTimeout:5.0 handler:NULL]; - XCTAssert(FBLWaitForPromisesWithTimeout(5.0)); - - // 2.5 Expect the promise to be rejected with a backoff error. - XCTAssertTrue(operationWithBackoff.isRejected); - XCTAssertFalse([self isBackoffError:operationWithBackoff.error]); -} - -@end diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckCryptoUtilsTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckCryptoUtilsTests.m deleted file mode 100644 index a7d1760f..00000000 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckCryptoUtilsTests.m +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Core/Utils/GACAppCheckCryptoUtils.h" - -@interface GACAppCheckCryptoUtilsTests : XCTestCase - -@end - -@implementation GACAppCheckCryptoUtilsTests - -- (void)testSHA256HashFromData { - NSData *dataToHash = [@"some data to hash" dataUsingEncoding:NSUTF8StringEncoding]; - - NSData *hashData = [GACAppCheckCryptoUtils sha256HashFromData:dataToHash]; - - // Convert to a base64 encoded string to compare. - NSString *base64EncodedHashString = [hashData base64EncodedStringWithOptions:0]; - - // Base64 encoded hash of UTF8 encoded string "some data to hash". - NSString *expectedHashString = @"ai2iCUOTHpg0/BLP5btHu9muQ0iaMHJpYrV29OOZPlA="; - - XCTAssertEqualObjects(base64EncodedHashString, expectedHashString); -} - -@end diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckLoggerTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckLoggerTests.m deleted file mode 100644 index 7937a941..00000000 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckLoggerTests.m +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckLogger.h" - -@interface GACAppCheckLoggerTests : XCTestCase -@end - -@implementation GACAppCheckLoggerTests - -- (void)testDefaultLogLevel { - GACAppCheckLogLevel defaultLogLevel = GACAppCheckLogger.logLevel; - - XCTAssertEqual(defaultLogLevel, GACAppCheckLogLevelWarning); -} - -- (void)testSetLogLevel { - GACAppCheckLogLevel expectedLogLevel = GACAppCheckLogLevelDebug; - - GACAppCheckLogger.logLevel = expectedLogLevel; - - XCTAssertEqual(GACAppCheckLogger.logLevel, expectedLogLevel); -} - -@end diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckStorageTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckStorageTests.m deleted file mode 100644 index 05ab4e7c..00000000 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckStorageTests.m +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright 2020 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 - -// Tests that use the Keychain require a host app and Swift Package Manager -// does not support adding a host app to test targets. -#if !SWIFT_PACKAGE - -// Skip keychain tests on Catalyst and macOS. Tests are skipped because they -// involve interactions with the keychain that require a provisioning profile. -// See go/firebase-macos-keychain-popups for more details. -#if !TARGET_OS_MACCATALYST && !TARGET_OS_OSX - -#import - -#import "AppCheckCore/Tests/Unit/Utils/GACKeychainStorageFake.h" - -#import "FBLPromise+Testing.h" - -#import "AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.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"; - -@interface GACAppCheckStorageTests : XCTestCase -@property(nonatomic) NSString *tokenKey; -@property(nonatomic) GACAppCheckStorage *storage; -@end - -@implementation GACAppCheckStorageTests - -- (void)setUp { - [super setUp]; - - self.tokenKey = [self tokenKeyWithGoogleAppID:kGoogleAppID]; - self.storage = [[GACAppCheckStorage alloc] initWithTokenKey:self.tokenKey accessGroup:nil]; -} - -- (void)tearDown { - self.storage = nil; - [super tearDown]; -} - -- (void)testSetAndGetToken { - GACAppCheckToken *tokenToStore = [[GACAppCheckToken alloc] initWithToken:@"token" - expirationDate:[NSDate distantPast] - receivedAtDate:[NSDate date]]; - - FBLPromise *setPromise = [self.storage setToken:tokenToStore]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(setPromise.value, tokenToStore); - XCTAssertNil(setPromise.error); - - __auto_type getPromise = [self.storage getToken]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(getPromise.value.token, tokenToStore.token); - XCTAssertEqualObjects(getPromise.value.expirationDate, tokenToStore.expirationDate); - XCTAssertEqualObjects(getPromise.value.receivedAtDate, tokenToStore.receivedAtDate); - XCTAssertNil(getPromise.error); -} - -- (void)testRemoveToken { - FBLPromise *setPromise = [self.storage setToken:nil]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(setPromise.value, nil); - XCTAssertNil(setPromise.error); - - __auto_type getPromise = [self.storage getToken]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertNil(getPromise.value); - XCTAssertNil(getPromise.error); -} - -- (void)testGetToken_KeychainError { - // 1. Set up storage mock. - GACKeychainStorageFake *fakeKeychainStorage = [[GACKeychainStorageFake alloc] init]; - GACAppCheckStorage *storage = [[GACAppCheckStorage alloc] initWithTokenKey:self.tokenKey - keychainStorage:fakeKeychainStorage - accessGroup:nil]; - // 2. Create and expect keychain error. - NSError *gulsKeychainError = [NSError errorWithDomain:@"com.guls.keychain" code:-1 userInfo:nil]; - fakeKeychainStorage.keychainError = gulsKeychainError; - - // 3. Get token and verify results. - __auto_type getPromise = [storage getToken]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertNotNil(getPromise.error); - XCTAssertEqualObjects(getPromise.error, - [_GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); -} - -- (void)testSetToken_KeychainError { - // 1. Set up storage mock. - GACKeychainStorageFake *fakeKeychainStorage = [[GACKeychainStorageFake alloc] init]; - GACAppCheckStorage *storage = [[GACAppCheckStorage alloc] initWithTokenKey:self.tokenKey - keychainStorage:fakeKeychainStorage - accessGroup:nil]; - - // 2. Create and expect keychain error. - NSError *gulsKeychainError = [NSError errorWithDomain:@"com.guls.keychain" code:-1 userInfo:nil]; - fakeKeychainStorage.keychainError = gulsKeychainError; - - // 3. Set token and verify results. - GACAppCheckToken *tokenToStore = [[GACAppCheckToken alloc] initWithToken:@"token" - expirationDate:[NSDate distantPast] - receivedAtDate:[NSDate date]]; - __auto_type getPromise = [storage setToken:tokenToStore]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertNotNil(getPromise.error); - XCTAssertEqualObjects(getPromise.error, - [_GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); -} - -- (void)testRemoveToken_KeychainError { - // 1. Set up storage mock. - GACKeychainStorageFake *fakeKeychainStorage = [[GACKeychainStorageFake alloc] init]; - GACAppCheckStorage *storage = [[GACAppCheckStorage alloc] initWithTokenKey:self.tokenKey - keychainStorage:fakeKeychainStorage - accessGroup:nil]; - // 2. Create and expect keychain error. - NSError *gulsKeychainError = [NSError errorWithDomain:@"com.guls.keychain" code:-1 userInfo:nil]; - fakeKeychainStorage.keychainError = gulsKeychainError; - - // 3. Remove token and verify results. - __auto_type getPromise = [storage setToken:nil]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertNotNil(getPromise.error); - XCTAssertEqualObjects(getPromise.error, - [_GACAppCheckErrorUtil keychainErrorWithError:gulsKeychainError]); -} - -- (void)testSetTokenPerApp { - // 1. Set token with a storage. - GACAppCheckToken *tokenToStore = [[GACAppCheckToken alloc] initWithToken:@"token" - expirationDate:[NSDate distantPast] - receivedAtDate:[NSDate date]]; - - FBLPromise *setPromise = [self.storage setToken:tokenToStore]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertEqualObjects(setPromise.value, tokenToStore); - XCTAssertNil(setPromise.error); - - // 2. Try to read the token with another storage. - NSString *tokenKey = - [self tokenKeyWithGoogleAppID:@"1:200000000000:ios:aaaaaaaaaaaaaaaaaaaaaaaa"]; - GACAppCheckStorage *storage2 = [[GACAppCheckStorage alloc] initWithTokenKey:tokenKey - accessGroup:nil]; - __auto_type getPromise = [storage2 getToken]; - XCTAssert(FBLWaitForPromisesWithTimeout(0.5)); - XCTAssertNil(getPromise.value); - XCTAssertNil(getPromise.error); -} - -#pragma mark - Private Helpers - -- (NSString *)tokenKeyWithGoogleAppID:(NSString *)googleAppID { - return [NSString stringWithFormat:@"app_check_token.%@.%@", kAppName, googleAppID]; -} - -@end - -#endif // !TARGET_OS_MACCATALYST && !TARGET_OS_OSX - -#endif // !SWIFT_PACKAGE diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckStoredTokenTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckStoredTokenTests.m deleted file mode 100644 index 35288fa1..00000000 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckStoredTokenTests.m +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken+GACAppCheckToken.h" -#import "AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" - -@interface GACAppCheckStoredTokenTests : XCTestCase - -@end - -@implementation GACAppCheckStoredTokenTests - -- (void)testSecureCoding { - GACAppCheckStoredToken *tokenToArchive = [[GACAppCheckStoredToken alloc] init]; - tokenToArchive.token = @"some_token"; - tokenToArchive.expirationDate = [NSDate date]; - tokenToArchive.receivedAtDate = [tokenToArchive.expirationDate dateByAddingTimeInterval:-10]; - - NSError *error; - NSData *archivedToken = [NSKeyedArchiver archivedDataWithRootObject:tokenToArchive - requiringSecureCoding:YES - error:&error]; - XCTAssertNotNil(archivedToken); - XCTAssertNil(error); - - GACAppCheckStoredToken *unarchivedToken = - [NSKeyedUnarchiver unarchivedObjectOfClass:[GACAppCheckStoredToken class] - fromData:archivedToken - error:&error]; - XCTAssertNotNil(unarchivedToken); - XCTAssertNil(error); - XCTAssertEqualObjects(unarchivedToken.token, tokenToArchive.token); - XCTAssertEqualObjects(unarchivedToken.expirationDate, tokenToArchive.expirationDate); - XCTAssertEqualObjects(unarchivedToken.receivedAtDate, tokenToArchive.receivedAtDate); - XCTAssertEqual(unarchivedToken.storageVersion, tokenToArchive.storageVersion); -} - -- (void)testConvertingToAndFromGACAppCheckToken { - GACAppCheckToken *originalToken = [[GACAppCheckToken alloc] initWithToken:@"___" - expirationDate:[NSDate date] - receivedAtDate:[NSDate date]]; - - GACAppCheckStoredToken *storedToken = [[GACAppCheckStoredToken alloc] init]; - [storedToken updateWithToken:originalToken]; - XCTAssertEqualObjects(originalToken.token, storedToken.token); - XCTAssertEqualObjects(originalToken.expirationDate, storedToken.expirationDate); - XCTAssertEqualObjects(originalToken.receivedAtDate, storedToken.receivedAtDate); - - GACAppCheckToken *recoveredToken = [storedToken appCheckToken]; - XCTAssertEqualObjects(recoveredToken.token, storedToken.token); - XCTAssertEqualObjects(recoveredToken.expirationDate, storedToken.expirationDate); - XCTAssertEqualObjects(recoveredToken.receivedAtDate, storedToken.receivedAtDate); -} - -@end diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckTests.m deleted file mode 100644 index e6a92971..00000000 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckTests.m +++ /dev/null @@ -1,541 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Tests/Unit/Utils/GACAppCheckProviderFake.h" -#import "AppCheckCore/Tests/Unit/Utils/GACAppCheckSettingsFake.h" -#import "AppCheckCore/Tests/Unit/Utils/GACAppCheckStorageFake.h" -#import "AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenDelegateFake.h" -#import "AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenRefresherFake.h" - -#import "FBLPromise+Testing.h" - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheck.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckProvider.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckSettings.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 -static NSString *const kPlaceholderTokenValue = @"eyJlcnJvciI6IlVOS05PV05fRVJST1IifQ=="; - -static NSString *const kResourceName = @"projects/test_project_id/apps/test_app_id"; -static NSString *const kAppName = @"GACAppCheckTests"; -static NSString *const kAppGroupID = @"app_group_id"; - -@interface GACAppCheck (Tests) - -- (instancetype)initWithServiceName:(NSString *)instanceName - appCheckProvider:(id)appCheckProvider - storage:(id)storage - tokenRefresher:(id)tokenRefresher - settings:(id)settings - tokenDelegate:(nullable id)tokenDelegate; - -@end - -@interface GACAppCheckTests : XCTestCase - -@property(nonatomic) GACAppCheckStorageFake *fakeStorage; -@property(nonatomic) GACAppCheckProviderFake *fakeAppCheckProvider; -@property(nonatomic) GACAppCheckTokenRefresherFake *fakeTokenRefresher; -@property(nonatomic) GACAppCheckSettingsFake *fakeSettings; -@property(nonatomic) GACAppCheckTokenDelegateFake *fakeTokenDelegate; -@property(nonatomic) GACAppCheck *appCheck; - -@end - -@implementation GACAppCheckTests - -- (void)setUp { - [super setUp]; - - self.fakeStorage = [[GACAppCheckStorageFake alloc] init]; - self.fakeAppCheckProvider = [[GACAppCheckProviderFake alloc] init]; - self.fakeTokenRefresher = [[GACAppCheckTokenRefresherFake alloc] init]; - self.fakeSettings = [[GACAppCheckSettingsFake alloc] init]; - self.fakeTokenDelegate = [[GACAppCheckTokenDelegateFake alloc] init]; - - self.appCheck = [[GACAppCheck alloc] initWithServiceName:kAppName - appCheckProvider:self.fakeAppCheckProvider - storage:self.fakeStorage - tokenRefresher:self.fakeTokenRefresher - settings:self.fakeSettings - tokenDelegate:self.fakeTokenDelegate]; -} - -- (void)tearDown { - self.appCheck = nil; - self.fakeAppCheckProvider = nil; - self.fakeStorage = nil; - self.fakeTokenRefresher = nil; - self.fakeSettings = nil; - self.fakeTokenDelegate = nil; - - [super tearDown]; -} - -#pragma mark - Public Init - -#pragma mark - Public Get Token - -- (void)testGetToken_WhenNoCache_Success { - // 1. Create expected token and configure expectations. - GACAppCheckToken *expectedToken = [self validToken]; - - XCTestExpectation *expectation = - [self configuredExpectations_GetTokenWhenNoCache_withExpectedToken:expectedToken]; - - // 2. Request token and verify result. - [self.appCheck tokenForcingRefresh:NO - completion:^(GACAppCheckTokenResult *result) { - [expectation fulfill]; - XCTAssertEqualObjects(result.token, expectedToken); - XCTAssertNil(result.error); - }]; - - // 3. Wait for expectations and validate mocks. - [self waitForExpectations:@[ expectation ] timeout:0.5]; - - XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); - XCTAssertEqualObjects(self.fakeStorage.lastSetToken, expectedToken); - XCTAssertEqual(self.fakeTokenRefresher.updateWithRefreshResultCallCount, 1); - XCTAssertEqualObjects(self.fakeTokenDelegate.lastToken, expectedToken); -} - -- (void)testGetToken_WhenCachedTokenIsValid_Success { - [self assertGetToken_WhenCachedTokenIsValid_Success]; -} - -- (void)testGetTokenForcingRefresh_WhenCachedTokenIsValid_Success { - // 1. Create expected token and configure expectations. - GACAppCheckToken *expectedToken = [self validToken]; - XCTestExpectation *expectation = - [self configuredExpectations_GetTokenForcingRefreshWhenCacheIsValid_withExpectedToken: - expectedToken]; - - // 2. Request token and verify result. - [self.appCheck tokenForcingRefresh:YES - completion:^(GACAppCheckTokenResult *result) { - [expectation fulfill]; - XCTAssertEqualObjects(result.token, expectedToken); - XCTAssertNil(result.error); - }]; - - // 3. Wait for expectations and validate mocks. - [self waitForExpectations:@[ expectation ] timeout:0.5]; - - XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); - XCTAssertEqualObjects(self.fakeStorage.lastSetToken, expectedToken); - XCTAssertEqual(self.fakeTokenRefresher.updateWithRefreshResultCallCount, 1); - XCTAssertEqualObjects(self.fakeTokenDelegate.lastToken, expectedToken); -} - -- (void)testGetToken_WhenCachedTokenExpired_Success { - // 1. Create expected token and configure expectations. - GACAppCheckToken *expectedToken = [self validToken]; - - XCTestExpectation *expectation = - [self configuredExpectations_GetTokenWhenCachedTokenExpired_withExpectedToken:expectedToken]; - - // 2. Request token and verify result. - [self.appCheck tokenForcingRefresh:NO - completion:^(GACAppCheckTokenResult *result) { - [expectation fulfill]; - XCTAssertEqualObjects(result.token, expectedToken); - XCTAssertNil(result.error); - }]; - - // 3. Wait for expectations and validate mocks. - [self waitForExpectations:@[ expectation ] timeout:0.5]; - - XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); - XCTAssertEqualObjects(self.fakeStorage.lastSetToken, expectedToken); - XCTAssertEqual(self.fakeTokenRefresher.updateWithRefreshResultCallCount, 1); - XCTAssertEqualObjects(self.fakeTokenDelegate.lastToken, expectedToken); -} - -- (void)testGetToken_AppCheckProviderError { - // 1. Create expected tokens and errors and configure expectations. - GACAppCheckToken *cachedToken = [self soonExpiringToken]; - NSError *providerError = [NSError errorWithDomain:@"GACAppCheckTests" code:-1 userInfo:nil]; - - XCTestExpectation *expectation = - [self configuredExpectations_GetTokenWhenError_withError:providerError andToken:cachedToken]; - - // 2. Request token and verify result. - [self.appCheck tokenForcingRefresh:NO - completion:^(GACAppCheckTokenResult *result) { - [expectation fulfill]; - XCTAssertEqualObjects(result.token.token, kPlaceholderTokenValue); - XCTAssertNotNil(result.error); - XCTAssertEqualObjects(result.error, providerError); - // App Check Core does not wrap errors in public domain. - XCTAssertNotEqualObjects(result.error.domain, GACAppCheckErrorDomain); - }]; - - // 3. Wait for expectations and validate mocks. - [self waitForExpectations:@[ expectation ] timeout:0.5]; - - XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); - XCTAssertEqual(self.fakeTokenDelegate.tokenDidUpdateCallCount, 0); - XCTAssertNil(self.fakeStorage.lastSetToken); - XCTAssertEqual(self.fakeTokenRefresher.updateWithRefreshResultCallCount, 0); -} - -#pragma mark - Token refresher - -- (void)testTokenRefreshTriggeredAndRefreshSuccess { - // 1. Expect token to be requested from storage. - self.fakeStorage.getTokenPromise = [FBLPromise resolvedWith:nil]; - - // 2. Expect token requested from app check provider. - NSDate *expirationDate = [NSDate dateWithTimeIntervalSinceNow:10000]; - GACAppCheckToken *tokenToReturn = [[GACAppCheckToken alloc] initWithToken:@"valid" - expirationDate:expirationDate]; - self.fakeAppCheckProvider.tokenToReturn = tokenToReturn; - - // 3. Expect new token to be stored. - self.fakeStorage.setTokenPromise = [FBLPromise resolvedWith:tokenToReturn]; - - // 4. Trigger refresh and expect the result. - if (self.fakeTokenRefresher.tokenRefreshHandler == nil) { - XCTFail(@"`tokenRefreshHandler` must be not `nil`."); - return; - } - - XCTestExpectation *completionExpectation = [self expectationWithDescription:@"completion"]; - self.fakeTokenRefresher.tokenRefreshHandler(^(GACAppCheckTokenRefreshResult *refreshResult) { - [completionExpectation fulfill]; - XCTAssertEqualObjects(refreshResult.tokenExpirationDate, expirationDate); - XCTAssertEqual(refreshResult.status, GACAppCheckTokenRefreshStatusSuccess); - }); - - [self waitForExpectations:@[ completionExpectation ] timeout:0.5]; - - XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); - XCTAssertEqualObjects(self.fakeStorage.lastSetToken, tokenToReturn); - XCTAssertEqual(self.fakeTokenRefresher.updateWithRefreshResultCallCount, 1); - XCTAssertEqual(self.fakeTokenDelegate.tokenDidUpdateCallCount, 1); - XCTAssertEqualObjects(self.fakeTokenDelegate.lastToken, tokenToReturn); -} - -- (void)testTokenRefreshTriggeredAndRefreshError { - // 1. Expect token to be requested from storage. - self.fakeStorage.getTokenPromise = [FBLPromise resolvedWith:nil]; - - // 2. Expect token requested from app check provider. - NSError *providerError = [self internalError]; - self.fakeAppCheckProvider.errorToReturn = providerError; - - // 5. Trigger refresh and expect the result. - if (self.fakeTokenRefresher.tokenRefreshHandler == nil) { - XCTFail(@"`tokenRefreshHandler` must be not `nil`."); - return; - } - - XCTestExpectation *completionExpectation = [self expectationWithDescription:@"completion"]; - self.fakeTokenRefresher.tokenRefreshHandler(^(GACAppCheckTokenRefreshResult *refreshResult) { - [completionExpectation fulfill]; - XCTAssertEqual(refreshResult.status, GACAppCheckTokenRefreshStatusFailure); - XCTAssertNil(refreshResult.tokenExpirationDate); - XCTAssertNil(refreshResult.tokenReceivedAtDate); - }); - - [self waitForExpectations:@[ completionExpectation ] timeout:0.5]; - - XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); - XCTAssertEqual(self.fakeTokenDelegate.tokenDidUpdateCallCount, 0); - XCTAssertNil(self.fakeStorage.lastSetToken); - XCTAssertEqual(self.fakeTokenRefresher.updateWithRefreshResultCallCount, 0); -} - -- (void)testLimitedUseTokenWithSuccess { - // 1. Expect token requested from app check provider. - GACAppCheckToken *expectedToken = [self validToken]; - self.fakeAppCheckProvider.limitedUseTokenToReturn = expectedToken; - - // 5. Expect token request to be completed. - XCTestExpectation *getTokenExpectation = [self expectationWithDescription:@"getToken"]; - - [self.appCheck limitedUseTokenWithCompletion:^(GACAppCheckTokenResult *result) { - [getTokenExpectation fulfill]; - XCTAssertEqualObjects(result.token, expectedToken); - XCTAssertNil(result.error); - }]; - [self waitForExpectations:@[ getTokenExpectation ] timeout:0.5]; - - XCTAssertEqual(self.fakeAppCheckProvider.getLimitedUseTokenCallCount, 1); - XCTAssertEqualObjects(self.fakeStorage.lastSetToken, nil); - XCTAssertEqual(self.fakeTokenDelegate.tokenDidUpdateCallCount, 0); -} - -- (void)testLimitedUseToken_WhenTokenGenerationErrors { - // 2. Expect error when requesting token from app check provider. - NSError *providerError = [_GACAppCheckErrorUtil keychainErrorWithError:[self internalError]]; - self.fakeAppCheckProvider.limitedUseErrorToReturn = providerError; - - // 5. Expect token request to be completed. - XCTestExpectation *getTokenExpectation = [self expectationWithDescription:@"getToken"]; - - [self.appCheck limitedUseTokenWithCompletion:^(GACAppCheckTokenResult *result) { - [getTokenExpectation fulfill]; - XCTAssertEqualObjects(result.token.token, kPlaceholderTokenValue); - XCTAssertNotNil(result.error); - XCTAssertEqualObjects(result.error, providerError); - XCTAssertEqualObjects(result.error.domain, GACAppCheckErrorDomain); - }]; - - [self waitForExpectations:@[ getTokenExpectation ] timeout:0.5]; - - XCTAssertEqual(self.fakeAppCheckProvider.getLimitedUseTokenCallCount, 1); - XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 0); - XCTAssertNil(self.fakeStorage.lastSetToken); - XCTAssertEqual(self.fakeTokenDelegate.tokenDidUpdateCallCount, 0); - XCTAssertEqual(self.fakeTokenRefresher.updateWithRefreshResultCallCount, 0); -} - -#pragma mark - Merging multiple get token requests - -- (void)testGetToken_WhenCalledSeveralTimesSuccess_ThenThereIsOnlyOneOperation { - // 1. Expect a token to be requested and stored. - NSArray * /*[expectedToken, storeTokenPromise]*/ expectedTokenAndPromise = - [self expectTokenRequestFromAppCheckProvider]; - GACAppCheckToken *expectedToken = expectedTokenAndPromise.firstObject; - FBLPromise *storeTokenPromise = expectedTokenAndPromise.lastObject; - - // 3. Request token several times. - NSInteger getTokenCallsCount = 10; - NSMutableArray *getTokenCompletionExpectations = - [NSMutableArray arrayWithCapacity:getTokenCallsCount]; - - for (NSInteger i = 0; i < getTokenCallsCount; i++) { - // 3.1. Expect a completion to be called for each method call. - XCTestExpectation *getTokenExpectation = - [self expectationWithDescription:[NSString stringWithFormat:@"getToken%@", @(i)]]; - [getTokenCompletionExpectations addObject:getTokenExpectation]; - - // 3.2. Request token and verify result. - [self.appCheck tokenForcingRefresh:NO - completion:^(GACAppCheckTokenResult *result) { - [getTokenExpectation fulfill]; - XCTAssertEqualObjects(result.token, expectedToken); - XCTAssertNil(result.error); - }]; - } - - // 3.3. Fulfill the pending promise to finish the get token operation. - [storeTokenPromise fulfill:expectedToken]; - - // 4. Wait for expectations and validate mocks. - [self waitForExpectations:getTokenCompletionExpectations timeout:0.5]; - - XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); - XCTAssertEqual(self.fakeTokenRefresher.updateWithRefreshResultCallCount, 1); - XCTAssertEqual(self.fakeTokenDelegate.tokenDidUpdateCallCount, 1); - - // 5. Check a get token call after. - [self assertGetToken_WhenCachedTokenIsValid_Success]; -} - -- (void)testGetToken_WhenCalledSeveralTimesError_ThenThereIsOnlyOneOperation { - // 1. Expect a token to be requested and stored. - NSArray * /*[expectedToken, storeTokenPromise]*/ expectedTokenAndPromise = - [self expectTokenRequestFromAppCheckProvider]; - FBLPromise *storeTokenPromise = expectedTokenAndPromise.lastObject; - - // 1.1. Create an expected error to be reject the store token promise with later. - NSError *storageError = [NSError errorWithDomain:self.name code:0 userInfo:nil]; - - // 3. Request token several times. - NSInteger getTokenCallsCount = 10; - NSMutableArray *getTokenCompletionExpectations = - [NSMutableArray arrayWithCapacity:getTokenCallsCount]; - - for (NSInteger i = 0; i < getTokenCallsCount; i++) { - // 3.1. Expect a completion to be called for each method call. - XCTestExpectation *getTokenExpectation = - [self expectationWithDescription:[NSString stringWithFormat:@"getToken%@", @(i)]]; - [getTokenCompletionExpectations addObject:getTokenExpectation]; - - // 3.2. Request token and verify result. - [self.appCheck tokenForcingRefresh:NO - completion:^(GACAppCheckTokenResult *result) { - [getTokenExpectation fulfill]; - XCTAssertEqualObjects(result.token.token, kPlaceholderTokenValue); - XCTAssertNotNil(result.error); - XCTAssertNotNil(result.error); - XCTAssertEqualObjects(result.error, storageError); - }]; - } - - // 3.3. Reject the pending promise to finish the get token operation. - [storeTokenPromise reject:storageError]; - - // 4. Wait for expectations and validate mocks. - [self waitForExpectations:getTokenCompletionExpectations timeout:0.5]; - - // After the first token generation fails and caches the result, the call count will be 1 - XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); - XCTAssertEqual(self.fakeTokenDelegate.tokenDidUpdateCallCount, 0); // No updates on error - XCTAssertEqualObjects(self.fakeStorage.lastSetToken, expectedTokenAndPromise.firstObject); - XCTAssertEqual(self.fakeTokenRefresher.updateWithRefreshResultCallCount, 0); - - // 5. Check a get token call after. - [self assertGetToken_WhenCachedTokenIsValid_Success]; -} - -#pragma mark - Helpers - -- (NSError *)internalError { - return [NSError errorWithDomain:@"com.internal.error" code:-1 userInfo:nil]; -} - -- (GACAppCheckToken *)validToken { - return [[GACAppCheckToken alloc] initWithToken:[NSUUID UUID].UUIDString - expirationDate:[NSDate distantFuture]]; -} - -- (GACAppCheckToken *)soonExpiringToken { - NSDate *soonExpiringTokenDate = [NSDate dateWithTimeIntervalSinceNow:4.5 * 60]; - return [[GACAppCheckToken alloc] initWithToken:@"valid" expirationDate:soonExpiringTokenDate]; -} - -- (void)assertGetToken_WhenCachedTokenIsValid_Success { - NSInteger initialCallCount = self.fakeAppCheckProvider.getTokenCallCount; - - // 1. Create expected token and configure expectations. - GACAppCheckToken *cachedToken = [self validToken]; - - XCTestExpectation *expectation = - [self configuredExpectation_GetTokenWhenCacheTokenIsValid_withExpectedToken:cachedToken]; - - // 2. Request token and verify result. - [self.appCheck tokenForcingRefresh:NO - completion:^(GACAppCheckTokenResult *result) { - [expectation fulfill]; - XCTAssertEqualObjects(result.token, cachedToken); - XCTAssertNil(result.error); - }]; - - // 3. Wait for expectations and validate mocks. - [self waitForExpectations:@[ expectation ] timeout:0.5]; - - XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, initialCallCount); -} - -- (XCTestExpectation *)configuredExpectations_GetTokenWhenNoCache_withExpectedToken: - (GACAppCheckToken *)expectedToken { - // 1. Expect token to be requested from storage. - self.fakeStorage.getTokenPromise = [FBLPromise resolvedWith:nil]; - - // 2. Expect token requested from app check provider. - self.fakeAppCheckProvider.tokenToReturn = expectedToken; - - // 3. Expect new token to be stored. - self.fakeStorage.setTokenPromise = [FBLPromise resolvedWith:expectedToken]; - - // 5. Expect token request to be completed. - XCTestExpectation *getTokenExpectation = [self expectationWithDescription:@"getToken"]; - - return getTokenExpectation; -} - -- (XCTestExpectation *)configuredExpectation_GetTokenWhenCacheTokenIsValid_withExpectedToken: - (GACAppCheckToken *)expectedToken { - // 1. Expect token to be requested from storage. - self.fakeStorage.getTokenPromise = [FBLPromise resolvedWith:expectedToken]; - - // 4. Expect token request to be completed. - return [self expectationWithDescription:@"getToken"]; -} - -- (XCTestExpectation *) - configuredExpectations_GetTokenForcingRefreshWhenCacheIsValid_withExpectedToken: - (GACAppCheckToken *)expectedToken { - // 2. Expect token requested from app check provider. - self.fakeAppCheckProvider.tokenToReturn = expectedToken; - - // 3. Expect new token to be stored. - self.fakeStorage.setTokenPromise = [FBLPromise resolvedWith:expectedToken]; - - // 5. Expect token request to be completed. - XCTestExpectation *getTokenExpectation = [self expectationWithDescription:@"getToken"]; - - return getTokenExpectation; -} - -- (XCTestExpectation *)configuredExpectations_GetTokenWhenCachedTokenExpired_withExpectedToken: - (GACAppCheckToken *)expectedToken { - // 1. Expect token to be requested from storage. - GACAppCheckToken *cachedToken = [[GACAppCheckToken alloc] initWithToken:@"expired" - expirationDate:[NSDate date]]; - self.fakeStorage.getTokenPromise = [FBLPromise resolvedWith:cachedToken]; - - // 2. Expect token requested from app check provider. - self.fakeAppCheckProvider.tokenToReturn = expectedToken; - - // 3. Expect new token to be stored. - self.fakeStorage.setTokenPromise = [FBLPromise resolvedWith:expectedToken]; - - // 5. Expect token request to be completed. - XCTestExpectation *getTokenExpectation = [self expectationWithDescription:@"getToken"]; - - return getTokenExpectation; -} - -- (XCTestExpectation *) - configuredExpectations_GetTokenWhenError_withError:(NSError *_Nonnull)error - andToken:(GACAppCheckToken *_Nullable)token { - // 1. Expect token to be requested from storage. - self.fakeStorage.getTokenPromise = [FBLPromise resolvedWith:token]; - - // 2. Expect token requested from app check provider. - self.fakeAppCheckProvider.errorToReturn = error; - - // 5. Expect token request to be completed. - XCTestExpectation *getTokenExpectation = [self expectationWithDescription:@"getToken"]; - - return getTokenExpectation; -} - -- (NSArray *)expectTokenRequestFromAppCheckProvider { - // 1. Expect token to be requested from storage. - self.fakeStorage.getTokenPromise = [FBLPromise resolvedWith:nil]; - - // 2. Expect token requested from app check provider. - GACAppCheckToken *expectedToken = [self validToken]; - self.fakeAppCheckProvider.tokenToReturn = expectedToken; - - // 3. Expect new token to be stored. - // 3.1. Create a pending promise to resolve later. - FBLPromise *storeTokenPromise = [FBLPromise pendingPromise]; - // 3.2. Stub storage set token method. - self.fakeStorage.setTokenPromise = storeTokenPromise; - - return @[ expectedToken, storeTokenPromise ]; -} - -@end diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckTimerTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckTimerTests.m deleted file mode 100644 index 8673ca26..00000000 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckTimerTests.m +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTimer.h" - -@interface GACAppCheckTimerTests : XCTestCase - -@end - -@implementation GACAppCheckTimerTests - -- (void)testTimerProvider { - dispatch_queue_t queue = - dispatch_queue_create("GACAppCheckTimerTests.testInit", DISPATCH_QUEUE_SERIAL); - NSTimeInterval fireTimerIn = 1; - NSDate *startTime = [NSDate date]; - NSDate *fireDate = [NSDate dateWithTimeIntervalSinceNow:fireTimerIn]; - - GACTimerProvider timerProvider = [GACAppCheckTimer timerProvider]; - - XCTestExpectation *timerExpectation = [self expectationWithDescription:@"timer"]; - GACAppCheckTimer *timer = timerProvider(fireDate, queue, ^{ - NSTimeInterval actuallyFiredIn = [[NSDate date] timeIntervalSinceDate:startTime]; - // Check that fired at proper time (allowing some timer drift). - XCTAssertLessThan(ABS(actuallyFiredIn - fireTimerIn), 0.5); - - [timerExpectation fulfill]; - }); - - XCTAssertNotNil(timer); - - [self waitForExpectations:@[ timerExpectation ] timeout:fireTimerIn + 1]; -} - -- (void)testInit { - dispatch_queue_t queue = - dispatch_queue_create("GACAppCheckTimerTests.testInit", DISPATCH_QUEUE_SERIAL); - NSTimeInterval fireTimerIn = 2; - NSDate *startTime = [NSDate date]; - NSDate *fireDate = [NSDate dateWithTimeIntervalSinceNow:fireTimerIn]; - - XCTestExpectation *timerExpectation = [self expectationWithDescription:@"timer"]; - GACAppCheckTimer *timer = [[GACAppCheckTimer alloc] - initWithFireDate:fireDate - dispatchQueue:queue - block:^{ - NSTimeInterval actuallyFiredIn = [[NSDate date] timeIntervalSinceDate:startTime]; - // Check that fired at proper time (allowing some timer drift). - XCTAssertLessThan(ABS(actuallyFiredIn - fireTimerIn), 0.5); - - [timerExpectation fulfill]; - }]; - - XCTAssertNotNil(timer); - - [self waitForExpectations:@[ timerExpectation ] timeout:fireTimerIn + 1]; -} - -@end diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckTokenRefresherTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckTokenRefresherTests.m deleted file mode 100644 index 33320e45..00000000 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckTokenRefresherTests.m +++ /dev/null @@ -1,482 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckSettings.h" - -#import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefreshResult.h" -#import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefresher.h" -#import "AppCheckCore/Tests/Unit/Utils/GACFakeTimer.h" -#import "AppCheckCore/Tests/Utils/Date/GACDateTestUtils.h" - -@interface GACAppCheckTokenRefresherTests : XCTestCase - -@property(nonatomic) GACFakeTimer *fakeTimer; - -@property(nonatomic) GACAppCheckSettings *settings; - -@property(nonatomic) GACAppCheckTokenRefreshResult *initialTokenRefreshResult; - -@end - -@implementation GACAppCheckTokenRefresherTests - -- (void)setUp { - self.settings = [[GACAppCheckSettings alloc] init]; - self.fakeTimer = [[GACFakeTimer alloc] init]; - - NSDate *receivedAtDate = [NSDate date]; - self.initialTokenRefreshResult = [[GACAppCheckTokenRefreshResult alloc] - initWithStatusSuccessAndExpirationDate:[receivedAtDate dateByAddingTimeInterval:1000] - receivedAtDate:receivedAtDate]; -} - -- (void)tearDown { - self.fakeTimer = nil; - self.settings = nil; -} - -#pragma mark - Auto refresh is allowed - -- (void)testInitialRefreshWhenAutoRefreshAllowed { - __auto_type weakSelf = self; - - self.initialTokenRefreshResult = [[GACAppCheckTokenRefreshResult alloc] initWithStatusNever]; - GACAppCheckTokenRefresher *refresher = [self createRefresher]; - - // 1. Expect checking if auto-refresh allowed before scheduling the initial refresh. - self.settings.isTokenAutoRefreshEnabled = YES; - - // 2. Don't expect the timer to be scheduled for the first refresh as the refresh should be - // triggered straight away. - XCTestExpectation *initialTimerCreatedExpectation = - [self expectationWithDescription:@"initial refresh timer created"]; - initialTimerCreatedExpectation.inverted = YES; - self.fakeTimer.createHandler = ^(NSDate *_Nonnull fireDate) { - weakSelf.fakeTimer.createHandler = nil; - [initialTimerCreatedExpectation fulfill]; - }; - - // 3. Expect checking if auto-refresh allowed before triggering the initial refresh. - self.settings.isTokenAutoRefreshEnabled = YES; - - // 4. Expect initial refresh handler to be called. - __block GACAppCheckTokenRefreshCompletion initialRefreshCompletion; - XCTestExpectation *initialRefreshExpectation = - [self expectationWithDescription:@"initial refresh"]; - refresher.tokenRefreshHandler = ^(GACAppCheckTokenRefreshCompletion _Nonnull completion) { - // Save completion to be called later. - initialRefreshCompletion = completion; - - [initialRefreshExpectation fulfill]; - }; - - NSDate *initialTokenExpirationDate = [NSDate dateWithTimeIntervalSinceNow:60 * 60]; - NSDate *initialTokenReceivedDate = [NSDate date]; - __auto_type initialRefreshResult = [[GACAppCheckTokenRefreshResult alloc] - initWithStatusSuccessAndExpirationDate:initialTokenExpirationDate - receivedAtDate:initialTokenReceivedDate]; - - [self waitForExpectations:@[ initialTimerCreatedExpectation, initialRefreshExpectation ] - timeout:1]; - - // 5. Expect checking if auto-refresh allowed before scheduling next refresh. - self.settings.isTokenAutoRefreshEnabled = YES; - - // 6. Expect a next refresh timer to be scheduled on initial refresh completion. - NSDate *expectedRefreshDate = - [self expectedRefreshDateWithReceivedDate:initialTokenReceivedDate - expirationDate:initialTokenExpirationDate]; - XCTestExpectation *nextTimerCreateExpectation = - [self expectationWithDescription:@"next refresh create timer"]; - self.fakeTimer.createHandler = ^(NSDate *_Nonnull fireDate) { - weakSelf.fakeTimer.createHandler = nil; - XCTAssertEqualObjects(fireDate, expectedRefreshDate); - [nextTimerCreateExpectation fulfill]; - }; - - // 7. Call initial refresh completion and wait for next refresh timer to be scheduled. - initialRefreshCompletion(initialRefreshResult); - [self waitForExpectations:@[ nextTimerCreateExpectation ] timeout:0.5]; - - // 8. Expect checking if auto-refresh allowed before triggering the next refresh. - self.settings.isTokenAutoRefreshEnabled = YES; - - // 9. Expect refresh handler to be called for the next refresh. - __auto_type nextRefreshResult = [[GACAppCheckTokenRefreshResult alloc] - initWithStatusSuccessAndExpirationDate:[expectedRefreshDate dateByAddingTimeInterval:60 * 60] - receivedAtDate:expectedRefreshDate]; - XCTestExpectation *nextRefreshExpectation = [self expectationWithDescription:@"next refresh"]; - refresher.tokenRefreshHandler = ^(GACAppCheckTokenRefreshCompletion _Nonnull completion) { - [nextRefreshExpectation fulfill]; - - // Call completion. - completion(nextRefreshResult); - }; - - // 10. Fire the timer. - [self fireTimer]; - - // 11. Wait for the next refresh handler to be called. - [self waitForExpectations:@[ nextRefreshExpectation ] timeout:1]; -} - -- (void)testNoTimeScheduledUntilHandlerSet { - // 1. Don't expect timer to be scheduled. - XCTestExpectation *timerCreateExpectation1 = [self expectationWithDescription:@"create timer 1"]; - timerCreateExpectation1.inverted = YES; - self.fakeTimer.createHandler = ^(NSDate *_Nonnull fireDate) { - [timerCreateExpectation1 fulfill]; - }; - - // 2. Create a publisher. - GACAppCheckTokenRefresher *refresher = [self createRefresher]; - - XCTAssertNotNil(refresher); - - [self waitForExpectations:@[ timerCreateExpectation1 ] timeout:0.5]; - - // 3. Expect timer to be created after the handler has been set. - // 3.1. Expect checking if auto-refresh allowed one more time when timer fires. - self.settings.isTokenAutoRefreshEnabled = YES; - - // 3.2. Expect timer to fire. - XCTestExpectation *timerCreateExpectation2 = [self expectationWithDescription:@"create timer 2"]; - self.fakeTimer.createHandler = ^(NSDate *_Nonnull fireDate) { - [timerCreateExpectation2 fulfill]; - }; - - // 3.3. Set handler. - refresher.tokenRefreshHandler = ^(GACAppCheckTokenRefreshCompletion _Nonnull completion) { - }; - - [self waitForExpectations:@[ timerCreateExpectation2 ] timeout:0.5]; -} - -- (void)testNextRefreshOnRefreshSuccess { - GACAppCheckTokenRefresher *refresher = [self createRefresher]; - - NSDate *refreshedTokenExpirationDate = - [self.initialTokenRefreshResult.tokenExpirationDate dateByAddingTimeInterval:60 * 60]; - __auto_type refreshResult = [[GACAppCheckTokenRefreshResult alloc] - initWithStatusSuccessAndExpirationDate:refreshedTokenExpirationDate - receivedAtDate:self.initialTokenRefreshResult.tokenExpirationDate]; - - // 1. Expect checking if auto-refresh allowed before scheduling initial refresh. - self.settings.isTokenAutoRefreshEnabled = YES; - - // 2. Expect checking if auto-refresh allowed before calling the refresh handler. - self.settings.isTokenAutoRefreshEnabled = YES; - - // 3. Expect refresh handler. - XCTestExpectation *initialRefreshExpectation = - [self expectationWithDescription:@"initial refresh"]; - refresher.tokenRefreshHandler = ^(GACAppCheckTokenRefreshCompletion _Nonnull completion) { - [initialRefreshExpectation fulfill]; - - // Call completion in a while. - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), - dispatch_get_main_queue(), ^{ - completion(refreshResult); - }); - }; - - // 4. Expect for new timer to be created. - NSDate *expectedFireDate = - [self expectedRefreshDateWithReceivedDate:refreshResult.tokenReceivedAtDate - expirationDate:refreshResult.tokenExpirationDate]; - XCTestExpectation *createTimerExpectation = [self expectationWithDescription:@"create timer"]; - self.fakeTimer.createHandler = ^(NSDate *_Nonnull fireDate) { - [createTimerExpectation fulfill]; - XCTAssertEqualObjects(fireDate, expectedFireDate); - }; - - // 5. Expect checking if auto-refresh allowed before refreshing. - self.settings.isTokenAutoRefreshEnabled = YES; - - // 6. Fire initial timer and wait for expectations. - [self fireTimer]; - - [self waitForExpectations:@[ initialRefreshExpectation, createTimerExpectation ] - timeout:1 - enforceOrder:YES]; -} - -- (void)testBackoff { - GACAppCheckTokenRefresher *refresher = [self createRefresher]; - - // Initial backoff interval. - NSTimeInterval expectedBackoffTime = 0; - NSTimeInterval maximumBackoffTime = 16 * 60; // 16 min. - - // 1. Expect checking if auto-refresh allowed before scheduling initial refresh. - self.settings.isTokenAutoRefreshEnabled = YES; - - for (NSInteger i = 0; i < 10; i++) { - // 2. Expect checking if auto-refresh allowed before calling the refresh handler. - self.settings.isTokenAutoRefreshEnabled = YES; - - // 3. Expect refresh handler. - XCTestExpectation *initialRefreshExpectation = - [self expectationWithDescription:@"initial refresh"]; - refresher.tokenRefreshHandler = ^(GACAppCheckTokenRefreshCompletion _Nonnull completion) { - [initialRefreshExpectation fulfill]; - - // Call completion in a while. - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), - dispatch_get_main_queue(), ^{ - __auto_type refreshFailure = - [[GACAppCheckTokenRefreshResult alloc] initWithStatusFailure]; - completion(refreshFailure); - }); - }; - - // 4. Expect for new timer to be created. - // No backoff initially, 1st backoff 30sec, double backoff on each next attempt until 16min. - expectedBackoffTime = expectedBackoffTime == 0 ? 30 : expectedBackoffTime * 2; - expectedBackoffTime = MIN(expectedBackoffTime, maximumBackoffTime); - NSDate *expectedFireDate = [[NSDate date] dateByAddingTimeInterval:expectedBackoffTime]; - - XCTestExpectation *createTimerExpectation = [self expectationWithDescription:@"create timer"]; - self.fakeTimer.createHandler = ^(NSDate *_Nonnull fireDate) { - [createTimerExpectation fulfill]; - - // Check expected and actual fire date are not too different (account for the random part - // and request attempt delay). - XCTAssertLessThan(ABS([expectedFireDate timeIntervalSinceDate:fireDate]), 2); - }; - - // 5. Expect checking if auto-refresh allowed before refreshing. - self.settings.isTokenAutoRefreshEnabled = YES; - - // 6. Fire initial timer and wait for expectations. - [self fireTimer]; - - [self waitForExpectations:@[ initialRefreshExpectation, createTimerExpectation ] - timeout:1 - enforceOrder:YES]; - } -} - -#pragma mark - Auto refresh is not allowed - -- (void)testNoInitialRefreshWhenAutoRefreshIsNotAllowed { - GACAppCheckTokenRefresher *refresher = [self createRefresher]; - - // 1. Expect checking if auto-refresh allowed before scheduling initial refresh. - self.settings.isTokenAutoRefreshEnabled = NO; - - // 2. Don't expect timer to be scheduled. - XCTestExpectation *timerCreateExpectation = [self expectationWithDescription:@"create timer"]; - timerCreateExpectation.inverted = YES; - - __auto_type weakSelf = self; - self.fakeTimer.createHandler = ^(NSDate *_Nonnull fireDate) { - weakSelf.fakeTimer.createHandler = nil; - [timerCreateExpectation fulfill]; - }; - - // 3. Don't expect refresh handler to be called. - __auto_type refreshResult = [[GACAppCheckTokenRefreshResult alloc] - initWithStatusSuccessAndExpirationDate:[NSDate dateWithTimeIntervalSinceNow:60 * 60] - receivedAtDate:[NSDate date]]; - XCTestExpectation *refreshExpectation = [self expectationWithDescription:@"refresh"]; - refreshExpectation.inverted = YES; - - refresher.tokenRefreshHandler = ^(GACAppCheckTokenRefreshCompletion _Nonnull completion) { - [refreshExpectation fulfill]; - - // Call completion. - completion(refreshResult); - }; - - // 4. Check if the handler is not fired before the timer. - [self waitForExpectations:@[ timerCreateExpectation, refreshExpectation ] timeout:1]; -} - -- (void)testNoRefreshWhenAutoRefreshWasDisabledAfterInit { - GACAppCheckTokenRefresher *refresher = [self createRefresher]; - - // 1. Expect checking if auto-refresh allowed before scheduling initial refresh. - self.settings.isTokenAutoRefreshEnabled = YES; - - // 2. Expect timer to be scheduled. - NSDate *expectedTimerFireDate = - [self expectedRefreshDateWithReceivedDate:self.initialTokenRefreshResult.tokenReceivedAtDate - expirationDate:self.initialTokenRefreshResult.tokenExpirationDate]; - XCTestExpectation *timerCreateExpectation = [self expectationWithDescription:@"create timer"]; - - __auto_type weakSelf = self; - self.fakeTimer.createHandler = ^(NSDate *_Nonnull fireDate) { - weakSelf.fakeTimer.createHandler = nil; - XCTAssertEqualObjects(fireDate, expectedTimerFireDate); - [timerCreateExpectation fulfill]; - }; - - // 3. Expect refresh handler to be called. - __auto_type refreshResult = [[GACAppCheckTokenRefreshResult alloc] - initWithStatusSuccessAndExpirationDate:[expectedTimerFireDate - dateByAddingTimeInterval:60 * 60] - receivedAtDate:expectedTimerFireDate]; - XCTestExpectation *noRefreshExpectation = [self expectationWithDescription:@"initial refresh"]; - noRefreshExpectation.inverted = YES; - refresher.tokenRefreshHandler = ^(GACAppCheckTokenRefreshCompletion _Nonnull completion) { - [noRefreshExpectation fulfill]; - - // Call completion. - completion(refreshResult); - }; - - // 4. Check if the handler is not fired before the timer. - [self waitForExpectations:@[ timerCreateExpectation ] timeout:1]; - - // 5. Expect checking if auto-refresh allowed before refreshing. - self.settings.isTokenAutoRefreshEnabled = NO; - - // 6. Fire the timer and wait for completion. - [self fireTimer]; - - [self waitForExpectations:@[ noRefreshExpectation ] timeout:1]; -} - -#pragma mark - Update token expiration - -- (void)testUpdateWithRefreshResultWhenAutoRefreshIsAllowed { - GACAppCheckTokenRefresher *refresher = [self createRefresher]; - - NSDate *newExpirationDate = - [self.initialTokenRefreshResult.tokenExpirationDate dateByAddingTimeInterval:10 * 60]; - __auto_type newRefreshResult = [[GACAppCheckTokenRefreshResult alloc] - initWithStatusSuccessAndExpirationDate:newExpirationDate - receivedAtDate:self.initialTokenRefreshResult.tokenExpirationDate]; - - // 1. Expect checking if auto-refresh allowed before scheduling refresh. - self.settings.isTokenAutoRefreshEnabled = YES; - - // 2. Expect timer to be scheduled. - NSDate *expectedTimerFireDate = - [self expectedRefreshDateWithReceivedDate:newRefreshResult.tokenReceivedAtDate - expirationDate:newRefreshResult.tokenExpirationDate]; - XCTestExpectation *timerCreateExpectation = [self expectationWithDescription:@"create timer"]; - - __auto_type weakSelf = self; - self.fakeTimer.createHandler = ^(NSDate *_Nonnull fireDate) { - weakSelf.fakeTimer.createHandler = nil; - XCTAssertEqualObjects(fireDate, expectedTimerFireDate); - [timerCreateExpectation fulfill]; - }; - - // 3. Update token expiration date. - [refresher updateWithRefreshResult:newRefreshResult]; - - // 4. Wait for timer to be created. - [self waitForExpectations:@[ timerCreateExpectation ] timeout:1]; -} - -- (void)testUpdateWithRefreshResultWhenAutoRefreshIsNotAllowed { - GACAppCheckTokenRefresher *refresher = [self createRefresher]; - - __auto_type newRefreshResult = [[GACAppCheckTokenRefreshResult alloc] - initWithStatusSuccessAndExpirationDate:[NSDate dateWithTimeIntervalSinceNow:60 * 60] - receivedAtDate:self.initialTokenRefreshResult.tokenExpirationDate]; - - // 1. Expect checking if auto-refresh allowed before scheduling initial refresh. - self.settings.isTokenAutoRefreshEnabled = NO; - - // 2. Don't expect timer to be scheduled. - XCTestExpectation *timerCreateExpectation = [self expectationWithDescription:@"create timer"]; - timerCreateExpectation.inverted = YES; - - __auto_type weakSelf = self; - self.fakeTimer.createHandler = ^(NSDate *_Nonnull fireDate) { - weakSelf.fakeTimer.createHandler = nil; - [timerCreateExpectation fulfill]; - }; - - // 3. Update token expiration date. - [refresher updateWithRefreshResult:newRefreshResult]; - - // 4. Wait for timer to be created. - [self waitForExpectations:@[ timerCreateExpectation ] timeout:1]; -} - -- (void)testUpdateWithRefreshResult_WhenTokenExpiresLessThanIn1Minute { - GACAppCheckTokenRefresher *refresher = [self createRefresher]; - - NSDate *newExpirationDate = [NSDate dateWithTimeIntervalSinceNow:0.5 * 60]; - __auto_type newRefreshResult = [[GACAppCheckTokenRefreshResult alloc] - initWithStatusSuccessAndExpirationDate:newExpirationDate - receivedAtDate:[NSDate date]]; - - // 1. Expect checking if auto-refresh allowed before scheduling refresh. - self.settings.isTokenAutoRefreshEnabled = YES; - - // 2. Expect timer to be scheduled in at least 1 minute. - XCTestExpectation *timerCreateExpectation = [self expectationWithDescription:@"create timer"]; - - __auto_type weakSelf = self; - self.fakeTimer.createHandler = ^(NSDate *_Nonnull fireDate) { - weakSelf.fakeTimer.createHandler = nil; - - // 1 minute is the minimal interval between successful refreshes. - XCTAssert([GACDateTestUtils isDate:fireDate - approximatelyEqualCurrentPlusTimeInterval:60 - precision:1]); - [timerCreateExpectation fulfill]; - }; - - // 3. Update token expiration date. - [refresher updateWithRefreshResult:newRefreshResult]; - - // 4. Wait for timer to be created. - [self waitForExpectations:@[ timerCreateExpectation ] timeout:1]; -} - -#pragma mark - Helpers - -- (void)fireTimer { - if (self.fakeTimer.handler) { - self.fakeTimer.handler(); - } else { - XCTFail(@"handler must not be nil!"); - } -} - -- (GACAppCheckTokenRefresher *)createRefresher { - return [[GACAppCheckTokenRefresher alloc] initWithRefreshResult:self.initialTokenRefreshResult - timerProvider:[self.fakeTimer fakeTimerProvider] - settings:self.settings]; -} - -- (NSDate *)expectedRefreshDateWithReceivedDate:(NSDate *)receivedDate - expirationDate:(NSDate *)expirationDate { - NSTimeInterval timeToLive = [expirationDate timeIntervalSinceDate:receivedDate]; - XCTAssertGreaterThanOrEqual(timeToLive, 0); - - NSTimeInterval timeToRefresh = timeToLive / 2 + 5 * 60; // 50% of TTL + 5 min - - NSTimeInterval minimalAutoRefreshInterval = 60; // 1 min - timeToRefresh = MAX(timeToRefresh, minimalAutoRefreshInterval); - - NSDate *refreshDate = [receivedDate dateByAddingTimeInterval:timeToRefresh]; - - NSDate *now = [NSDate date]; - - return [refreshDate laterDate:now]; -} - -@end diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckTokenResultTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckTokenResultTests.m deleted file mode 100644 index 5567688d..00000000 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckTokenResultTests.m +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2023 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckTokenResult.h" - -static NSString *const kTestTokenValue = @"test-token"; -/// Placeholder value that indicates failure: `{"error":"UNKNOWN_ERROR"}` encoded as base64 -static NSString *const kPlaceholderTokenValue = @"eyJlcnJvciI6IlVOS05PV05fRVJST1IifQ=="; -static NSString *const kTestErrorDomain = @"TestErrorDomain"; -static NSInteger const kTestErrorCode = 42; - -@interface GACAppCheckTokenResult (Tests) - -+ (GACAppCheckToken *)placeholderToken; - -@end - -@interface GACAppCheckTokenResultTests : XCTestCase -@end - -@implementation GACAppCheckTokenResultTests - -- (void)testInitWithToken { - NSDate *expectedExpirationDate = [NSDate dateWithTimeIntervalSince1970:1693314000.0]; - NSDate *expectedReceivedAtDate = [NSDate dateWithTimeIntervalSince1970:1693317600.0]; - GACAppCheckToken *expectedToken = [[GACAppCheckToken alloc] initWithToken:kTestTokenValue - expirationDate:expectedExpirationDate - receivedAtDate:expectedReceivedAtDate]; - - GACAppCheckTokenResult *tokenResult = - [[GACAppCheckTokenResult alloc] initWithToken:expectedToken]; - - XCTAssertEqualObjects(tokenResult.token, expectedToken); - XCTAssertNil(tokenResult.error); -} - -- (void)testInitWithError { - NSError *expectedError = [NSError errorWithDomain:kTestErrorDomain - code:kTestErrorCode - userInfo:nil]; - - GACAppCheckTokenResult *tokenResult = - [[GACAppCheckTokenResult alloc] initWithError:expectedError]; - - XCTAssertEqualObjects(tokenResult.token.token, kPlaceholderTokenValue); - XCTAssertNotNil(tokenResult.error); - XCTAssertEqualObjects(tokenResult.error, expectedError); -} - -- (void)testInitWithTokenAndError { - GACAppCheckToken *placeholderToken = [GACAppCheckTokenResult placeholderToken]; - NSError *expectedError = [NSError errorWithDomain:kTestErrorDomain - code:kTestErrorCode - userInfo:nil]; - - GACAppCheckTokenResult *tokenResult = - [[GACAppCheckTokenResult alloc] initWithToken:placeholderToken error:expectedError]; - - XCTAssertEqualObjects(tokenResult.token, placeholderToken); - XCTAssertNotNil(tokenResult.error); - XCTAssertEqualObjects(tokenResult.error, expectedError); -} - -- (void)testPlaceholderToken { - NSDate *expectedExpirationDate = [NSDate distantPast]; - NSDate *expectedReceivedAtDate = [NSDate date]; // Current time - - GACAppCheckToken *placeholderToken = [GACAppCheckTokenResult placeholderToken]; - - XCTAssertEqualObjects(placeholderToken.token, kPlaceholderTokenValue); - // Verify that the placeholder token's received at time is approximately equal to current time. - XCTAssertEqualWithAccuracy( - [placeholderToken.receivedAtDate timeIntervalSinceDate:expectedReceivedAtDate], 0, 5.0); - XCTAssertEqualObjects(placeholderToken.expirationDate, expectedExpirationDate); -} - -@end diff --git a/AppCheckCore/Tests/Unit/DebugProvider/AppCheckCoreDebugProviderAPIServiceTests.swift b/AppCheckCore/Tests/Unit/DebugProvider/AppCheckCoreDebugProviderAPIServiceTests.swift new file mode 100644 index 00000000..fd5bf0ba --- /dev/null +++ b/AppCheckCore/Tests/Unit/DebugProvider/AppCheckCoreDebugProviderAPIServiceTests.swift @@ -0,0 +1,197 @@ +// 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 +#if canImport(Promises) + import Promises +#endif +@testable import AppCheckCore + +private class MockAppCheckAPIService: NSObject, AppCheckCoreAPIServiceProtocol { + var baseURL: String = "https://test.appcheck.url.com/alpha" + + var passedRequestURL: URL? + var passedHTTPMethod: String? + var passedBody: Data? + var passedAdditionalHeaders: [String: String]? + + var sendRequestResult: Result? + var appCheckTokenResult: Result? + + var passedAPIResponse: AppCheckCoreURLSessionDataResponse? + + func sendRequest(withURL requestURL: URL, httpMethod: String, body: Data?, + additionalHeaders: [String: String]?) async throws + -> AppCheckCoreURLSessionDataResponse { + passedRequestURL = requestURL + passedHTTPMethod = httpMethod + passedBody = body + passedAdditionalHeaders = additionalHeaders + if let result = sendRequestResult { + switch result { + case let .success(response): return response + case let .failure(error): throw error + } + } + throw NSError(domain: "MockAppCheckAPIService", code: -1, userInfo: nil) + } + + func appCheckToken(withAPIResponse response: AppCheckCoreURLSessionDataResponse) async throws + -> AppCheckCoreToken { + passedAPIResponse = response + if let result = appCheckTokenResult { + switch result { + case let .success(token): return token + case let .failure(error): throw error + } + } + throw NSError(domain: "MockAppCheckAPIService", code: -1, userInfo: nil) + } +} + +class AppCheckCoreDebugProviderAPIServiceTests: XCTestCase { + var debugAPIService: AppCheckCoreDebugProviderAPIService! + private var mockAPIService: MockAppCheckAPIService! + + let kResourceName = "projects/test_project_id/apps/test_app_id" + + override func setUp() { + super.setUp() + mockAPIService = MockAppCheckAPIService() + debugAPIService = AppCheckCoreDebugProviderAPIService( + apiService: mockAPIService, + resourceName: kResourceName + ) + } + + override func tearDown() { + debugAPIService = nil + mockAPIService = nil + super.tearDown() + } + + func testAppCheckTokenSuccess() async throws { + try await testAppCheckTokenSuccess(withLimitedUse: false) + } + + func testAppCheckTokenSuccessWithLimitedUse() async throws { + try await testAppCheckTokenSuccess(withLimitedUse: true) + } + + func testAppCheckTokenSuccess(withLimitedUse limitedUse: Bool) async throws { + let debugToken = UUID().uuidString + let expectedResult = AppCheckCoreToken(token: "app_check_token", expirationDate: Date()) + + let expectedRequestURL = + "\(mockAPIService.baseURL)/projects/test_project_id/apps/test_app_id:exchangeDebugToken" + let fakeResponseData = "fake response".data(using: .utf8)! + let httpResponse = HTTPURLResponse( + url: URL(string: expectedRequestURL)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let apiResponse = AppCheckCoreURLSessionDataResponse( + response: httpResponse, + httpBody: fakeResponseData + ) + + mockAPIService.sendRequestResult = .success(apiResponse) + mockAPIService.appCheckTokenResult = .success(expectedResult) + + let token = try await debugAPIService.appCheckToken( + debugToken: debugToken, + limitedUse: limitedUse + ) + + XCTAssertEqual(token.token, expectedResult.token) + XCTAssertEqual(token.expirationDate, expectedResult.expirationDate) + + XCTAssertEqual(mockAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(mockAPIService.passedHTTPMethod, "POST") + XCTAssertEqual(mockAPIService.passedAdditionalHeaders?["Content-Type"], "application/json") + try assertHTTPBody(mockAPIService.passedBody, debugToken: debugToken, limitedUse: limitedUse) + XCTAssertEqual(mockAPIService.passedAPIResponse, apiResponse) + } + + func testAppCheckTokenResponseParsingError() async throws { + let debugToken = UUID().uuidString + let parsingError = NSError( + domain: "testAppCheckTokenResponseParsingError", + code: -1, + userInfo: nil + ) + + let expectedRequestURL = + "\(mockAPIService.baseURL)/projects/test_project_id/apps/test_app_id:exchangeDebugToken" + let fakeResponseData = "fake response".data(using: .utf8)! + let httpResponse = HTTPURLResponse( + url: URL(string: expectedRequestURL)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let apiResponse = AppCheckCoreURLSessionDataResponse( + response: httpResponse, + httpBody: fakeResponseData + ) + + mockAPIService.sendRequestResult = .success(apiResponse) + mockAPIService.appCheckTokenResult = .failure(parsingError) + + do { + _ = try await debugAPIService.appCheckToken(debugToken: debugToken, limitedUse: false) + XCTFail("Expected error to be thrown") + } catch let error as NSError { + XCTAssertEqual(error, parsingError) + } + + XCTAssertEqual(mockAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(mockAPIService.passedHTTPMethod, "POST") + XCTAssertEqual(mockAPIService.passedAdditionalHeaders?["Content-Type"], "application/json") + try assertHTTPBody(mockAPIService.passedBody, debugToken: debugToken, limitedUse: false) + XCTAssertEqual(mockAPIService.passedAPIResponse, apiResponse) + } + + func testAppCheckTokenNetworkError() async throws { + let debugToken = UUID().uuidString + let networkError = NSError(domain: "testAppCheckTokenNetworkError", code: -1, userInfo: nil) + + mockAPIService.sendRequestResult = .failure(networkError) + + do { + _ = try await debugAPIService.appCheckToken(debugToken: debugToken, limitedUse: false) + XCTFail("Expected error to be thrown") + } catch let error as NSError { + XCTAssertEqual(error, networkError) + } + + try assertHTTPBody(mockAPIService.passedBody, debugToken: debugToken, limitedUse: false) + } + + // MARK: - Helpers + + func assertHTTPBody(_ body: Data?, debugToken: String, limitedUse: Bool) throws { + let unwrappedBody = try XCTUnwrap(body) + let decodedData = try JSONSerialization + .jsonObject(with: unwrappedBody, options: []) as? [String: Any] + let unwrappedDecodedData = try XCTUnwrap(decodedData) + + let decodeDebugToken = unwrappedDecodedData["debug_token"] as? String + XCTAssertEqual(decodeDebugToken, debugToken) + + let decodedLimitedUse = unwrappedDecodedData["limited_use"] as? Bool + XCTAssertEqual(decodedLimitedUse, limitedUse) + } +} diff --git a/AppCheckCore/Tests/Unit/DebugProvider/AppCheckCoreDebugProviderTests.swift b/AppCheckCore/Tests/Unit/DebugProvider/AppCheckCoreDebugProviderTests.swift new file mode 100644 index 00000000..329e5dc7 --- /dev/null +++ b/AppCheckCore/Tests/Unit/DebugProvider/AppCheckCoreDebugProviderTests.swift @@ -0,0 +1,406 @@ +// 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 +#if canImport(GoogleUtilities) + import GoogleUtilities +#endif +@testable import AppCheckCore + +class MockAppCheckDebugProviderAPIService: NSObject, AppCheckCoreDebugProviderAPIServiceProtocol { + var passedDebugToken: String? + var passedLimitedUse: Bool? + + var tokenResult: Result? + var limitedUseTokenResult: Result? + + func appCheckToken(debugToken: String, limitedUse: Bool) async throws -> AppCheckCoreToken { + passedDebugToken = debugToken + passedLimitedUse = limitedUse + + let result = limitedUse ? limitedUseTokenResult : tokenResult + + guard let result = result else { + throw NSError(domain: "MockError", code: -1, userInfo: nil) + } + + switch result { + case let .success(token): + return token + case let .failure(error): + throw error + } + } +} + +class AppCheckCoreDebugProviderTests: XCTestCase { + let kDebugTokenEnvKey = "AppCheckDebugToken" + let kFirebaseDebugTokenEnvKey = "FIRAAppCheckDebugToken" + let kDebugTokenUserDefaultsKey = "AppCheckCoreDebugToken" + let kDebugTokenRegisteredUserDefaultsKey = "AppCheckCoreDebugTokenRegistered" + + var provider: AppCheckCoreDebugProvider! + var fakeAPIService: MockAppCheckDebugProviderAPIService! + + override func setUp() { + super.setUp() + fakeAPIService = MockAppCheckDebugProviderAPIService() + provider = AppCheckCoreDebugProvider(apiService: fakeAPIService, + serviceName: "test-service", + resourceName: "projects/test-project/apps/test-app", + environment: [:]) + } + + override func tearDown() { + provider = nil + UserDefaults.standard.removeObject(forKey: kDebugTokenUserDefaultsKey) + UserDefaults.standard.removeObject(forKey: kDebugTokenRegisteredUserDefaultsKey) + super.tearDown() + } + + // MARK: - Debug token generating/storing + + func testCurrentTokenWhenEnvironmentVariableSetAndTokenStored() { + UserDefaults.standard.set("stored token", forKey: kDebugTokenUserDefaultsKey) + let envToken = "env token" + provider = AppCheckCoreDebugProvider(apiService: fakeAPIService, + serviceName: "test-service", + resourceName: "projects/test-project/apps/test-app", + environment: [kDebugTokenEnvKey: envToken]) + + XCTAssertEqual(provider.currentDebugToken(), envToken) + } + + func testCurrentTokenWhenFirebaseAndCoreEnvironmentVariablesSetAndTokenStored() { + UserDefaults.standard.set("stored token", forKey: kDebugTokenUserDefaultsKey) + let envToken = "env token" + provider = AppCheckCoreDebugProvider(apiService: fakeAPIService, + serviceName: "test-service", + resourceName: "projects/test-project/apps/test-app", + environment: [ + kDebugTokenEnvKey: envToken, + kFirebaseDebugTokenEnvKey: "firebase env token", + ]) + + XCTAssertEqual(provider.currentDebugToken(), envToken) + } + + func testCurrentTokenWhenFirebaseEnvironmentVariableSetAndTokenStored() { + UserDefaults.standard.set("stored token", forKey: kDebugTokenUserDefaultsKey) + let envToken = "env token" + provider = AppCheckCoreDebugProvider(apiService: fakeAPIService, + serviceName: "test-service", + resourceName: "projects/test-project/apps/test-app", + environment: [kFirebaseDebugTokenEnvKey: envToken]) + + XCTAssertEqual(provider.currentDebugToken(), envToken) + } + + func testCurrentTokenWhenFirebaseAndCoreEnvironmentVariablesSet() { + let envToken = "env token" + provider = AppCheckCoreDebugProvider(apiService: fakeAPIService, + serviceName: "test-service", + resourceName: "projects/test-project/apps/test-app", + environment: [ + kDebugTokenEnvKey: envToken, + kFirebaseDebugTokenEnvKey: "firebase env token", + ]) + + XCTAssertEqual(provider.currentDebugToken(), envToken) + } + + func testCurrentTokenWhenNoEnvironmentVariableAndTokenStored() { + let storedToken = "stored token" + UserDefaults.standard.set(storedToken, forKey: kDebugTokenUserDefaultsKey) + + XCTAssertEqual(provider.currentDebugToken(), storedToken) + XCTAssertEqual(provider.currentDebugToken(), storedToken) + } + + func testCurrentTokenWhenNoEnvironmentVariableAndNoTokenStored() { + UserDefaults.standard.removeObject(forKey: kDebugTokenUserDefaultsKey) + XCTAssertNil(UserDefaults.standard.string(forKey: kDebugTokenUserDefaultsKey)) + + let generatedToken = provider.currentDebugToken() + XCTAssertNotNil(generatedToken) + + // Check if the generated token is stored to the user defaults. + XCTAssertEqual(UserDefaults.standard.string(forKey: kDebugTokenUserDefaultsKey), generatedToken) + + // Check if the same token is used once generated. + XCTAssertEqual(provider.currentDebugToken(), generatedToken) + } + + // MARK: - Debug token to FAC token exchange + + func testGetTokenSuccess() async throws { + // 1. Stub API service. + let expectedDebugToken = provider.currentDebugToken() + let validToken = AppCheckCoreToken( + token: "valid_token", + expirationDate: Date(), + receivedAt: Date() + ) + fakeAPIService.tokenResult = .success(validToken) + + // 2. Validate get token. + let token = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + AppCheckCoreToken, + Error + >) in + provider.getToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: nil)) + } + } + } + + XCTAssertEqual(token.token, validToken.token) + XCTAssertEqual(token.expirationDate, validToken.expirationDate) + XCTAssertEqual(token.receivedAtDate, validToken.receivedAtDate) + + // 3. Verify fakes. + XCTAssertEqual(fakeAPIService.passedDebugToken, expectedDebugToken) + XCTAssertEqual(fakeAPIService.passedLimitedUse, false) + } + + func testGetTokenAPIError() async throws { + // 1. Stub API service. + let expectedDebugToken = provider.currentDebugToken() + let apiError = NSError(domain: "testGetTokenAPIError", code: -1, userInfo: nil) + fakeAPIService.tokenResult = .failure(apiError) + + // 2. Validate get token. + do { + _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + AppCheckCoreToken, + Error + >) in + provider.getToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: nil)) + } + } + } + XCTFail("Expected error to be thrown") + } catch let error as NSError { + XCTAssertEqual(error, apiError) + } + + // 3. Verify fakes. + XCTAssertEqual(fakeAPIService.passedDebugToken, expectedDebugToken) + XCTAssertEqual(fakeAPIService.passedLimitedUse, false) + } + + func testGetLimitedUseTokenSuccess() async throws { + // 1. Stub API service. + let expectedDebugToken = provider.currentDebugToken() + let validToken = AppCheckCoreToken( + token: "valid_token", + expirationDate: Date(), + receivedAt: Date() + ) + fakeAPIService.limitedUseTokenResult = .success(validToken) + + // 2. Validate get limited-use token. + let token = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + AppCheckCoreToken, + Error + >) in + provider.getLimitedUseToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: nil)) + } + } + } + + XCTAssertEqual(token.token, validToken.token) + XCTAssertEqual(token.expirationDate, validToken.expirationDate) + XCTAssertEqual(token.receivedAtDate, validToken.receivedAtDate) + + // 3. Verify fakes. + XCTAssertEqual(fakeAPIService.passedDebugToken, expectedDebugToken) + XCTAssertEqual(fakeAPIService.passedLimitedUse, true) + } + + func testGetLimitedUseTokenAPIError() async throws { + // 1. Stub API service. + let expectedDebugToken = provider.currentDebugToken() + let apiError = NSError(domain: "testGetLimitedUseTokenAPIError", code: -1, userInfo: nil) + fakeAPIService.limitedUseTokenResult = .failure(apiError) + + // 2. Validate get limited-use token. + do { + _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + AppCheckCoreToken, + Error + >) in + provider.getLimitedUseToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: nil)) + } + } + } + XCTFail("Expected error to be thrown") + } catch let error as NSError { + XCTAssertEqual(error, apiError) + } + + // 3. Verify fakes. + XCTAssertEqual(fakeAPIService.passedDebugToken, expectedDebugToken) + XCTAssertEqual(fakeAPIService.passedLimitedUse, true) + } + + func testGetTokenSuccessSetsRegisteredFlag() async throws { + // 1. Stub API service. + let expectedDebugToken = provider.currentDebugToken() + let validToken = AppCheckCoreToken( + token: "valid_token", + expirationDate: Date(), + receivedAt: Date() + ) + fakeAPIService.tokenResult = .success(validToken) + + // The mirror way to get registeredUserDefaultsKey, or since we know it, we can just hardcode or + // access it + let registeredKey = + "\(kDebugTokenRegisteredUserDefaultsKey)_test-service_projects_test-project_apps_test-app" + UserDefaults.standard.removeObject(forKey: registeredKey) + + // 2. Validate get token. + _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + AppCheckCoreToken, + Error + >) in + provider.getToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: nil)) + } + } + } + + // 3. Verify flag is now YES. + XCTAssertTrue(UserDefaults.standard.bool(forKey: registeredKey)) + + // 4. Verify fakes. + XCTAssertEqual(fakeAPIService.passedDebugToken, expectedDebugToken) + XCTAssertEqual(fakeAPIService.passedLimitedUse, false) + } + + func testGetTokenPermanentFailureClearsRegisteredFlag() async throws { + // 1. Stub API service. + let expectedDebugToken = provider.currentDebugToken() + let apiError = NSError( + domain: "testGetTokenPermanentFailureClearsRegisteredFlag", + code: -1, + userInfo: nil + ) + fakeAPIService.tokenResult = .failure(apiError) + + let registeredKey = + "\(kDebugTokenRegisteredUserDefaultsKey)_test-service_projects_test-project_apps_test-app" + UserDefaults.standard.set(true, forKey: registeredKey) + + // 2. Validate get token. + do { + _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + AppCheckCoreToken, + Error + >) in + provider.getToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: nil)) + } + } + } + XCTFail("Expected error to be thrown") + } catch _ { + // expected + } + + // 3. Verify flag is cleared. + XCTAssertNil(UserDefaults.standard.object(forKey: registeredKey)) + + // 4. Verify fakes. + XCTAssertEqual(fakeAPIService.passedDebugToken, expectedDebugToken) + XCTAssertEqual(fakeAPIService.passedLimitedUse, false) + } + + func testGetTokenNetworkFailureDoesNotClearRegisteredFlag() async throws { + // 1. Stub API service. + let expectedDebugToken = provider.currentDebugToken() + let networkError = NSError( + domain: AppCheckCoreErrorDomain, + code: AppCheckCoreErrorCode.serverUnreachable.rawValue, + userInfo: nil + ) + fakeAPIService.tokenResult = .failure(networkError) + + let registeredKey = + "\(kDebugTokenRegisteredUserDefaultsKey)_test-service_projects_test-project_apps_test-app" + UserDefaults.standard.set(true, forKey: registeredKey) + + // 2. Validate get token. + do { + _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + AppCheckCoreToken, + Error + >) in + provider.getToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: nil)) + } + } + } + XCTFail("Expected error to be thrown") + } catch let error as NSError { + XCTAssertEqual(error, networkError) + } + + // 3. Verify flag is still YES. + XCTAssertTrue(UserDefaults.standard.bool(forKey: registeredKey)) + + // 4. Verify fakes. + XCTAssertEqual(fakeAPIService.passedDebugToken, expectedDebugToken) + XCTAssertEqual(fakeAPIService.passedLimitedUse, false) + } +} diff --git a/AppCheckCore/Tests/Unit/DebugProvider/GACAppCheckDebugProviderAPIServiceTests.m b/AppCheckCore/Tests/Unit/DebugProvider/GACAppCheckDebugProviderAPIServiceTests.m deleted file mode 100644 index 551b6e7a..00000000 --- a/AppCheckCore/Tests/Unit/DebugProvider/GACAppCheckDebugProviderAPIServiceTests.m +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "FBLPromise+Testing.h" - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheck.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.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/Unit/Utils/GACAppCheckAPIServiceFake.h" -#import "AppCheckCore/Tests/Unit/Utils/GACURLSessionFake.h" - -static NSString *const kResourceName = @"projects/test_project_id/apps/test_app_id"; - -@interface GACAppCheckDebugProviderAPIServiceTests : XCTestCase -@property(nonatomic) GACAppCheckDebugProviderAPIService *debugAPIService; - -@property(nonatomic) GACAppCheckAPIServiceFake *mockAPIService; -@end - -@implementation GACAppCheckDebugProviderAPIServiceTests - -- (void)setUp { - [super setUp]; - - self.mockAPIService = [[GACAppCheckAPIServiceFake alloc] init]; - self.mockAPIService.baseURL = @"https://test.appcheck.url.com/alpha"; - - self.debugAPIService = - [[GACAppCheckDebugProviderAPIService alloc] initWithAPIService:self.mockAPIService - resourceName:kResourceName]; -} - -- (void)tearDown { - self.debugAPIService = nil; - self.mockAPIService = nil; - [super tearDown]; -} - -- (void)testAppCheckTokenSuccess { - [self testAppCheckTokenSuccessWithLimitedUse:NO]; -} - -- (void)testAppCheckTokenSuccessWithLimitedUse { - [self testAppCheckTokenSuccessWithLimitedUse:YES]; -} - -- (void)testAppCheckTokenSuccessWithLimitedUse:(BOOL)limitedUse { - NSString *debugToken = [NSUUID UUID].UUIDString; - GACAppCheckToken *expectedResult = [[GACAppCheckToken alloc] initWithToken:@"app_check_token" - expirationDate:[NSDate date]]; - - // 1. Stub API service. - // 1.1. Stub API response. - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@%@", [self.mockAPIService baseURL], - @"/projects/test_project_id/apps/test_app_id:exchangeDebugToken"]; - NSData *fakeResponseData = [@"fake response" dataUsingEncoding:NSUTF8StringEncoding]; - NSHTTPURLResponse *HTTPResponse = [GACURLSessionFake HTTPResponseWithCode:200]; - _GACURLSessionDataResponse *APIResponse = - [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:fakeResponseData]; - - self.mockAPIService.sendRequestPromise = [FBLPromise resolvedWith:APIResponse]; - - // 1.2. Stub response parsing. - self.mockAPIService.appCheckTokenPromise = [FBLPromise resolvedWith:expectedResult]; - - // 2. Send request. - __auto_type tokenPromise = [self.debugAPIService appCheckTokenWithDebugToken:debugToken - limitedUse:limitedUse]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(tokenPromise.isFulfilled); - XCTAssertNil(tokenPromise.error); - - XCTAssertEqualObjects(tokenPromise.value.token, expectedResult.token); - XCTAssertEqualObjects(tokenPromise.value.expirationDate, expectedResult.expirationDate); - - XCTAssertEqualObjects(tokenPromise.value.token, expectedResult.token); - XCTAssertEqualObjects(tokenPromise.value.expirationDate, expectedResult.expirationDate); - - XCTAssertEqualObjects(self.mockAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.mockAPIService.passedHTTPMethod, @"POST"); - XCTAssertEqualObjects(self.mockAPIService.passedAdditionalHeaders[@"Content-Type"], - @"application/json"); - [self assertHTTPBody:self.mockAPIService.passedBody debugToken:debugToken limitedUse:limitedUse]; - XCTAssertEqualObjects(self.mockAPIService.passedAPIResponse, APIResponse); -} - -- (void)testAppCheckTokenResponseParsingError { - NSString *debugToken = [NSUUID UUID].UUIDString; - NSError *parsingError = [NSError errorWithDomain:@"testAppCheckTokenResponseParsingError" - code:-1 - userInfo:nil]; - - // 1. Stub API service. - // 1.1. Stub API response. - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@%@", [self.mockAPIService baseURL], - @"/projects/test_project_id/apps/test_app_id:exchangeDebugToken"]; - NSData *fakeResponseData = [@"fake response" dataUsingEncoding:NSUTF8StringEncoding]; - NSHTTPURLResponse *HTTPResponse = [GACURLSessionFake HTTPResponseWithCode:200]; - _GACURLSessionDataResponse *APIResponse = - [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:fakeResponseData]; - - self.mockAPIService.sendRequestPromise = [FBLPromise resolvedWith:APIResponse]; - - // 1.2. Stub response parsing. - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:parsingError]; - self.mockAPIService.appCheckTokenPromise = rejectedPromise; - - // 2. Send request. - __auto_type tokenPromise = [self.debugAPIService appCheckTokenWithDebugToken:debugToken - limitedUse:NO]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(tokenPromise.isRejected); - XCTAssertEqualObjects(tokenPromise.error, parsingError); - XCTAssertNil(tokenPromise.value); - - XCTAssertEqualObjects(self.mockAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.mockAPIService.passedHTTPMethod, @"POST"); - XCTAssertEqualObjects(self.mockAPIService.passedAdditionalHeaders[@"Content-Type"], - @"application/json"); - [self assertHTTPBody:self.mockAPIService.passedBody debugToken:debugToken limitedUse:NO]; - XCTAssertEqualObjects(self.mockAPIService.passedAPIResponse, APIResponse); -} - -- (void)testAppCheckTokenNetworkError { - NSString *debugToken = [NSUUID UUID].UUIDString; - NSError *APIError = [NSError errorWithDomain:@"testAppCheckTokenNetworkError" - code:-1 - userInfo:nil]; - - // 1. Stub API service. - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:APIError]; - - self.mockAPIService.sendRequestPromise = rejectedPromise; - - // 2. Send request. - __auto_type tokenPromise = [self.debugAPIService appCheckTokenWithDebugToken:debugToken - limitedUse:NO]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(tokenPromise.isRejected); - XCTAssertNil(tokenPromise.value); - XCTAssertEqualObjects(tokenPromise.error, APIError); - - [self assertHTTPBody:self.mockAPIService.passedBody debugToken:debugToken limitedUse:NO]; -} - -#pragma mark - Helpores - -- (void)assertHTTPBody:(NSData *)body - debugToken:(NSString *)debugToken - limitedUse:(BOOL)limitedUse { - NSDictionary *decodedData = [NSJSONSerialization JSONObjectWithData:body - options:0 - error:nil]; - XCTAssert([decodedData isKindOfClass:[NSDictionary class]]); - - NSString *decodeDebugToken = decodedData[@"debug_token"]; - XCTAssertNotNil(decodeDebugToken); - XCTAssertEqualObjects(decodeDebugToken, debugToken); - NSNumber *decodedLimitedUse = decodedData[@"limited_use"]; - XCTAssertNotNil(decodedLimitedUse); - XCTAssertEqualObjects(decodedLimitedUse, @(limitedUse)); -} - -@end diff --git a/AppCheckCore/Tests/Unit/DebugProvider/GACAppCheckDebugProviderTests.m b/AppCheckCore/Tests/Unit/DebugProvider/GACAppCheckDebugProviderTests.m deleted file mode 100644 index 5ef3dc5d..00000000 --- a/AppCheckCore/Tests/Unit/DebugProvider/GACAppCheckDebugProviderTests.m +++ /dev/null @@ -1,374 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import -#import "FBLPromise+Testing.h" - -#import "AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckDebugProvider.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" - -#import "AppCheckCore/Sources/Core/GACAppCheckDebugProvider+Internal.h" -#import "AppCheckCore/Tests/Unit/Utils/GACAppCheckDebugProviderAPIServiceFake.h" - -static NSString *const kDebugTokenEnvKey = @"AppCheckDebugToken"; -static NSString *const kFirebaseDebugTokenEnvKey = @"FIRAAppCheckDebugToken"; -static NSString *const kDebugTokenUserDefaultsKey = @"GACAppCheckDebugToken"; -static NSString *const kDebugTokenRegisteredUserDefaultsKey = @"GACAppCheckDebugTokenRegistered"; - -@interface GACAppCheckDebugProvider (Tests) - -- (instancetype)initWithAPIService:(id)APIService - serviceName:(NSString *)serviceName - resourceName:(NSString *)resourceName - environment:(NSDictionary *)environment; - -+ (NSString *)registeredUserDefaultsKeyForServiceName:(NSString *)serviceName - resourceName:(NSString *)resourceName; - -@property(nonatomic, readonly, copy) NSString *registeredUserDefaultsKey; - -@end - -@interface GACAppCheckDebugProviderTests : XCTestCase - -@property(nonatomic) GACAppCheckDebugProvider *provider; -@property(nonatomic) GACAppCheckDebugProviderAPIServiceFake *fakeAPIService; - -@end - -typedef void (^GACAppCheckTokenValidationBlock)(GACAppCheckToken *_Nullable token, - NSError *_Nullable error); - -@implementation GACAppCheckDebugProviderTests - -- (void)setUp { - self.fakeAPIService = [[GACAppCheckDebugProviderAPIServiceFake alloc] init]; - self.provider = - [[GACAppCheckDebugProvider alloc] initWithAPIService:self.fakeAPIService - serviceName:@"test-service" - resourceName:@"projects/test-project/apps/test-app" - environment:@{}]; -} - -- (void)tearDown { - self.provider = nil; - [[GULUserDefaults standardUserDefaults] removeObjectForKey:kDebugTokenUserDefaultsKey]; - [[GULUserDefaults standardUserDefaults] removeObjectForKey:kDebugTokenRegisteredUserDefaultsKey]; - [super tearDown]; -} - -#pragma mark - Debug token generating/storing - -- (void)testCurrentTokenWhenEnvironmentVariableSetAndTokenStored { - [[GULUserDefaults standardUserDefaults] setObject:@"stored token" - forKey:kDebugTokenUserDefaultsKey]; - NSString *envToken = @"env token"; - self.provider = - [[GACAppCheckDebugProvider alloc] initWithAPIService:self.fakeAPIService - serviceName:@"test-service" - resourceName:@"projects/test-project/apps/test-app" - environment:@{kDebugTokenEnvKey : envToken}]; - - XCTAssertEqualObjects([self.provider currentDebugToken], envToken); -} - -- (void)testCurrentTokenWhenFirebaseAndCoreEnvironmentVariablesSetAndTokenStored { - [[GULUserDefaults standardUserDefaults] setObject:@"stored token" - forKey:kDebugTokenUserDefaultsKey]; - NSString *envToken = @"env token"; - self.provider = - [[GACAppCheckDebugProvider alloc] initWithAPIService:self.fakeAPIService - serviceName:@"test-service" - resourceName:@"projects/test-project/apps/test-app" - environment:@{ - kDebugTokenEnvKey : envToken, - kFirebaseDebugTokenEnvKey : @"firebase env token" - }]; - - XCTAssertEqualObjects([self.provider currentDebugToken], envToken); -} - -- (void)testCurrentTokenWhenFirebaseEnvironmentVariableSetAndTokenStored { - [[GULUserDefaults standardUserDefaults] setObject:@"stored token" - forKey:kDebugTokenUserDefaultsKey]; - NSString *envToken = @"env token"; - self.provider = - [[GACAppCheckDebugProvider alloc] initWithAPIService:self.fakeAPIService - serviceName:@"test-service" - resourceName:@"projects/test-project/apps/test-app" - environment:@{kFirebaseDebugTokenEnvKey : envToken}]; - - XCTAssertEqualObjects([self.provider currentDebugToken], envToken); -} - -- (void)testCurrentTokenWhenFirebaseAndCoreEnvironmentVariablesSet { - NSString *envToken = @"env token"; - self.provider = - [[GACAppCheckDebugProvider alloc] initWithAPIService:self.fakeAPIService - serviceName:@"test-service" - resourceName:@"projects/test-project/apps/test-app" - environment:@{ - kDebugTokenEnvKey : envToken, - kFirebaseDebugTokenEnvKey : @"firebase env token" - }]; - - XCTAssertEqualObjects([self.provider currentDebugToken], envToken); -} - -- (void)testCurrentTokenWhenNoEnvironmentVariableAndTokenStored { - NSString *storedToken = @"stored token"; - [[GULUserDefaults standardUserDefaults] setObject:storedToken forKey:kDebugTokenUserDefaultsKey]; - - XCTAssertEqualObjects([self.provider currentDebugToken], storedToken); - - XCTAssertEqualObjects([self.provider currentDebugToken], storedToken); -} - -- (void)testCurrentTokenWhenNoEnvironmentVariableAndNoTokenStored { - [[GULUserDefaults standardUserDefaults] removeObjectForKey:kDebugTokenUserDefaultsKey]; - [[GULUserDefaults standardUserDefaults] removeObjectForKey:kDebugTokenUserDefaultsKey]; - XCTAssertNil([[GULUserDefaults standardUserDefaults] stringForKey:kDebugTokenUserDefaultsKey]); - - NSString *generatedToken = [self.provider currentDebugToken]; - XCTAssertNotNil(generatedToken); - - // Check if the generated token is stored to the user defaults. - XCTAssertEqualObjects( - [[GULUserDefaults standardUserDefaults] stringForKey:kDebugTokenUserDefaultsKey], - generatedToken); - - // Check if the same token is used once generated. - XCTAssertEqualObjects([self.provider currentDebugToken], generatedToken); -} - -#pragma mark - Debug token to FAC token exchange - -- (void)testGetTokenSuccess { - // 1. Stub API service. - NSString *expectedDebugToken = [self.provider currentDebugToken]; - GACAppCheckToken *validToken = [[GACAppCheckToken alloc] initWithToken:@"valid_token" - expirationDate:[NSDate date] - receivedAtDate:[NSDate date]]; - self.fakeAPIService.tokenPromise = [FBLPromise resolvedWith:validToken]; - - // 2. Validate get token. - [self validateGetToken:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - XCTAssertNil(error); - XCTAssertEqualObjects(token.token, validToken.token); - XCTAssertEqualObjects(token.expirationDate, validToken.expirationDate); - XCTAssertEqualObjects(token.receivedAtDate, validToken.receivedAtDate); - }]; - - // 3. Verify fakes. - XCTAssertEqualObjects(self.fakeAPIService.passedDebugToken, expectedDebugToken); - XCTAssertFalse(self.fakeAPIService.passedLimitedUse); -} - -- (void)testGetTokenAPIError { - // 1. Stub API service. - NSString *expectedDebugToken = [self.provider currentDebugToken]; - NSError *APIError = [NSError errorWithDomain:@"testGetTokenAPIError" code:-1 userInfo:nil]; - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:APIError]; - self.fakeAPIService.tokenPromise = rejectedPromise; - - // 2. Validate get token. - [self validateGetToken:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - XCTAssertEqualObjects(error, APIError); - XCTAssertNil(token); - }]; - - // 3. Verify fakes. - XCTAssertEqualObjects(self.fakeAPIService.passedDebugToken, expectedDebugToken); - XCTAssertFalse(self.fakeAPIService.passedLimitedUse); -} - -- (void)testGetLimitedUseTokenSuccess { - // 1. Stub API service. - NSString *expectedDebugToken = [self.provider currentDebugToken]; - GACAppCheckToken *validToken = [[GACAppCheckToken alloc] initWithToken:@"valid_token" - expirationDate:[NSDate date] - receivedAtDate:[NSDate date]]; - self.fakeAPIService.limitedUseTokenPromise = [FBLPromise resolvedWith:validToken]; - - // 2. Validate get limited-use token. - [self validateGetLimitedUseToken:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - XCTAssertNil(error); - XCTAssertEqualObjects(token.token, validToken.token); - XCTAssertEqualObjects(token.expirationDate, validToken.expirationDate); - XCTAssertEqualObjects(token.receivedAtDate, validToken.receivedAtDate); - }]; - - // 3. Verify fakes. - XCTAssertEqualObjects(self.fakeAPIService.passedDebugToken, expectedDebugToken); - XCTAssertTrue(self.fakeAPIService.passedLimitedUse); -} - -- (void)testGetLimitedUseTokenAPIError { - // 1. Stub API service. - NSString *expectedDebugToken = [self.provider currentDebugToken]; - NSError *APIError = [NSError errorWithDomain:@"testGetLimitedUseTokenAPIError" - code:-1 - userInfo:nil]; - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:APIError]; - self.fakeAPIService.limitedUseTokenPromise = rejectedPromise; - - // 2. Validate get limited-use token. - [self validateGetLimitedUseToken:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - XCTAssertEqualObjects(error, APIError); - XCTAssertNil(token); - }]; - - // 3. Verify fakes. - XCTAssertEqualObjects(self.fakeAPIService.passedDebugToken, expectedDebugToken); - XCTAssertTrue(self.fakeAPIService.passedLimitedUse); -} - -- (void)testGetTokenSuccessSetsRegisteredFlag { - // 1. Stub API service. - NSString *expectedDebugToken = [self.provider currentDebugToken]; - GACAppCheckToken *validToken = [[GACAppCheckToken alloc] initWithToken:@"valid_token" - expirationDate:[NSDate date] - receivedAtDate:[NSDate date]]; - FBLPromise *resolvedPromise = [FBLPromise pendingPromise]; - [resolvedPromise fulfill:validToken]; - self.fakeAPIService.tokenPromise = resolvedPromise; - - [[GULUserDefaults standardUserDefaults] - removeObjectForKey:self.provider.registeredUserDefaultsKey]; - - // 2. Validate get token. - [self validateGetToken:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - XCTAssertNil(error); - XCTAssertNotNil(token); - }]; - - // 3. Verify flag is now YES. - XCTAssertTrue( - [[GULUserDefaults standardUserDefaults] boolForKey:self.provider.registeredUserDefaultsKey]); - - // 4. Verify fakes. - XCTAssertEqualObjects(self.fakeAPIService.passedDebugToken, expectedDebugToken); - XCTAssertFalse(self.fakeAPIService.passedLimitedUse); -} - -- (void)testGetTokenPermanentFailureClearsRegisteredFlag { - // 1. Stub API service. - NSString *expectedDebugToken = [self.provider currentDebugToken]; - NSError *APIError = [NSError errorWithDomain:@"testGetTokenPermanentFailureClearsRegisteredFlag" - code:-1 - userInfo:nil]; - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:APIError]; - self.fakeAPIService.tokenPromise = rejectedPromise; - - // Pre-populate flag to YES. - [[GULUserDefaults standardUserDefaults] setBool:YES - forKey:self.provider.registeredUserDefaultsKey]; - - // 2. Validate get token. - [self validateGetToken:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - XCTAssertNotNil(error); - XCTAssertNil(token); - }]; - - // 3. Verify flag is cleared. - XCTAssertNil([[GULUserDefaults standardUserDefaults] - objectForKey:self.provider.registeredUserDefaultsKey]); - - // 4. Verify fakes. - XCTAssertEqualObjects(self.fakeAPIService.passedDebugToken, expectedDebugToken); - XCTAssertFalse(self.fakeAPIService.passedLimitedUse); -} - -- (void)testGetTokenNetworkFailureDoesNotClearRegisteredFlag { - // 1. Stub API service. - NSString *expectedDebugToken = [self.provider currentDebugToken]; - NSError *networkError = [NSError errorWithDomain:GACAppCheckErrorDomain - code:GACAppCheckErrorCodeServerUnreachable - userInfo:nil]; - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:networkError]; - self.fakeAPIService.tokenPromise = rejectedPromise; - - // Pre-populate flag to YES. - [[GULUserDefaults standardUserDefaults] setBool:YES - forKey:self.provider.registeredUserDefaultsKey]; - - // 2. Validate get token. - [self validateGetToken:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - XCTAssertNotNil(error); - XCTAssertNil(token); - }]; - - // 3. Verify flag is still YES. - XCTAssertTrue( - [[GULUserDefaults standardUserDefaults] boolForKey:self.provider.registeredUserDefaultsKey]); - - // 4. Verify fakes. - XCTAssertEqualObjects(self.fakeAPIService.passedDebugToken, expectedDebugToken); - XCTAssertFalse(self.fakeAPIService.passedLimitedUse); -} - -#pragma mark - Keys - -- (void)testRegisteredUserDefaultsKeyForServiceName_resourceName { - XCTAssertEqualObjects( - [GACAppCheckDebugProvider registeredUserDefaultsKeyForServiceName:@"app1" - resourceName:@"projects/p1/apps/a1"], - @"GACAppCheckDebugTokenRegistered_app1_projects_p1_apps_a1"); - XCTAssertEqualObjects( - [GACAppCheckDebugProvider registeredUserDefaultsKeyForServiceName:@"app2" - resourceName:@"projects/p2/apps/a2"], - @"GACAppCheckDebugTokenRegistered_app2_projects_p2_apps_a2"); - XCTAssertEqualObjects([GACAppCheckDebugProvider registeredUserDefaultsKeyForServiceName:@"" - resourceName:@""], - @"GACAppCheckDebugTokenRegistered_default_default"); - XCTAssertEqualObjects([GACAppCheckDebugProvider registeredUserDefaultsKeyForServiceName:nil - resourceName:nil], - @"GACAppCheckDebugTokenRegistered_default_default"); -} - -#pragma mark - Helpers - -- (void)validateGetToken:(GACAppCheckTokenValidationBlock)validationBlock { - XCTestExpectation *expectation = [self expectationWithDescription:@"getToken"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - validationBlock(token, error); - [expectation fulfill]; - }]; - - [self waitForExpectations:@[ expectation ] timeout:0.5]; -} - -- (void)validateGetLimitedUseToken:(GACAppCheckTokenValidationBlock)validationBlock { - XCTestExpectation *expectation = [self expectationWithDescription:@"getLimitedUseToken"]; - [self.provider getLimitedUseTokenWithCompletion:^(GACAppCheckToken *_Nullable token, - NSError *_Nullable error) { - validationBlock(token, error); - [expectation fulfill]; - }]; - - [self waitForExpectations:@[ expectation ] timeout:0.5]; -} - -@end diff --git a/AppCheckCore/Tests/Unit/DeviceCheckProvider/AppCheckCoreDeviceCheckAPIServiceTests.swift b/AppCheckCore/Tests/Unit/DeviceCheckProvider/AppCheckCoreDeviceCheckAPIServiceTests.swift new file mode 100644 index 00000000..58b74bd2 --- /dev/null +++ b/AppCheckCore/Tests/Unit/DeviceCheckProvider/AppCheckCoreDeviceCheckAPIServiceTests.swift @@ -0,0 +1,222 @@ +// 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 +#if canImport(Promises) + import Promises +#endif +@testable import AppCheckCore + +private class MockAppCheckAPIService: NSObject, AppCheckCoreAPIServiceProtocol { + var baseURL: String = "https://test.appcheck.url.com/alpha" + + var passedRequestURL: URL? + var passedHTTPMethod: String? + var passedBody: Data? + var passedAdditionalHeaders: [String: String]? + + var sendRequestResult: Result? + var appCheckTokenResult: Result? + + var passedAPIResponse: AppCheckCoreURLSessionDataResponse? + + func sendRequest(withURL requestURL: URL, httpMethod: String, body: Data?, + additionalHeaders: [String: String]?) async throws + -> AppCheckCoreURLSessionDataResponse { + passedRequestURL = requestURL + passedHTTPMethod = httpMethod + passedBody = body + passedAdditionalHeaders = additionalHeaders + if let result = sendRequestResult { + switch result { + case let .success(response): return response + case let .failure(error): throw error + } + } + throw NSError(domain: "MockAppCheckAPIService", code: -1, userInfo: nil) + } + + func appCheckToken(withAPIResponse response: AppCheckCoreURLSessionDataResponse) async throws + -> AppCheckCoreToken { + passedAPIResponse = response + if let result = appCheckTokenResult { + switch result { + case let .success(token): return token + case let .failure(error): throw error + } + } + throw NSError(domain: "MockAppCheckAPIService", code: -1, userInfo: nil) + } +} + +class AppCheckCoreDeviceCheckAPIServiceTests: XCTestCase { + var apiService: AppCheckCoreDeviceCheckAPIService! + private var mockAPIService: MockAppCheckAPIService! + + let kResourceName = "projects/project_id/apps/app_id" + + override func setUp() { + super.setUp() + mockAPIService = MockAppCheckAPIService() + apiService = AppCheckCoreDeviceCheckAPIService( + apiService: mockAPIService, + resourceName: kResourceName + ) + } + + override func tearDown() { + apiService = nil + mockAPIService = nil + super.tearDown() + } + + func testAppCheckTokenSuccess() async throws { + try await testAppCheckTokenSuccess(withLimitedUse: false) + } + + func testAppCheckTokenSuccessWithLimitedUse() async throws { + try await testAppCheckTokenSuccess(withLimitedUse: true) + } + + func testAppCheckTokenSuccess(withLimitedUse limitedUse: Bool) async throws { + let deviceTokenData = "device_token".data(using: .utf8)! + let expectedResult = AppCheckCoreToken(token: "app_check_token", expirationDate: Date()) + + let expectedRequestURL = + "\(mockAPIService.baseURL)/projects/project_id/apps/app_id:exchangeDeviceCheckToken" + + // Since we aren't using the fixture loader for the fake response, we'll just mock any response + // data. + let responseBody = "{}".data(using: .utf8)! + let httpResponse = HTTPURLResponse( + url: URL(string: expectedRequestURL)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let apiResponse = AppCheckCoreURLSessionDataResponse( + response: httpResponse, + httpBody: responseBody + ) + + mockAPIService.sendRequestResult = .success(apiResponse) + mockAPIService.appCheckTokenResult = .success(expectedResult) + + let token = try await apiService.appCheckToken( + deviceToken: deviceTokenData, + limitedUse: limitedUse + ) + + XCTAssertEqual(token.token, expectedResult.token) + XCTAssertEqual(token.expirationDate, expectedResult.expirationDate) + + XCTAssertEqual(mockAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(mockAPIService.passedHTTPMethod, "POST") + XCTAssertEqual(mockAPIService.passedAdditionalHeaders?["Content-Type"], "application/json") + try assertHTTPBody( + mockAPIService.passedBody, + deviceToken: deviceTokenData, + limitedUse: limitedUse + ) + XCTAssertEqual(mockAPIService.passedAPIResponse, apiResponse) + } + + func testAppCheckTokenResponseParsingError() async throws { + let deviceTokenData = "device_token".data(using: .utf8)! + let parsingError = NSError( + domain: "testAppCheckTokenResponseParsingError", + code: -1, + userInfo: nil + ) + + let expectedRequestURL = + "\(mockAPIService.baseURL)/projects/project_id/apps/app_id:exchangeDeviceCheckToken" + let responseBody = "{}".data(using: .utf8)! + let httpResponse = HTTPURLResponse( + url: URL(string: expectedRequestURL)!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let apiResponse = AppCheckCoreURLSessionDataResponse( + response: httpResponse, + httpBody: responseBody + ) + + mockAPIService.sendRequestResult = .success(apiResponse) + mockAPIService.appCheckTokenResult = .failure(parsingError) + + do { + _ = try await apiService.appCheckToken(deviceToken: deviceTokenData, limitedUse: false) + XCTFail("Expected error to be thrown") + } catch let error as NSError { + XCTAssertEqual(error, parsingError) + } + + XCTAssertEqual(mockAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(mockAPIService.passedHTTPMethod, "POST") + XCTAssertEqual(mockAPIService.passedAdditionalHeaders?["Content-Type"], "application/json") + try assertHTTPBody(mockAPIService.passedBody, deviceToken: deviceTokenData, limitedUse: false) + XCTAssertEqual(mockAPIService.passedAPIResponse, apiResponse) + } + + func testAppCheckTokenNetworkError() async throws { + let deviceTokenData = "device_token".data(using: .utf8)! + let apiError = NSError(domain: "testAppCheckTokenNetworkError", code: -1, userInfo: nil) + + mockAPIService.sendRequestResult = .failure(apiError) + + do { + _ = try await apiService.appCheckToken(deviceToken: deviceTokenData, limitedUse: false) + XCTFail("Expected error to be thrown") + } catch let error as NSError { + XCTAssertEqual(error, apiError) + } + + try assertHTTPBody(mockAPIService.passedBody, deviceToken: deviceTokenData, limitedUse: false) + } + + func testAppCheckTokenEmptyDeviceToken() async throws { + let deviceTokenData = Data() + + do { + _ = try await apiService.appCheckToken(deviceToken: deviceTokenData, limitedUse: false) + XCTFail("Expected error to be thrown") + } catch let error as NSError { + XCTAssertEqual(error.domain, AppCheckCoreErrorDomain) + XCTAssertEqual(error.code, AppCheckCoreErrorCode.unknown.rawValue) + let failureReason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String + XCTAssertEqual(failureReason, "DeviceCheck token must not be empty.") + } + + XCTAssertNil(mockAPIService.passedRequestURL) + } + + // MARK: - Helpers + + func assertHTTPBody(_ body: Data?, deviceToken: Data, limitedUse: Bool) throws { + let unwrappedBody = try XCTUnwrap(body) + let decodedData = try JSONSerialization + .jsonObject(with: unwrappedBody, options: []) as? [String: Any] + let unwrappedDecodedData = try XCTUnwrap(decodedData) + + let base64EncodedDeviceToken = try XCTUnwrap(unwrappedDecodedData["device_token"] as? String) + let decodedLimitedUse = try XCTUnwrap(unwrappedDecodedData["limited_use"] as? Bool) + + XCTAssertEqual(decodedLimitedUse, limitedUse) + + let decodedToken = try XCTUnwrap(Data(base64Encoded: base64EncodedDeviceToken)) + XCTAssertEqual(decodedToken, deviceToken) + } +} diff --git a/AppCheckCore/Tests/Unit/DeviceCheckProvider/AppCheckCoreDeviceCheckProviderTests.swift b/AppCheckCore/Tests/Unit/DeviceCheckProvider/AppCheckCoreDeviceCheckProviderTests.swift new file mode 100644 index 00000000..1f7b5370 --- /dev/null +++ b/AppCheckCore/Tests/Unit/DeviceCheckProvider/AppCheckCoreDeviceCheckProviderTests.swift @@ -0,0 +1,413 @@ +// 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 +#if canImport(FBLPromises) + import FBLPromises +#endif +@testable import AppCheckCore + +class AppCheckCoreDeviceCheckAPIServiceFake: NSObject, AppCheckCoreDeviceCheckAPIServiceProtocol { + var passedDeviceToken: Data? + var passedLimitedUse: Bool? + var appCheckTokenResult: Result? + + func appCheckToken(deviceToken: Data, limitedUse: Bool) async throws -> AppCheckCoreToken { + passedDeviceToken = deviceToken + passedLimitedUse = limitedUse + guard let result = appCheckTokenResult else { + throw NSError(domain: "FakeError", code: -1, userInfo: nil) + } + switch result { + case let .success(token): return token + case let .failure(error): throw error + } + } +} + +class AppCheckCoreDeviceCheckTokenGeneratorFake: NSObject, AppCheckCoreDeviceCheckTokenGenerator { + var supported: Bool = true + var generateTokenCalled = false + var tokenToReturn: Data? + var errorToReturn: Error? + + var isSupported: Bool { supported } + + func generateToken(completionHandler: @escaping (Data?, Error?) -> Void) { + generateTokenCalled = true + if let error = errorToReturn { + completionHandler(nil, error) + } else if let token = tokenToReturn { + completionHandler(token, nil) + } else { + completionHandler(nil, NSError(domain: "FakeError", code: -1, userInfo: nil)) + } + } +} + +class AppCheckCoreBackoffWrapperFake: NSObject, AppCheckBackoffWrapperProtocol { + var isNextOperationAllowed: Bool = true + var backoffError: Error = NSError(domain: "BackoffError", code: -1, userInfo: nil) + + var backoffExpectation: XCTestExpectation? + var defaultErrorHandlerCalled = false + var defaultErrorHandler: ((Error) -> AppCheckBackoffType)? + + var operationResult: Any? + var operationError: Error? + + func applyBackoffToOperation(_ operationProvider: @escaping () async throws -> Any, + errorHandler: @escaping (Error) -> AppCheckBackoffType) async throws + -> Any { + backoffExpectation?.fulfill() + if isNextOperationAllowed { + do { + let value = try await operationProvider() + operationResult = value + return value + } catch { + operationError = error + _ = errorHandler(error) + throw error + } + } else { + throw backoffError + } + } + + func defaultAppCheckProviderErrorHandler() -> (Error) -> AppCheckBackoffType { + return { error in + self.defaultErrorHandlerCalled = true + if let handler = self.defaultErrorHandler { + return handler(error) + } + return .oneDay + } + } +} + +@available(iOS 11.0, macOS 10.15, macCatalyst 13.0, tvOS 11.0, watchOS 9.0, *) +class AppCheckCoreDeviceCheckProviderTests: XCTestCase { + var provider: AppCheckCoreDeviceCheckProvider! + var fakeAPIService: AppCheckCoreDeviceCheckAPIServiceFake! + var fakeTokenGenerator: AppCheckCoreDeviceCheckTokenGeneratorFake! + var fakeBackoffWrapper: AppCheckCoreBackoffWrapperFake! + + override func setUp() { + super.setUp() + fakeAPIService = AppCheckCoreDeviceCheckAPIServiceFake() + fakeTokenGenerator = AppCheckCoreDeviceCheckTokenGeneratorFake() + fakeBackoffWrapper = AppCheckCoreBackoffWrapperFake() + fakeBackoffWrapper.isNextOperationAllowed = true + + provider = AppCheckCoreDeviceCheckProvider(apiService: fakeAPIService, + deviceTokenGenerator: fakeTokenGenerator, + backoffWrapper: fakeBackoffWrapper) + } + + override func tearDown() { + provider = nil + fakeAPIService = nil + fakeTokenGenerator = nil + fakeBackoffWrapper = nil + super.tearDown() + } + + func testGetTokenSuccess() async throws { + fakeTokenGenerator.supported = true + let deviceToken = Data() + fakeTokenGenerator.tokenToReturn = deviceToken + + let validToken = AppCheckCoreToken( + token: "valid_token", + expirationDate: Date.distantFuture, + receivedAt: Date() + ) + fakeAPIService.appCheckTokenResult = .success(validToken) + + fakeBackoffWrapper.backoffExpectation = expectation(description: "Backoff") + + let token = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + AppCheckCoreToken, + Error + >) in + provider.getToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: nil)) + } + } + } + + await fulfillment(of: [fakeBackoffWrapper.backoffExpectation!], timeout: 0.5) + + XCTAssertEqual(token.token, validToken.token) + XCTAssertEqual(token.expirationDate, validToken.expirationDate) + XCTAssertEqual(token.receivedAtDate, validToken.receivedAtDate) + + XCTAssertNil(fakeBackoffWrapper.operationError) + let wrapperResult = fakeBackoffWrapper.operationResult as? AppCheckCoreToken + XCTAssertEqual(wrapperResult?.token, validToken.token) + + XCTAssertEqual(fakeAPIService.passedDeviceToken, deviceToken) + XCTAssertEqual(fakeAPIService.passedLimitedUse, false) + XCTAssertTrue(fakeTokenGenerator.generateTokenCalled) + } + + func testGetTokenWhenDeviceCheckIsNotSupported() async throws { + let expectedError = AppCheckCoreErrorUtil.unsupportedAttestationProvider("DeviceCheckProvider") + + fakeBackoffWrapper.backoffExpectation = expectation(description: "Backoff") + let errorHandlerExpectation = expectation(description: "Error handler") + + fakeBackoffWrapper.defaultErrorHandler = { error in + let nsError = error as NSError + let expNSError = expectedError as NSError + XCTAssertEqual(nsError.domain, expNSError.domain) + XCTAssertEqual(nsError.code, expNSError.code) + errorHandlerExpectation.fulfill() + return .oneDay + } + + fakeTokenGenerator.supported = false + + do { + _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + AppCheckCoreToken, + Error + >) in + provider.getToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: nil)) + } + } + } + XCTFail("Expected error") + } catch let error as NSError { + let expNSError = expectedError as NSError + XCTAssertEqual(error.domain, expNSError.domain) + XCTAssertEqual(error.code, expNSError.code) + } + + await fulfillment( + of: [fakeBackoffWrapper.backoffExpectation!, errorHandlerExpectation], + timeout: 0.5 + ) + + XCTAssertNil(fakeAPIService.passedDeviceToken) + XCTAssertFalse(fakeTokenGenerator.generateTokenCalled) + + let opError = fakeBackoffWrapper.operationError as? NSError + XCTAssertEqual(opError?.domain, (expectedError as NSError).domain) + XCTAssertEqual(opError?.code, (expectedError as NSError).code) + XCTAssertNil(fakeBackoffWrapper.operationResult) + } + + func testGetTokenWhenDeviceTokenFails() async throws { + let deviceTokenError = NSError( + domain: "AppCheckCoreDeviceCheckProviderTests", + code: -1, + userInfo: nil + ) + + fakeBackoffWrapper.backoffExpectation = expectation(description: "Backoff") + let errorHandlerExpectation = expectation(description: "Error handler") + + fakeBackoffWrapper.defaultErrorHandler = { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, deviceTokenError.domain) + XCTAssertEqual(nsError.code, deviceTokenError.code) + errorHandlerExpectation.fulfill() + return .oneDay + } + + fakeTokenGenerator.supported = true + fakeTokenGenerator.errorToReturn = deviceTokenError + + do { + _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + AppCheckCoreToken, + Error + >) in + provider.getToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: nil)) + } + } + } + XCTFail("Expected error") + } catch let error as NSError { + XCTAssertEqual(error.domain, deviceTokenError.domain) + XCTAssertEqual(error.code, deviceTokenError.code) + } + + await fulfillment( + of: [fakeBackoffWrapper.backoffExpectation!, errorHandlerExpectation], + timeout: 0.5 + ) + + XCTAssertNil(fakeAPIService.passedDeviceToken) + XCTAssertTrue(fakeTokenGenerator.generateTokenCalled) + + let opError = fakeBackoffWrapper.operationError as? NSError + XCTAssertEqual(opError?.domain, deviceTokenError.domain) + XCTAssertEqual(opError?.code, deviceTokenError.code) + XCTAssertNil(fakeBackoffWrapper.operationResult) + } + + func testGetTokenWhenAPIServiceFails() async throws { + let apiError = NSError(domain: "AppCheckCoreDeviceCheckProviderTests", code: -1, userInfo: nil) + + fakeBackoffWrapper.backoffExpectation = expectation(description: "Backoff") + let errorHandlerExpectation = expectation(description: "Error handler") + + fakeBackoffWrapper.defaultErrorHandler = { error in + let nsError = error as NSError + XCTAssertEqual(nsError.domain, apiError.domain) + XCTAssertEqual(nsError.code, apiError.code) + errorHandlerExpectation.fulfill() + return .oneDay + } + + fakeTokenGenerator.supported = true + let deviceToken = Data() + fakeTokenGenerator.tokenToReturn = deviceToken + + fakeAPIService.appCheckTokenResult = .failure(apiError) + + do { + _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + AppCheckCoreToken, + Error + >) in + provider.getToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: nil)) + } + } + } + XCTFail("Expected error") + } catch let error as NSError { + XCTAssertEqual(error.domain, apiError.domain) + XCTAssertEqual(error.code, apiError.code) + } + + await fulfillment( + of: [fakeBackoffWrapper.backoffExpectation!, errorHandlerExpectation], + timeout: 0.5 + ) + + XCTAssertEqual(fakeAPIService.passedDeviceToken, deviceToken) + XCTAssertEqual(fakeAPIService.passedLimitedUse, false) + XCTAssertTrue(fakeTokenGenerator.generateTokenCalled) + + let opError = fakeBackoffWrapper.operationError as? NSError + XCTAssertEqual(opError?.domain, apiError.domain) + XCTAssertEqual(opError?.code, apiError.code) + XCTAssertNil(fakeBackoffWrapper.operationResult) + } + + func testGetLimitedUseTokenSuccess() async throws { + fakeTokenGenerator.supported = true + let deviceToken = Data() + fakeTokenGenerator.tokenToReturn = deviceToken + + let validToken = AppCheckCoreToken( + token: "valid_token", + expirationDate: Date.distantFuture, + receivedAt: Date() + ) + fakeAPIService.appCheckTokenResult = .success(validToken) + + fakeBackoffWrapper.backoffExpectation = expectation(description: "Backoff") + + let token = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + AppCheckCoreToken, + Error + >) in + provider.getLimitedUseToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: nil)) + } + } + } + + await fulfillment(of: [fakeBackoffWrapper.backoffExpectation!], timeout: 0.5) + + XCTAssertEqual(token.token, validToken.token) + XCTAssertEqual(token.expirationDate, validToken.expirationDate) + XCTAssertEqual(token.receivedAtDate, validToken.receivedAtDate) + + XCTAssertNil(fakeBackoffWrapper.operationError) + let wrapperResult = fakeBackoffWrapper.operationResult as? AppCheckCoreToken + XCTAssertEqual(wrapperResult?.token, validToken.token) + + XCTAssertEqual(fakeAPIService.passedDeviceToken, deviceToken) + XCTAssertEqual(fakeAPIService.passedLimitedUse, true) + XCTAssertTrue(fakeTokenGenerator.generateTokenCalled) + } + + // MARK: - Backoff tests + + func testGetTokenBackoff() async throws { + fakeBackoffWrapper.isNextOperationAllowed = false + fakeBackoffWrapper.backoffExpectation = expectation(description: "Backoff") + + do { + _ = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation< + AppCheckCoreToken, + Error + >) in + provider.getToken { token, error in + if let error = error { + continuation.resume(throwing: error) + } else if let token = token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: NSError(domain: "TestError", code: -1, userInfo: nil)) + } + } + } + XCTFail("Expected error") + } catch let error as NSError { + let backoffError = fakeBackoffWrapper.backoffError as NSError + XCTAssertEqual(error.domain, backoffError.domain) + XCTAssertEqual(error.code, backoffError.code) + } + + await fulfillment(of: [fakeBackoffWrapper.backoffExpectation!], timeout: 0.5) + + XCTAssertNil(fakeAPIService.passedDeviceToken) + XCTAssertFalse(fakeTokenGenerator.generateTokenCalled) + } +} diff --git a/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckAPIServiceTests.m b/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckAPIServiceTests.m deleted file mode 100644 index 4db23b1d..00000000 --- a/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckAPIServiceTests.m +++ /dev/null @@ -1,241 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "FBLPromise+Testing.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/GACAppCheckAPIServiceFake.h" -#import "AppCheckCore/Tests/Unit/Utils/GACFixtureLoader.h" -#import "AppCheckCore/Tests/Unit/Utils/GACURLSessionFake.h" - -static NSString *const kResourceName = @"projects/project_id/apps/app_id"; - -typedef BOOL (^FIRRequestValidationBlock)(NSURLRequest *request); - -@interface GACDeviceCheckAPIServiceTests : XCTestCase -@property(nonatomic) GACDeviceCheckAPIService *APIService; - -@property(nonatomic) GACAppCheckAPIServiceFake *mockAPIService; - -@end - -@implementation GACDeviceCheckAPIServiceTests - -- (void)setUp { - [super setUp]; - - self.mockAPIService = [[GACAppCheckAPIServiceFake alloc] init]; - self.mockAPIService.baseURL = @"https://test.appcheck.url.com/alpha"; - - self.APIService = [[GACDeviceCheckAPIService alloc] initWithAPIService:self.mockAPIService - resourceName:kResourceName]; -} - -- (void)tearDown { - self.APIService = nil; - self.mockAPIService = nil; - - [super tearDown]; -} - -- (void)testAppCheckTokenSuccess { - [self testAppCheckTokenSuccessWithLimitedUse:NO]; -} - -- (void)testAppCheckTokenSuccessWithLimitedUse { - [self testAppCheckTokenSuccessWithLimitedUse:YES]; -} - -- (void)testAppCheckTokenSuccessWithLimitedUse:(BOOL)limitedUse { - NSData *deviceTokenData = [@"device_token" dataUsingEncoding:NSUTF8StringEncoding]; - GACAppCheckToken *expectedResult = [[GACAppCheckToken alloc] initWithToken:@"app_check_token" - expirationDate:[NSDate date]]; - - // 1. Stub API service. - // 1.1 Stub send request. - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@%@", [self.mockAPIService baseURL], - @"/projects/project_id/apps/app_id:exchangeDeviceCheckToken"]; - - NSData *responseBody = - [GACFixtureLoader loadFixtureNamed:@"FACTokenExchangeResponseSuccess.json"]; - XCTAssertNotNil(responseBody); - - NSHTTPURLResponse *HTTPResponse = [GACURLSessionFake HTTPResponseWithCode:200]; - _GACURLSessionDataResponse *APIResponse = - [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; - - self.mockAPIService.sendRequestPromise = [FBLPromise resolvedWith:APIResponse]; - - // 1.2. Stub response parsing. - self.mockAPIService.appCheckTokenPromise = [FBLPromise resolvedWith:expectedResult]; - - // 2. Send request. - __auto_type tokenPromise = [self.APIService appCheckTokenWithDeviceToken:deviceTokenData - limitedUse:limitedUse]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(tokenPromise.isFulfilled); - XCTAssertNil(tokenPromise.error); - - XCTAssertEqualObjects(tokenPromise.value.token, expectedResult.token); - XCTAssertEqualObjects(tokenPromise.value.expirationDate, expectedResult.expirationDate); - - XCTAssertEqualObjects(tokenPromise.value.token, expectedResult.token); - XCTAssertEqualObjects(tokenPromise.value.expirationDate, expectedResult.expirationDate); - - XCTAssertEqualObjects(self.mockAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.mockAPIService.passedHTTPMethod, @"POST"); - XCTAssertEqualObjects(self.mockAPIService.passedAdditionalHeaders[@"Content-Type"], - @"application/json"); - [self assertHTTPBody:self.mockAPIService.passedBody - deviceToken:deviceTokenData - limitedUse:limitedUse]; - XCTAssertEqualObjects(self.mockAPIService.passedAPIResponse, APIResponse); -} - -- (void)testAppCheckTokenResponseParsingError { - NSData *deviceTokenData = [@"device_token" dataUsingEncoding:NSUTF8StringEncoding]; - NSError *parsingError = [NSError errorWithDomain:@"testAppCheckTokenResponseParsingError" - code:-1 - userInfo:nil]; - - // 1. Stub API service. - // 1.1 Stub send request. - NSString *expectedRequestURL = - [NSString stringWithFormat:@"%@%@", [self.mockAPIService baseURL], - @"/projects/project_id/apps/app_id:exchangeDeviceCheckToken"]; - - NSData *responseBody = - [GACFixtureLoader loadFixtureNamed:@"FACTokenExchangeResponseSuccess.json"]; - XCTAssertNotNil(responseBody); - - NSHTTPURLResponse *HTTPResponse = [GACURLSessionFake HTTPResponseWithCode:200]; - _GACURLSessionDataResponse *APIResponse = - [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse HTTPBody:responseBody]; - - self.mockAPIService.sendRequestPromise = [FBLPromise resolvedWith:APIResponse]; - - // 1.2. Stub response parsing. - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:parsingError]; - self.mockAPIService.appCheckTokenPromise = rejectedPromise; - - // 2. Send request. - __auto_type tokenPromise = [self.APIService appCheckTokenWithDeviceToken:deviceTokenData - limitedUse:NO]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(tokenPromise.isRejected); - XCTAssertEqualObjects(tokenPromise.error, parsingError); - XCTAssertNil(tokenPromise.value); - - XCTAssertEqualObjects(self.mockAPIService.passedRequestURL.absoluteString, expectedRequestURL); - XCTAssertEqualObjects(self.mockAPIService.passedHTTPMethod, @"POST"); - XCTAssertEqualObjects(self.mockAPIService.passedAdditionalHeaders[@"Content-Type"], - @"application/json"); - [self assertHTTPBody:self.mockAPIService.passedBody deviceToken:deviceTokenData limitedUse:NO]; - XCTAssertEqualObjects(self.mockAPIService.passedAPIResponse, APIResponse); -} - -- (void)testAppCheckTokenNetworkError { - NSData *deviceTokenData = [@"device_token" dataUsingEncoding:NSUTF8StringEncoding]; - NSError *APIError = [NSError errorWithDomain:@"testAppCheckTokenNetworkError" - code:-1 - userInfo:nil]; - - // 1. Stub API service. - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:APIError]; - - self.mockAPIService.sendRequestPromise = rejectedPromise; - - // 2. Send request. - __auto_type tokenPromise = [self.APIService appCheckTokenWithDeviceToken:deviceTokenData - limitedUse:NO]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(tokenPromise.isRejected); - XCTAssertNil(tokenPromise.value); - XCTAssertEqualObjects(tokenPromise.error, APIError); - - [self assertHTTPBody:self.mockAPIService.passedBody deviceToken:deviceTokenData limitedUse:NO]; -} - -- (void)testAppCheckTokenEmptyDeviceToken { - NSData *deviceTokenData = [NSData data]; - - // 1. Stub API service. - // It shouldn't be called. - - // 2. Send request. - __auto_type tokenPromise = [self.APIService appCheckTokenWithDeviceToken:deviceTokenData - limitedUse:NO]; - - // 3. Verify. - XCTAssert(FBLWaitForPromisesWithTimeout(1)); - - XCTAssertTrue(tokenPromise.isRejected); - XCTAssertNil(tokenPromise.value); - - XCTAssertNotNil(tokenPromise.error); - XCTAssertEqualObjects(tokenPromise.error.domain, GACAppCheckErrorDomain); - XCTAssertEqual(tokenPromise.error.code, GACAppCheckErrorCodeUnknown); - - // Expect response body and HTTP status code to be included in the error. - NSString *failureReason = tokenPromise.error.userInfo[NSLocalizedFailureReasonErrorKey]; - XCTAssertEqualObjects(failureReason, @"DeviceCheck token must not be empty."); - - XCTAssertNil(self.mockAPIService.passedRequestURL); -} - -#pragma mark - Helpers - -- (void)assertHTTPBody:(NSData *)body - deviceToken:(NSData *)deviceToken - limitedUse:(BOOL)limitedUse { - NSDictionary *decodedData = [NSJSONSerialization JSONObjectWithData:body - options:0 - error:nil]; - XCTAssert([decodedData isKindOfClass:[NSDictionary class]]); - - NSString *base64EncodedDeviceToken = decodedData[@"device_token"]; - XCTAssertNotNil(base64EncodedDeviceToken); - - NSNumber *decodedLimitedUse = decodedData[@"limited_use"]; - XCTAssertNotNil(decodedLimitedUse); - XCTAssertEqualObjects(decodedLimitedUse, @(limitedUse)); - - NSData *decodedToken = [[NSData alloc] initWithBase64EncodedString:base64EncodedDeviceToken - options:0]; - XCTAssertEqualObjects(decodedToken, deviceToken); -} - -@end diff --git a/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckProviderTests.m b/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckProviderTests.m deleted file mode 100644 index 538e70fd..00000000 --- a/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckProviderTests.m +++ /dev/null @@ -1,333 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "FBLPromise+Testing.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/Unit/Utils/GACDeviceCheckAPIServiceFake.h" -#import "AppCheckCore/Tests/Unit/Utils/GACDeviceCheckTokenGeneratorFake.h" -#import "AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.h" - -GAC_DEVICE_CHECK_PROVIDER_AVAILABILITY -@interface GACDeviceCheckProvider (Tests) - -- (instancetype)initWithAPIService:(id)APIService - deviceTokenGenerator:(id)deviceTokenGenerator - backoffWrapper:(id<_GACAppCheckBackoffWrapperProtocol>)backoffWrapper; - -@end - -GAC_DEVICE_CHECK_PROVIDER_AVAILABILITY -@interface GACDeviceCheckProviderTests : XCTestCase - -@property(nonatomic) GACDeviceCheckProvider *provider; -@property(nonatomic) GACDeviceCheckAPIServiceFake *fakeAPIService; -@property(nonatomic) GACDeviceCheckTokenGeneratorFake *fakeTokenGenerator; -@property(nonatomic) GACAppCheckBackoffWrapperFake *fakeBackoffWrapper; - -@end - -@implementation GACDeviceCheckProviderTests - -- (void)setUp { - [super setUp]; - - self.fakeAPIService = [[GACDeviceCheckAPIServiceFake alloc] init]; - self.fakeTokenGenerator = [[GACDeviceCheckTokenGeneratorFake alloc] init]; - - self.fakeBackoffWrapper = [[GACAppCheckBackoffWrapperFake alloc] init]; - // Don't backoff by default. - self.fakeBackoffWrapper.isNextOperationAllowed = YES; - - self.provider = [[GACDeviceCheckProvider alloc] initWithAPIService:self.fakeAPIService - deviceTokenGenerator:self.fakeTokenGenerator - backoffWrapper:self.fakeBackoffWrapper]; -} - -- (void)tearDown { - self.provider = nil; - self.fakeAPIService = nil; - self.fakeTokenGenerator = nil; - self.fakeBackoffWrapper = nil; -} - -- (void)testGetTokenSuccess { - // 1. Expect GACDeviceCheckTokenGenerator.isSupported. - self.fakeTokenGenerator.supported = YES; - - // 2. Expect device token to be generated. - NSData *deviceToken = [NSData data]; - self.fakeTokenGenerator.tokenToReturn = deviceToken; - - // 3. Expect FAA token to be requested. - GACAppCheckToken *validToken = [[GACAppCheckToken alloc] initWithToken:@"valid_token" - expirationDate:[NSDate distantFuture] - receivedAtDate:[NSDate date]]; - self.fakeAPIService.appCheckTokenPromise = [FBLPromise resolvedWith:validToken]; - - // 4. Expect backoff wrapper to be used. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 5. Call getToken and validate the result. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - XCTAssertEqualObjects(token.token, validToken.token); - XCTAssertEqualObjects(token.expirationDate, validToken.expirationDate); - XCTAssertEqualObjects(token.receivedAtDate, validToken.receivedAtDate); - XCTAssertNil(error); - }]; - - [self waitForExpectations:@[ self.fakeBackoffWrapper.backoffExpectation, completionExpectation ] - timeout:0.5 - enforceOrder:YES]; - - // 6. Verify. - XCTAssertNil(self.fakeBackoffWrapper.operationError); - GACAppCheckToken *wrapperResult = - [self.fakeBackoffWrapper.operationResult isKindOfClass:[GACAppCheckToken class]] - ? self.fakeBackoffWrapper.operationResult - : nil; - XCTAssertEqualObjects(wrapperResult.token, validToken.token); - - XCTAssertEqualObjects(self.fakeAPIService.passedDeviceToken, deviceToken); - XCTAssertEqual(self.fakeAPIService.passedLimitedUse, NO); - XCTAssertTrue(self.fakeTokenGenerator.generateTokenCalled); -} - -- (void)testGetTokenWhenDeviceCheckIsNotSupported { - NSError *expectedError = - [_GACAppCheckErrorUtil unsupportedAttestationProvider:@"DeviceCheckProvider"]; - - // 0.1. Expect backoff wrapper to be used. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 0.2. Expect default error handler to be used. - XCTestExpectation *errorHandlerExpectation = [self expectationWithDescription:@"Error handler"]; - self.fakeBackoffWrapper.defaultErrorHandler = ^GACAppCheckBackoffType(NSError *_Nonnull error) { - XCTAssertEqualObjects(error, expectedError); - [errorHandlerExpectation fulfill]; - return GACAppCheckBackoffType1Day; - }; - - // 1. Expect GACDeviceCheckTokenGenerator.isSupported. - self.fakeTokenGenerator.supported = NO; - - // 3. Call getToken and validate the result. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - XCTAssertNil(token); - XCTAssertEqualObjects(error, expectedError); - }]; - - [self waitForExpectations:@[ - self.fakeBackoffWrapper.backoffExpectation, errorHandlerExpectation, completionExpectation - ] - timeout:0.5 - enforceOrder:YES]; - - // 4. Verify. - XCTAssertNil(self.fakeAPIService.passedDeviceToken); - XCTAssertFalse(self.fakeTokenGenerator.generateTokenCalled); - - XCTAssertEqualObjects(self.fakeBackoffWrapper.operationError, expectedError); - XCTAssertNil(self.fakeBackoffWrapper.operationResult); -} - -- (void)testGetTokenWhenDeviceTokenFails { - NSError *deviceTokenError = [NSError errorWithDomain:@"GACDeviceCheckProviderTests" - code:-1 - userInfo:nil]; - - // 0.1. Expect backoff wrapper to be used. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 0.2. Expect default error handler to be used. - XCTestExpectation *errorHandlerExpectation = [self expectationWithDescription:@"Error handler"]; - self.fakeBackoffWrapper.defaultErrorHandler = ^GACAppCheckBackoffType(NSError *_Nonnull error) { - XCTAssertEqualObjects(error, deviceTokenError); - [errorHandlerExpectation fulfill]; - return GACAppCheckBackoffType1Day; - }; - - // 1. Expect GACDeviceCheckTokenGenerator.isSupported. - self.fakeTokenGenerator.supported = YES; - - // 2. Expect device token to be generated. - self.fakeTokenGenerator.errorToReturn = deviceTokenError; - - // 4. Call getToken and validate the result. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - XCTAssertNil(token); - XCTAssertEqualObjects(error, deviceTokenError); - }]; - - [self waitForExpectations:@[ - self.fakeBackoffWrapper.backoffExpectation, errorHandlerExpectation, completionExpectation - ] - timeout:0.5 - enforceOrder:YES]; - - // 5. Verify. - XCTAssertNil(self.fakeAPIService.passedDeviceToken); - XCTAssertTrue(self.fakeTokenGenerator.generateTokenCalled); - - XCTAssertEqualObjects(self.fakeBackoffWrapper.operationError, deviceTokenError); - XCTAssertNil(self.fakeBackoffWrapper.operationResult); -} - -- (void)testGetTokenWhenAPIServiceFails { - NSError *APIServiceError = [NSError errorWithDomain:@"GACDeviceCheckProviderTests" - code:-1 - userInfo:nil]; - - // 0.1. Expect backoff wrapper to be used. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 0.2. Expect default error handler to be used. - XCTestExpectation *errorHandlerExpectation = [self expectationWithDescription:@"Error handler"]; - self.fakeBackoffWrapper.defaultErrorHandler = ^GACAppCheckBackoffType(NSError *_Nonnull error) { - XCTAssertEqualObjects(error, APIServiceError); - [errorHandlerExpectation fulfill]; - return GACAppCheckBackoffType1Day; - }; - - // 1. Expect GACDeviceCheckTokenGenerator.isSupported. - self.fakeTokenGenerator.supported = YES; - - // 2. Expect device token to be generated. - NSData *deviceToken = [NSData data]; - self.fakeTokenGenerator.tokenToReturn = deviceToken; - - // 3. Expect FAA token to be requested. - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:APIServiceError]; - self.fakeAPIService.appCheckTokenPromise = rejectedPromise; - - // 4. Call getToken and validate the result. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - XCTAssertNil(token); - XCTAssertEqualObjects(error, APIServiceError); - }]; - - [self waitForExpectations:@[ - self.fakeBackoffWrapper.backoffExpectation, errorHandlerExpectation, completionExpectation - ] - timeout:0.5 - enforceOrder:YES]; - - // 5. Verify. - XCTAssertEqualObjects(self.fakeAPIService.passedDeviceToken, deviceToken); - XCTAssertEqual(self.fakeAPIService.passedLimitedUse, NO); - XCTAssertTrue(self.fakeTokenGenerator.generateTokenCalled); - - XCTAssertEqualObjects(self.fakeBackoffWrapper.operationError, APIServiceError); - XCTAssertNil(self.fakeBackoffWrapper.operationResult); -} - -- (void)testGetLimitedUseTokenSuccess { - // 1. Expect GACDeviceCheckTokenGenerator.isSupported. - self.fakeTokenGenerator.supported = YES; - - // 2. Expect device token to be generated. - NSData *deviceToken = [NSData data]; - self.fakeTokenGenerator.tokenToReturn = deviceToken; - - // 3. Expect FAA token to be requested. - GACAppCheckToken *validToken = [[GACAppCheckToken alloc] initWithToken:@"valid_token" - expirationDate:[NSDate distantFuture] - receivedAtDate:[NSDate date]]; - self.fakeAPIService.appCheckTokenPromise = [FBLPromise resolvedWith:validToken]; - - // 4. Expect backoff wrapper to be used. - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 5. Call getToken and validate the result. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider getLimitedUseTokenWithCompletion:^(GACAppCheckToken *_Nullable token, - NSError *_Nullable error) { - [completionExpectation fulfill]; - XCTAssertEqualObjects(token.token, validToken.token); - XCTAssertEqualObjects(token.expirationDate, validToken.expirationDate); - XCTAssertEqualObjects(token.receivedAtDate, validToken.receivedAtDate); - XCTAssertNil(error); - }]; - - [self waitForExpectations:@[ self.fakeBackoffWrapper.backoffExpectation, completionExpectation ] - timeout:0.5 - enforceOrder:YES]; - - // 6. Verify. - XCTAssertNil(self.fakeBackoffWrapper.operationError); - GACAppCheckToken *wrapperResult = - [self.fakeBackoffWrapper.operationResult isKindOfClass:[GACAppCheckToken class]] - ? self.fakeBackoffWrapper.operationResult - : nil; - XCTAssertEqualObjects(wrapperResult.token, validToken.token); - - XCTAssertEqualObjects(self.fakeAPIService.passedDeviceToken, deviceToken); - XCTAssertEqual(self.fakeAPIService.passedLimitedUse, YES); - XCTAssertTrue(self.fakeTokenGenerator.generateTokenCalled); -} - -#pragma mark - Backoff tests - -- (void)testGetTokenBackoff { - // 1. Configure backoff. - self.fakeBackoffWrapper.isNextOperationAllowed = NO; - self.fakeBackoffWrapper.backoffExpectation = [self expectationWithDescription:@"Backoff"]; - - // 3. Call getToken and validate the result. - XCTestExpectation *completionExpectation = - [self expectationWithDescription:@"completionExpectation"]; - [self.provider - getTokenWithCompletion:^(GACAppCheckToken *_Nullable token, NSError *_Nullable error) { - [completionExpectation fulfill]; - XCTAssertNil(token); - XCTAssertEqualObjects(error, self.fakeBackoffWrapper.backoffError); - }]; - - [self waitForExpectations:@[ self.fakeBackoffWrapper.backoffExpectation, completionExpectation ] - timeout:0.5 - enforceOrder:YES]; - - // 4. Verify. - XCTAssertNil(self.fakeAPIService.passedDeviceToken); - XCTAssertFalse(self.fakeTokenGenerator.generateTokenCalled); -} - -@end diff --git a/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift b/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift index a36fab5f..0d39813c 100644 --- a/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift +++ b/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift @@ -37,7 +37,7 @@ final class AppCheckAPITests { baseURL: nil, apiKey: apiKey, keychainAccessGroup: nil, - requestHooks: nil + requestHooks: nil as [AppCheckCoreAPIRequestHook]? ) provider.getToken { token, error in if let _ /* error */ = error { @@ -61,7 +61,7 @@ final class AppCheckAPITests { ) // Get token - appCheck.token(forcingRefresh: false) { result in + appCheck.token(forcingRefresh: false, completion: { result in if let _ /* error */ = result.error { _ /* placeholder token */ = result.token // ... @@ -69,25 +69,23 @@ final class AppCheckAPITests { _ /* token */ = result.token // ... } - } + }) // Get token (async/await) if #available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 7.0, *) { // async/await is only available on iOS 13+ Task { - let result = await appCheck.token(forcingRefresh: false) - if let _ /* error */ = result.error { - _ /* placeholder token */ = result.token - // ... - } else { - _ /* token */ = result.token - // ... + do { + let token = try await appCheck.token(forcingRefresh: false) + _ /* token */ = token.token + } catch { + _ /* error */ = error } } } // Get limited-use token - appCheck.limitedUseToken { result in + appCheck.limitedUseToken(completion: { result in if let _ /* error */ = result.error { _ /* placeholder token */ = result.token // ... @@ -95,19 +93,17 @@ final class AppCheckAPITests { _ /* token */ = result.token // ... } - } + }) // Get limited-use token (async/await) if #available(iOS 13.0, macOS 10.15, macCatalyst 13.0, tvOS 13.0, watchOS 7.0, *) { // async/await is only available on iOS 13+ Task { - let result = await appCheck.limitedUseToken() - if let _ /* error */ = result.error { - _ /* placeholder token */ = result.token - // ... - } else { - _ /* token */ = result.token - // ... + do { + let token = try await appCheck.limitedUseToken() + _ /* token */ = token.token + } catch { + _ /* error */ = error } } } @@ -121,7 +117,7 @@ final class AppCheckAPITests { resourceName: resourceName, baseURL: nil, apiKey: apiKey, - requestHooks: nil + requestHooks: nil as [AppCheckCoreAPIRequestHook]? ) // Get token debugProvider.getToken { token, error in @@ -155,7 +151,7 @@ final class AppCheckAPITests { // MARK: - AppCheckErrors - appCheck.token(forcingRefresh: false) { result in + appCheck.token(forcingRefresh: false, completion: { result in if let error = result.error { switch error { case AppCheckCoreErrorCode.unknown: @@ -173,7 +169,7 @@ final class AppCheckAPITests { } } // ... - } + }) // MARK: - AppCheckProvider @@ -192,7 +188,7 @@ final class AppCheckAPITests { serviceName: serviceName, resourceName: resourceName, apiKey: apiKey, - requestHooks: nil + requestHooks: nil as [AppCheckCoreAPIRequestHook]? ) // Get token deviceCheckProvider.getToken { token, error in @@ -223,21 +219,21 @@ final class AppCheckAPITests { // Set the log level for App Check Core AppCheckCoreLogger.logLevel = .debug - // MARK: - GACAppCheckErrors + // MARK: - AppCheckCoreErrors let code: AppCheckCoreMessageCode! = nil switch code! { - case .loggerAppCheckMessageCodeUnknown: break - case .loggerAppCheckMessageCodeProviderIsMissing: break - case .loggerAppCheckMessageCodeStagingModeEnabled: break - case .loggerAppCheckMessageCodeUnexpectedHTTPCode: break - case .loggerAppCheckMessageLocalDebugToken: break - case .loggerAppCheckMessageEnvironmentVariableDebugToken: break - case .loggerAppCheckMessageDebugProviderFirebaseEnvironmentVariable: break - case .loggerAppCheckMessageDebugProviderFailedExchange: break - case .loggerAppCheckMessageCodeAppAttestNotSupported: break - case .loggerAppCheckMessageCodeAttestationRejected: break - case .loggerAppCheckMessageCodeAssertionRejected: break + case .unknown: break + case .providerIsMissing: break + case .stagingModeEnabled: break + case .unexpectedHTTPCode: break + case .localDebugToken: break + case .environmentVariableDebugToken: break + case .debugProviderFirebaseEnvironmentVariable: break + case .debugProviderFailedExchange: break + case .appAttestNotSupported: break + case .attestationRejected: break + case .assertionRejected: break @unknown default: break } } diff --git a/AppCheckCore/Tests/Unit/Utils/AppCheckCoreFakes.swift b/AppCheckCore/Tests/Unit/Utils/AppCheckCoreFakes.swift new file mode 100644 index 00000000..c3c4f9c6 --- /dev/null +++ b/AppCheckCore/Tests/Unit/Utils/AppCheckCoreFakes.swift @@ -0,0 +1,159 @@ +// 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 +import Foundation +#if COCOAPODS + import GoogleUtilities +#else + import GoogleUtilities_Environment + import GoogleUtilities_UserDefaults +#endif + +class AppCheckCoreStorageFake: NSObject, AppCheckCoreStorageProtocol { + var getTokenHandler: (() async throws -> AppCheckCoreToken?)? + var setTokenHandler: ((AppCheckCoreToken?) async throws -> AppCheckCoreToken?)? + var lastSetToken: AppCheckCoreToken? + + func getToken() async throws -> AppCheckCoreToken? { + if let handler = getTokenHandler { + return try await handler() + } + return nil + } + + func setToken(_ token: AppCheckCoreToken?) async throws -> AppCheckCoreToken? { + lastSetToken = token + if let handler = setTokenHandler { + return try await handler(token) + } + return token + } +} + +class AppCheckCoreProviderFake: NSObject, AppCheckCoreProvider { + var tokenToReturn: AppCheckCoreToken? + var errorToReturn: Error? + var limitedUseTokenToReturn: AppCheckCoreToken? + var limitedUseErrorToReturn: Error? + var getTokenCallCount = 0 + var getLimitedUseTokenCallCount = 0 + + func getToken(completion: @escaping (AppCheckCoreToken?, Error?) -> Void) { + getTokenCallCount += 1 + completion(tokenToReturn, errorToReturn) + } + + func getLimitedUseToken(completion: @escaping (AppCheckCoreToken?, Error?) -> Void) { + getLimitedUseTokenCallCount += 1 + completion(limitedUseTokenToReturn, limitedUseErrorToReturn) + } +} + +class AppCheckCoreTokenRefresherFake: NSObject, AppCheckCoreTokenRefresherProtocol { + var updateWithRefreshResultCallCount = 0 + var tokenRefreshHandler: AppCheckCoreTokenRefreshBlock? + var lastToken: AppCheckCoreToken? + var lastUpdateStateTokenHandler: AppCheckCoreTokenRefreshBlock? + + func updateWithRefreshResult(_ refreshResult: AppCheckCoreTokenRefreshResult) { + updateWithRefreshResultCallCount += 1 + } +} + +class AppCheckCoreSettingsFake: NSObject, AppCheckCoreSettingsProtocol { + var isTokenAutoRefreshEnabled: Bool = true +} + +class AppCheckCoreTokenDelegateFake: NSObject, AppCheckCoreTokenDelegate { + var tokenDidUpdateCallCount = 0 + var lastToken: AppCheckCoreToken? + + func tokenDidUpdate(_ token: AppCheckCoreToken, serviceName: String) { + tokenDidUpdateCallCount += 1 + lastToken = token + } +} + +class AppCheckCoreFakeTimer: NSObject, AppCheckCoreTimerProtocol { + var handler: (() -> Void)? + var createHandler: ((Date) -> Void)? + var isInvalidated = false + var fireDate: Date? + + func fakeTimerProvider() -> AppCheckCoreTimerProvider { + return { fireDate, queue, handler in + self.fireDate = fireDate + self.handler = handler + self.createHandler?(fireDate) + return self + } + } + + func start() { + // do nothing + } + + func invalidate() { + isInvalidated = true + } + + func fire() { + handler?() + } +} + +class AppCheckCoreKeychainStorageFake: GULKeychainStorage { + var keychainError: Error? + var storedObject: NSSecureCoding? + + init() { + super.init(service: "test") + } + + override func getObjectForKey(_ key: String, + objectClass: AnyClass, + accessGroup: String?, + completionHandler: @escaping ((any NSSecureCoding)?, Error?) + -> Void) { + if let error = keychainError { + completionHandler(nil, error) + } else { + completionHandler(storedObject, nil) + } + } + + override func setObject(_ object: any NSSecureCoding, + forKey key: String, + accessGroup: String?, + completionHandler: @escaping ((any NSSecureCoding)?, Error?) -> Void) { + if let error = keychainError { + completionHandler(nil, error) + } else { + storedObject = object + completionHandler(object, nil) + } + } + + override func removeObject(forKey key: String, + accessGroup: String?, + completionHandler: @escaping (Error?) -> Void) { + if let error = keychainError { + completionHandler(error) + } else { + storedObject = nil + completionHandler(nil) + } + } +} diff --git a/AppCheckCore/Tests/Unit/Utils/AppCheckCoreFixtureLoader.swift b/AppCheckCore/Tests/Unit/Utils/AppCheckCoreFixtureLoader.swift new file mode 100644 index 00000000..9896d9ce --- /dev/null +++ b/AppCheckCore/Tests/Unit/Utils/AppCheckCoreFixtureLoader.swift @@ -0,0 +1,62 @@ +// 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 Foundation + +class AppCheckCoreFixtureLoader { + static func loadFixture(named fileName: String) throws -> Data { + var fileURL: URL? + + let possibleBundles = possibleResourceBundles() + for bundle in possibleBundles { + if let url = bundle.url(forResource: fileName, withExtension: nil) { + fileURL = url + print( + "Fixture named: \(fileName) was found at bundle \(bundle.bundleIdentifier ?? "unknown")" + ) + break + } + } + + guard let url = fileURL else { + print("Fixture named \(fileName) not found") + throw NSError( + domain: "AppCheckCoreFixtureLoaderError", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Fixture not found: \(fileName)"] + ) + } + + return try Data(contentsOf: url) + } + + private static func possibleResourceBundles() -> [Bundle] { + let bundleForClass = Bundle(for: self) + + // Swift Package Manager packages resources into separate bundles inside the test bundle. + var bundlesForResources: [Bundle] = [bundleForClass] + if let enclosedBundleURLs = bundleForClass.urls( + forResourcesWithExtension: "bundle", + subdirectory: nil + ) { + for bundleURL in enclosedBundleURLs { + if let bundle = Bundle(url: bundleURL) { + bundlesForResources.append(bundle) + } + } + } + + return bundlesForResources + } +} diff --git a/AppCheckCore/Tests/Unit/Utils/AppCheckCoreURLSessionFake.swift b/AppCheckCore/Tests/Unit/Utils/AppCheckCoreURLSessionFake.swift new file mode 100644 index 00000000..6338744e --- /dev/null +++ b/AppCheckCore/Tests/Unit/Utils/AppCheckCoreURLSessionFake.swift @@ -0,0 +1,112 @@ +// 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 +import Foundation + +class AppCheckCoreURLSessionFake { + var resultResponse: AppCheckCoreURLSessionDataResponse? + var resultError: Error? + var lastRequest: URLRequest? + var requestValidationBlock: ((URLRequest) -> Bool)? + var isInvoked: Bool = false + + let session: URLSession + + init() { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [URLProtocolMock.self] + session = URLSession(configuration: configuration) + + URLProtocolMock.requestHandler = { [weak self] request in + guard let self = self else { + throw NSError(domain: "AppCheckCoreURLSessionFake", code: -1, userInfo: nil) + } + + var finalRequest = request + if finalRequest.httpBody == nil, let stream = finalRequest.httpBodyStream { + let data = NSMutableData() + stream.open() + while stream.hasBytesAvailable { + var buffer = [UInt8](repeating: 0, count: 1024) + let len = stream.read(&buffer, maxLength: buffer.count) + if len > 0 { + data.append(buffer, length: len) + } else if len < 0 { + break + } + } + stream.close() + finalRequest.httpBody = data as Data + } + + self.isInvoked = true + self.lastRequest = finalRequest + + if let validationBlock = self.requestValidationBlock { + _ = validationBlock(finalRequest) + } + + if let resultError = self.resultError { + throw resultError + } + + if let resultResponse = self.resultResponse { + return (resultResponse.httpResponse, resultResponse.httpBody) + } + + throw NSError(domain: "AppCheckCoreURLSessionFake", code: -1, userInfo: nil) + } + } + + static func httpResponse(withCode statusCode: Int) -> HTTPURLResponse { + return HTTPURLResponse(url: URL(string: "https://url.com")!, + statusCode: statusCode, + httpVersion: "HTTP/1.1", + headerFields: nil)! + } +} + +class URLProtocolMock: URLProtocol { + static var requestHandler: ((URLRequest) throws -> (URLResponse, Data?))? + + override class func canInit(with request: URLRequest) -> Bool { + return true + } + + override class func canInit(with task: URLSessionTask) -> Bool { + return true + } + + override class func canonicalRequest(for request: URLRequest) -> URLRequest { + return request + } + + override func startLoading() { + if let handler = URLProtocolMock.requestHandler { + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + if let data = data { + client?.urlProtocol(self, didLoad: data) + } + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + } + + override func stopLoading() {} +} diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppAttestAPIServiceFake.h b/AppCheckCore/Tests/Unit/Utils/GACAppAttestAPIServiceFake.h deleted file mode 100644 index 87fa9e4c..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppAttestAPIServiceFake.h +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import -#import "AppCheckCore/Sources/AppAttestProvider/API/GACAppAttestAPIService.h" - -@class FBLPromise; - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppAttestAPIServiceFake : NSObject - -@property(nonatomic) NSInteger getRandomChallengeCallCount; -@property(nonatomic, nullable) FBLPromise *getRandomChallengePromise; - -@property(nonatomic) NSInteger attestKeyCallCount; -@property(nonatomic, nullable) FBLPromise *attestKeyPromise; - -@property(nonatomic) NSInteger getAppCheckTokenCallCount; -@property(nonatomic, nullable) FBLPromise *getAppCheckTokenPromise; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppAttestAPIServiceFake.m b/AppCheckCore/Tests/Unit/Utils/GACAppAttestAPIServiceFake.m deleted file mode 100644 index 37e51656..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppAttestAPIServiceFake.m +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACAppAttestAPIServiceFake.h" - -#import "FBLPromise.h" - -@implementation GACAppAttestAPIServiceFake - -@synthesize getRandomChallengeCallCount = _getRandomChallengeCallCount; -@synthesize getRandomChallengePromise = _getRandomChallengePromise; -@synthesize attestKeyCallCount = _attestKeyCallCount; -@synthesize attestKeyPromise = _attestKeyPromise; -@synthesize getAppCheckTokenCallCount = _getAppCheckTokenCallCount; -@synthesize getAppCheckTokenPromise = _getAppCheckTokenPromise; - -- (FBLPromise *)getRandomChallenge { - @synchronized(self) { - _getRandomChallengeCallCount++; - return _getRandomChallengePromise ?: [FBLPromise pendingPromise]; - } -} - -- (FBLPromise *)attestKeyWithAttestation:(NSData *)attestation - keyID:(NSString *)keyID - challenge:(NSData *)challenge - limitedUse:(BOOL)limitedUse { - @synchronized(self) { - _attestKeyCallCount++; - return _attestKeyPromise ?: [FBLPromise pendingPromise]; - } -} - -- (FBLPromise *)getAppCheckTokenWithArtifact:(NSData *)artifact - challenge:(NSData *)challenge - assertion:(NSData *)assertion - limitedUse:(BOOL)limitedUse { - @synchronized(self) { - _getAppCheckTokenCallCount++; - return _getAppCheckTokenPromise ?: [FBLPromise pendingPromise]; - } -} - -- (NSInteger)getRandomChallengeCallCount { - @synchronized(self) { - return _getRandomChallengeCallCount; - } -} - -- (void)setGetRandomChallengePromise:(nullable FBLPromise *)promise { - @synchronized(self) { - _getRandomChallengePromise = promise; - } -} - -- (NSInteger)attestKeyCallCount { - @synchronized(self) { - return _attestKeyCallCount; - } -} - -- (void)setAttestKeyPromise:(nullable FBLPromise *)promise { - @synchronized(self) { - _attestKeyPromise = promise; - } -} - -- (NSInteger)getAppCheckTokenCallCount { - @synchronized(self) { - return _getAppCheckTokenCallCount; - } -} - -- (void)setGetAppCheckTokenPromise:(nullable FBLPromise *)promise { - @synchronized(self) { - _getAppCheckTokenPromise = promise; - } -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppAttestArtifactStorageFake.h b/AppCheckCore/Tests/Unit/Utils/GACAppAttestArtifactStorageFake.h deleted file mode 100644 index 3fd0d54c..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppAttestArtifactStorageFake.h +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import -#import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestArtifactStorage.h" - -@class FBLPromise; - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppAttestArtifactStorageFake : NSObject - -@property(nonatomic) NSInteger setArtifactCallCount; -@property(nonatomic, nullable) FBLPromise *setArtifactPromise; - -@property(nonatomic) NSInteger getArtifactCallCount; -@property(nonatomic, nullable) FBLPromise *getArtifactPromise; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppAttestArtifactStorageFake.m b/AppCheckCore/Tests/Unit/Utils/GACAppAttestArtifactStorageFake.m deleted file mode 100644 index 321a7136..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppAttestArtifactStorageFake.m +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACAppAttestArtifactStorageFake.h" - -#import "FBLPromise.h" - -@implementation GACAppAttestArtifactStorageFake - -@synthesize setArtifactCallCount = _setArtifactCallCount; -@synthesize setArtifactPromise = _setArtifactPromise; -@synthesize getArtifactCallCount = _getArtifactCallCount; -@synthesize getArtifactPromise = _getArtifactPromise; - -- (FBLPromise *)setArtifact:(nullable NSData *)artifact forKey:(NSString *)keyID { - @synchronized(self) { - _setArtifactCallCount++; - return _setArtifactPromise ?: [FBLPromise pendingPromise]; - } -} - -- (FBLPromise *)getArtifactForKey:(NSString *)keyID { - @synchronized(self) { - _getArtifactCallCount++; - return _getArtifactPromise ?: [FBLPromise pendingPromise]; - } -} - -- (NSInteger)setArtifactCallCount { - @synchronized(self) { - return _setArtifactCallCount; - } -} - -- (void)setSetArtifactPromise:(nullable FBLPromise *)promise { - @synchronized(self) { - _setArtifactPromise = promise; - } -} - -- (NSInteger)getArtifactCallCount { - @synchronized(self) { - return _getArtifactCallCount; - } -} - -- (void)setGetArtifactPromise:(nullable FBLPromise *)promise { - @synchronized(self) { - _getArtifactPromise = promise; - } -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppAttestKeyIDStorageFake.h b/AppCheckCore/Tests/Unit/Utils/GACAppAttestKeyIDStorageFake.h deleted file mode 100644 index f11fe6b5..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppAttestKeyIDStorageFake.h +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import -#import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestKeyIDStorage.h" - -@class FBLPromise; - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppAttestKeyIDStorageFake : NSObject - -@property(nonatomic) NSInteger setAppAttestKeyIDCallCount; -@property(nonatomic, nullable) FBLPromise *setAppAttestKeyIDPromise; - -@property(nonatomic) NSInteger getAppAttestKeyIDCallCount; -@property(nonatomic, nullable) FBLPromise *getAppAttestKeyIDPromise; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppAttestKeyIDStorageFake.m b/AppCheckCore/Tests/Unit/Utils/GACAppAttestKeyIDStorageFake.m deleted file mode 100644 index 43c4f5f8..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppAttestKeyIDStorageFake.m +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACAppAttestKeyIDStorageFake.h" - -#import "FBLPromise.h" - -@implementation GACAppAttestKeyIDStorageFake - -@synthesize setAppAttestKeyIDCallCount = _setAppAttestKeyIDCallCount; -@synthesize setAppAttestKeyIDPromise = _setAppAttestKeyIDPromise; -@synthesize getAppAttestKeyIDCallCount = _getAppAttestKeyIDCallCount; -@synthesize getAppAttestKeyIDPromise = _getAppAttestKeyIDPromise; - -- (FBLPromise *)setAppAttestKeyID:(nullable NSString *)keyID { - @synchronized(self) { - _setAppAttestKeyIDCallCount++; - return _setAppAttestKeyIDPromise ?: [FBLPromise pendingPromise]; - } -} - -- (FBLPromise *)getAppAttestKeyID { - @synchronized(self) { - _getAppAttestKeyIDCallCount++; - return _getAppAttestKeyIDPromise ?: [FBLPromise pendingPromise]; - } -} - -- (NSInteger)setAppAttestKeyIDCallCount { - @synchronized(self) { - return _setAppAttestKeyIDCallCount; - } -} - -- (void)setSetAppAttestKeyIDPromise:(nullable FBLPromise *)promise { - @synchronized(self) { - _setAppAttestKeyIDPromise = promise; - } -} - -- (NSInteger)getAppAttestKeyIDCallCount { - @synchronized(self) { - return _getAppAttestKeyIDCallCount; - } -} - -- (void)setGetAppAttestKeyIDPromise:(nullable FBLPromise *)promise { - @synchronized(self) { - _getAppAttestKeyIDPromise = promise; - } -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppAttestServiceFake.h b/AppCheckCore/Tests/Unit/Utils/GACAppAttestServiceFake.h deleted file mode 100644 index 3327b03f..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppAttestServiceFake.h +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import -#import "AppCheckCore/Sources/AppAttestProvider/GACAppAttestService.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppAttestServiceFake : NSObject - -@property(nonatomic, assign, getter=isSupported) BOOL supported; - -@property(nonatomic) NSInteger generateKeyCallCount; -@property(nonatomic, copy, nullable) NSString *keyIdToReturn; -@property(nonatomic, nullable) NSError *generateKeyErrorToReturn; - -@property(nonatomic) NSInteger attestKeyCallCount; -@property(nonatomic, copy, nullable) NSData *attestationToReturn; -@property(nonatomic, nullable) NSError *attestKeyErrorToReturn; - -@property(nonatomic) NSInteger generateAssertionCallCount; -@property(nonatomic, copy, nullable) NSData *assertionToReturn; -@property(nonatomic, nullable) NSError *generateAssertionErrorToReturn; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppAttestServiceFake.m b/AppCheckCore/Tests/Unit/Utils/GACAppAttestServiceFake.m deleted file mode 100644 index e2a2d94c..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppAttestServiceFake.m +++ /dev/null @@ -1,204 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACAppAttestServiceFake.h" - -@implementation GACAppAttestServiceFake - -- (instancetype)init { - self = [super init]; - if (self) { - _supported = YES; - } - return self; -} - -@synthesize supported = _supported; -@synthesize generateKeyCallCount = _generateKeyCallCount; -@synthesize keyIdToReturn = _keyIdToReturn; -@synthesize generateKeyErrorToReturn = _generateKeyErrorToReturn; -@synthesize attestKeyCallCount = _attestKeyCallCount; -@synthesize attestationToReturn = _attestationToReturn; -@synthesize attestKeyErrorToReturn = _attestKeyErrorToReturn; -@synthesize generateAssertionCallCount = _generateAssertionCallCount; -@synthesize assertionToReturn = _assertionToReturn; -@synthesize generateAssertionErrorToReturn = _generateAssertionErrorToReturn; - -- (void)generateKeyWithCompletionHandler:(void (^)(NSString *keyId, - NSError *error))completionHandler { - NSString *keyId; - NSError *error; - @synchronized(self) { - _generateKeyCallCount++; - keyId = _keyIdToReturn; - error = _generateKeyErrorToReturn; - } - if (completionHandler) { - completionHandler(keyId, error); - } -} - -- (void)attestKey:(NSString *)keyId - clientDataHash:(NSData *)clientDataHash - completionHandler:(void (^)(NSData *attestationObject, NSError *error))completionHandler { - NSData *attestation; - NSError *error; - @synchronized(self) { - _attestKeyCallCount++; - attestation = _attestationToReturn; - error = _attestKeyErrorToReturn; - } - if (completionHandler) { - completionHandler(attestation, error); - } -} - -- (void)generateAssertion:(NSString *)keyId - clientDataHash:(NSData *)clientDataHash - completionHandler:(void (^)(NSData *assertionObject, NSError *error))completionHandler { - NSData *assertion; - NSError *error; - @synchronized(self) { - _generateAssertionCallCount++; - assertion = _assertionToReturn; - error = _generateAssertionErrorToReturn; - } - if (completionHandler) { - completionHandler(assertion, error); - } -} - -- (BOOL)isSupported { - @synchronized(self) { - return _supported; - } -} - -- (void)setSupported:(BOOL)supported { - @synchronized(self) { - _supported = supported; - } -} - -- (NSInteger)generateKeyCallCount { - @synchronized(self) { - return _generateKeyCallCount; - } -} - -- (void)setGenerateKeyCallCount:(NSInteger)generateKeyCallCount { - @synchronized(self) { - _generateKeyCallCount = generateKeyCallCount; - } -} - -- (NSString *)keyIdToReturn { - @synchronized(self) { - return _keyIdToReturn; - } -} - -- (void)setKeyIdToReturn:(nullable NSString *)keyIdToReturn { - @synchronized(self) { - _keyIdToReturn = keyIdToReturn; - } -} - -- (NSError *)generateKeyErrorToReturn { - @synchronized(self) { - return _generateKeyErrorToReturn; - } -} - -- (void)setGenerateKeyErrorToReturn:(nullable NSError *)generateKeyErrorToReturn { - @synchronized(self) { - _generateKeyErrorToReturn = generateKeyErrorToReturn; - } -} - -- (NSInteger)attestKeyCallCount { - @synchronized(self) { - return _attestKeyCallCount; - } -} - -- (void)setAttestKeyCallCount:(NSInteger)attestKeyCallCount { - @synchronized(self) { - _attestKeyCallCount = attestKeyCallCount; - } -} - -- (NSData *)attestationToReturn { - @synchronized(self) { - return _attestationToReturn; - } -} - -- (void)setAttestationToReturn:(nullable NSData *)attestationToReturn { - @synchronized(self) { - _attestationToReturn = attestationToReturn; - } -} - -- (NSError *)attestKeyErrorToReturn { - @synchronized(self) { - return _attestKeyErrorToReturn; - } -} - -- (void)setAttestKeyErrorToReturn:(nullable NSError *)attestKeyErrorToReturn { - @synchronized(self) { - _attestKeyErrorToReturn = attestKeyErrorToReturn; - } -} - -- (NSInteger)generateAssertionCallCount { - @synchronized(self) { - return _generateAssertionCallCount; - } -} - -- (void)setGenerateAssertionCallCount:(NSInteger)generateAssertionCallCount { - @synchronized(self) { - _generateAssertionCallCount = generateAssertionCallCount; - } -} - -- (NSData *)assertionToReturn { - @synchronized(self) { - return _assertionToReturn; - } -} - -- (void)setAssertionToReturn:(nullable NSData *)assertionToReturn { - @synchronized(self) { - _assertionToReturn = assertionToReturn; - } -} - -- (NSError *)generateAssertionErrorToReturn { - @synchronized(self) { - return _generateAssertionErrorToReturn; - } -} - -- (void)setGenerateAssertionErrorToReturn:(nullable NSError *)generateAssertionErrorToReturn { - @synchronized(self) { - _generateAssertionErrorToReturn = generateAssertionErrorToReturn; - } -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckAPIServiceFake.h b/AppCheckCore/Tests/Unit/Utils/GACAppCheckAPIServiceFake.h deleted file mode 100644 index 258891fb..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckAPIServiceFake.h +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACAppCheckAPIService.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckAPIServiceFake : NSObject <_GACAppCheckAPIServiceProtocol> - -@property(nonatomic) NSString *baseURL; - -@property(nonatomic, nullable) FBLPromise<_GACURLSessionDataResponse *> *sendRequestPromise; -@property(nonatomic, nullable) FBLPromise *appCheckTokenPromise; - -@property(nonatomic, copy, nullable) void (^requestValidationBlock)(void); - -@property(nonatomic, nullable) NSURL *passedRequestURL; -@property(nonatomic, nullable) NSString *passedHTTPMethod; -@property(nonatomic, nullable) NSData *passedBody; -@property(nonatomic, nullable) NSDictionary *passedAdditionalHeaders; - -@property(nonatomic, nullable) _GACURLSessionDataResponse *passedAPIResponse; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckAPIServiceFake.m b/AppCheckCore/Tests/Unit/Utils/GACAppCheckAPIServiceFake.m deleted file mode 100644 index 14aa0ddf..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckAPIServiceFake.m +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACAppCheckAPIServiceFake.h" -#import "FBLPromise+Testing.h" - -@implementation GACAppCheckAPIServiceFake - -@synthesize baseURL = _baseURL; -@synthesize sendRequestPromise = _sendRequestPromise; -@synthesize appCheckTokenPromise = _appCheckTokenPromise; -@synthesize requestValidationBlock = _requestValidationBlock; -@synthesize passedRequestURL = _passedRequestURL; -@synthesize passedHTTPMethod = _passedHTTPMethod; -@synthesize passedBody = _passedBody; -@synthesize passedAdditionalHeaders = _passedAdditionalHeaders; -@synthesize passedAPIResponse = _passedAPIResponse; - -- (FBLPromise<_GACURLSessionDataResponse *> *) - sendRequestWithURL:(NSURL *)requestURL - HTTPMethod:(NSString *)HTTPMethod - body:(nullable NSData *)body - additionalHeaders:(nullable NSDictionary *)additionalHeaders { - void (^validationBlock)(void); - FBLPromise *promise; - @synchronized(self) { - _passedRequestURL = requestURL; - _passedHTTPMethod = HTTPMethod; - _passedBody = body; - _passedAdditionalHeaders = additionalHeaders; - promise = _sendRequestPromise; - validationBlock = _requestValidationBlock; - } - - if (validationBlock) { - validationBlock(); - } - - if (promise) { - return promise; - } - return [FBLPromise pendingPromise]; -} - -- (FBLPromise *)appCheckTokenWithAPIResponse: - (_GACURLSessionDataResponse *)response { - FBLPromise *promise; - @synchronized(self) { - _passedAPIResponse = response; - promise = _appCheckTokenPromise; - } - - if (promise) { - return promise; - } - return [FBLPromise pendingPromise]; -} - -- (NSString *)baseURL { - @synchronized(self) { - return _baseURL; - } -} - -- (void)setBaseURL:(NSString *)baseURL { - @synchronized(self) { - _baseURL = baseURL; - } -} - -- (void)setSendRequestPromise: - (nullable FBLPromise<_GACURLSessionDataResponse *> *)sendRequestPromise { - @synchronized(self) { - _sendRequestPromise = sendRequestPromise; - } -} - -- (nullable FBLPromise<_GACURLSessionDataResponse *> *)sendRequestPromise { - @synchronized(self) { - return _sendRequestPromise; - } -} - -- (void)setAppCheckTokenPromise:(nullable FBLPromise *)appCheckTokenPromise { - @synchronized(self) { - _appCheckTokenPromise = appCheckTokenPromise; - } -} - -- (nullable FBLPromise *)appCheckTokenPromise { - @synchronized(self) { - return _appCheckTokenPromise; - } -} - -- (void)setRequestValidationBlock:(nullable void (^)(void))requestValidationBlock { - @synchronized(self) { - _requestValidationBlock = requestValidationBlock; - } -} - -- (nullable void (^)(void))requestValidationBlock { - @synchronized(self) { - return _requestValidationBlock; - } -} - -- (nullable NSURL *)passedRequestURL { - @synchronized(self) { - return _passedRequestURL; - } -} - -- (void)setPassedRequestURL:(nullable NSURL *)passedRequestURL { - @synchronized(self) { - _passedRequestURL = passedRequestURL; - } -} - -- (nullable NSString *)passedHTTPMethod { - @synchronized(self) { - return _passedHTTPMethod; - } -} - -- (void)setPassedHTTPMethod:(nullable NSString *)passedHTTPMethod { - @synchronized(self) { - _passedHTTPMethod = passedHTTPMethod; - } -} - -- (nullable NSData *)passedBody { - @synchronized(self) { - return _passedBody; - } -} - -- (void)setPassedBody:(nullable NSData *)passedBody { - @synchronized(self) { - _passedBody = passedBody; - } -} - -- (nullable NSDictionary *)passedAdditionalHeaders { - @synchronized(self) { - return _passedAdditionalHeaders; - } -} - -- (void)setPassedAdditionalHeaders: - (nullable NSDictionary *)passedAdditionalHeaders { - @synchronized(self) { - _passedAdditionalHeaders = passedAdditionalHeaders; - } -} - -- (nullable _GACURLSessionDataResponse *)passedAPIResponse { - @synchronized(self) { - return _passedAPIResponse; - } -} - -- (void)setPassedAPIResponse:(nullable _GACURLSessionDataResponse *)passedAPIResponse { - @synchronized(self) { - _passedAPIResponse = passedAPIResponse; - } -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckDebugProviderAPIServiceFake.h b/AppCheckCore/Tests/Unit/Utils/GACAppCheckDebugProviderAPIServiceFake.h deleted file mode 100644 index 74d9683f..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckDebugProviderAPIServiceFake.h +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/DebugProvider/API/GACAppCheckDebugProviderAPIService.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckDebugProviderAPIServiceFake - : NSObject - -@property(nonatomic, nullable) FBLPromise *tokenPromise; -@property(nonatomic, nullable) FBLPromise *limitedUseTokenPromise; -@property(nonatomic, nullable) NSString *passedDebugToken; -@property(nonatomic, assign) BOOL passedLimitedUse; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckDebugProviderAPIServiceFake.m b/AppCheckCore/Tests/Unit/Utils/GACAppCheckDebugProviderAPIServiceFake.m deleted file mode 100644 index 8d727b4f..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckDebugProviderAPIServiceFake.m +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACAppCheckDebugProviderAPIServiceFake.h" -#import "FBLPromise+Testing.h" - -@implementation GACAppCheckDebugProviderAPIServiceFake - -@synthesize tokenPromise = _tokenPromise; -@synthesize limitedUseTokenPromise = _limitedUseTokenPromise; -@synthesize passedDebugToken = _passedDebugToken; -@synthesize passedLimitedUse = _passedLimitedUse; - -- (FBLPromise *)appCheckTokenWithDebugToken:(NSString *)debugToken - limitedUse:(BOOL)limitedUse { - FBLPromise *promise; - @synchronized(self) { - _passedDebugToken = debugToken; - _passedLimitedUse = limitedUse; - if (limitedUse) { - promise = _limitedUseTokenPromise; - } else { - promise = _tokenPromise; - } - } - if (promise) { - return promise; - } - return [FBLPromise pendingPromise]; -} - -- (nullable FBLPromise *)tokenPromise { - @synchronized(self) { - return _tokenPromise; - } -} - -- (void)setTokenPromise:(nullable FBLPromise *)tokenPromise { - @synchronized(self) { - _tokenPromise = tokenPromise; - } -} - -- (nullable FBLPromise *)limitedUseTokenPromise { - @synchronized(self) { - return _limitedUseTokenPromise; - } -} - -- (void)setLimitedUseTokenPromise: - (nullable FBLPromise *)limitedUseTokenPromise { - @synchronized(self) { - _limitedUseTokenPromise = limitedUseTokenPromise; - } -} - -- (nullable NSString *)passedDebugToken { - @synchronized(self) { - return _passedDebugToken; - } -} - -- (void)setPassedDebugToken:(nullable NSString *)passedDebugToken { - @synchronized(self) { - _passedDebugToken = passedDebugToken; - } -} - -- (BOOL)passedLimitedUse { - @synchronized(self) { - return _passedLimitedUse; - } -} - -- (void)setPassedLimitedUse:(BOOL)passedLimitedUse { - @synchronized(self) { - _passedLimitedUse = passedLimitedUse; - } -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckProviderFake.h b/AppCheckCore/Tests/Unit/Utils/GACAppCheckProviderFake.h deleted file mode 100644 index ea98a16b..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckProviderFake.h +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckProvider.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckProviderFake : NSObject - -@property(nonatomic, strong, nullable) GACAppCheckToken *tokenToReturn; -@property(nonatomic, strong, nullable) NSError *errorToReturn; -@property(nonatomic) NSInteger getTokenCallCount; - -@property(nonatomic, strong, nullable) GACAppCheckToken *limitedUseTokenToReturn; -@property(nonatomic, strong, nullable) NSError *limitedUseErrorToReturn; -@property(nonatomic) NSInteger getLimitedUseTokenCallCount; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckProviderFake.m b/AppCheckCore/Tests/Unit/Utils/GACAppCheckProviderFake.m deleted file mode 100644 index 2f70c47c..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckProviderFake.m +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACAppCheckProviderFake.h" - -@implementation GACAppCheckProviderFake - -@synthesize tokenToReturn = _tokenToReturn; -@synthesize errorToReturn = _errorToReturn; -@synthesize getTokenCallCount = _getTokenCallCount; -@synthesize limitedUseTokenToReturn = _limitedUseTokenToReturn; -@synthesize limitedUseErrorToReturn = _limitedUseErrorToReturn; -@synthesize getLimitedUseTokenCallCount = _getLimitedUseTokenCallCount; - -- (nullable GACAppCheckToken *)tokenToReturn { - @synchronized(self) { - return _tokenToReturn; - } -} - -- (void)setTokenToReturn:(nullable GACAppCheckToken *)tokenToReturn { - @synchronized(self) { - _tokenToReturn = tokenToReturn; - } -} - -- (nullable NSError *)errorToReturn { - @synchronized(self) { - return _errorToReturn; - } -} - -- (void)setErrorToReturn:(nullable NSError *)errorToReturn { - @synchronized(self) { - _errorToReturn = errorToReturn; - } -} - -- (nullable GACAppCheckToken *)limitedUseTokenToReturn { - @synchronized(self) { - return _limitedUseTokenToReturn; - } -} - -- (void)setLimitedUseTokenToReturn:(nullable GACAppCheckToken *)limitedUseTokenToReturn { - @synchronized(self) { - _limitedUseTokenToReturn = limitedUseTokenToReturn; - } -} - -- (nullable NSError *)limitedUseErrorToReturn { - @synchronized(self) { - return _limitedUseErrorToReturn; - } -} - -- (void)setLimitedUseErrorToReturn:(nullable NSError *)limitedUseErrorToReturn { - @synchronized(self) { - _limitedUseErrorToReturn = limitedUseErrorToReturn; - } -} - -- (NSInteger)getTokenCallCount { - @synchronized(self) { - return _getTokenCallCount; - } -} - -- (void)setGetTokenCallCount:(NSInteger)getTokenCallCount { - @synchronized(self) { - _getTokenCallCount = getTokenCallCount; - } -} - -- (NSInteger)getLimitedUseTokenCallCount { - @synchronized(self) { - return _getLimitedUseTokenCallCount; - } -} - -- (void)setGetLimitedUseTokenCallCount:(NSInteger)getLimitedUseTokenCallCount { - @synchronized(self) { - _getLimitedUseTokenCallCount = getLimitedUseTokenCallCount; - } -} - -- (void)getTokenWithCompletion:(void (^)(GACAppCheckToken *_Nullable token, - NSError *_Nullable error))handler { - GACAppCheckToken *token; - NSError *error; - @synchronized(self) { - _getTokenCallCount++; - token = _tokenToReturn; - error = _errorToReturn; - } - if (handler) { - handler(token, error); - } -} - -- (void)getLimitedUseTokenWithCompletion:(void (^)(GACAppCheckToken *_Nullable token, - NSError *_Nullable error))handler { - GACAppCheckToken *token; - NSError *error; - @synchronized(self) { - _getLimitedUseTokenCallCount++; - token = _limitedUseTokenToReturn; - error = _limitedUseErrorToReturn; - } - if (handler) { - handler(token, error); - } -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckSettingsFake.h b/AppCheckCore/Tests/Unit/Utils/GACAppCheckSettingsFake.h deleted file mode 100644 index 6b461972..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckSettingsFake.h +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckSettings.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckSettingsFake : NSObject - -@property(nonatomic) BOOL isTokenAutoRefreshEnabled; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckSettingsFake.m b/AppCheckCore/Tests/Unit/Utils/GACAppCheckSettingsFake.m deleted file mode 100644 index 1c436492..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckSettingsFake.m +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACAppCheckSettingsFake.h" - -@implementation GACAppCheckSettingsFake - -@synthesize isTokenAutoRefreshEnabled = _isTokenAutoRefreshEnabled; - -- (BOOL)isTokenAutoRefreshEnabled { - @synchronized(self) { - return _isTokenAutoRefreshEnabled; - } -} - -- (void)setIsTokenAutoRefreshEnabled:(BOOL)isTokenAutoRefreshEnabled { - @synchronized(self) { - _isTokenAutoRefreshEnabled = isTokenAutoRefreshEnabled; - } -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckStorageFake.h b/AppCheckCore/Tests/Unit/Utils/GACAppCheckStorageFake.h deleted file mode 100644 index 9ad6c7ec..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckStorageFake.h +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import -#import "AppCheckCore/Sources/Core/Storage/GACAppCheckStorage.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckStorageFake : NSObject - -@property(atomic, nullable) FBLPromise *getTokenPromise; -@property(atomic, nullable) FBLPromise *setTokenPromise; -@property(atomic, nullable) GACAppCheckToken *lastSetToken; -@property(atomic, nullable) FBLPromise *removeTokenPromise; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckStorageFake.m b/AppCheckCore/Tests/Unit/Utils/GACAppCheckStorageFake.m deleted file mode 100644 index ded19c18..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckStorageFake.m +++ /dev/null @@ -1,37 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACAppCheckStorageFake.h" -#import "FBLPromise+Testing.h" - -@implementation GACAppCheckStorageFake - -- (FBLPromise *)getToken { - return self.getTokenPromise ?: [FBLPromise resolvedWith:nil]; -} - -- (FBLPromise *)setToken:(GACAppCheckToken *)token { - @synchronized(self) { - self.lastSetToken = token; - } - return self.setTokenPromise ?: [FBLPromise resolvedWith:token]; -} - -- (FBLPromise *)removeToken { - return self.removeTokenPromise ?: [FBLPromise resolvedWith:[NSNull null]]; -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenDelegateFake.h b/AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenDelegateFake.h deleted file mode 100644 index c7a3e4f4..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenDelegateFake.h +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckTokenDelegate.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckTokenDelegateFake : NSObject - -@property(nonatomic) NSInteger tokenDidUpdateCallCount; -@property(nonatomic, strong, nullable) GACAppCheckToken *lastToken; -@property(nonatomic, copy, nullable) NSString *lastServiceName; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenDelegateFake.m b/AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenDelegateFake.m deleted file mode 100644 index 24839e91..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenDelegateFake.m +++ /dev/null @@ -1,69 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenDelegateFake.h" - -@implementation GACAppCheckTokenDelegateFake - -@synthesize tokenDidUpdateCallCount = _tokenDidUpdateCallCount; -@synthesize lastToken = _lastToken; -@synthesize lastServiceName = _lastServiceName; - -- (void)tokenDidUpdate:(GACAppCheckToken *)token serviceName:(NSString *)serviceName { - @synchronized(self) { - _tokenDidUpdateCallCount++; - _lastToken = token; - _lastServiceName = serviceName; - } -} - -- (NSInteger)tokenDidUpdateCallCount { - @synchronized(self) { - return _tokenDidUpdateCallCount; - } -} - -- (void)setTokenDidUpdateCallCount:(NSInteger)tokenDidUpdateCallCount { - @synchronized(self) { - _tokenDidUpdateCallCount = tokenDidUpdateCallCount; - } -} - -- (nullable GACAppCheckToken *)lastToken { - @synchronized(self) { - return _lastToken; - } -} - -- (void)setLastToken:(nullable GACAppCheckToken *)lastToken { - @synchronized(self) { - _lastToken = lastToken; - } -} - -- (nullable NSString *)lastServiceName { - @synchronized(self) { - return _lastServiceName; - } -} - -- (void)setLastServiceName:(nullable NSString *)lastServiceName { - @synchronized(self) { - _lastServiceName = lastServiceName; - } -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenRefresherFake.h b/AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenRefresherFake.h deleted file mode 100644 index 67f451c9..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenRefresherFake.h +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import -#import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTokenRefresher.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GACAppCheckTokenRefresherFake : NSObject - -@property(nonatomic, copy, nullable) GACAppCheckTokenRefreshBlock tokenRefreshHandler; -@property(nonatomic) NSInteger updateWithRefreshResultCallCount; -@property(nonatomic, strong, nullable) GACAppCheckTokenRefreshResult *lastRefreshResult; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenRefresherFake.m b/AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenRefresherFake.m deleted file mode 100644 index fe8acfa5..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenRefresherFake.m +++ /dev/null @@ -1,68 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACAppCheckTokenRefresherFake.h" - -@implementation GACAppCheckTokenRefresherFake - -@synthesize tokenRefreshHandler = _tokenRefreshHandler; -@synthesize updateWithRefreshResultCallCount = _updateWithRefreshResultCallCount; -@synthesize lastRefreshResult = _lastRefreshResult; - -- (GACAppCheckTokenRefreshBlock)tokenRefreshHandler { - @synchronized(self) { - return _tokenRefreshHandler; - } -} - -- (void)setTokenRefreshHandler:(GACAppCheckTokenRefreshBlock)tokenRefreshHandler { - @synchronized(self) { - _tokenRefreshHandler = [tokenRefreshHandler copy]; - } -} - -- (void)updateWithRefreshResult:(GACAppCheckTokenRefreshResult *)refreshResult { - @synchronized(self) { - _updateWithRefreshResultCallCount++; - _lastRefreshResult = refreshResult; - } -} - -- (NSInteger)updateWithRefreshResultCallCount { - @synchronized(self) { - return _updateWithRefreshResultCallCount; - } -} - -- (void)setUpdateWithRefreshResultCallCount:(NSInteger)updateWithRefreshResultCallCount { - @synchronized(self) { - _updateWithRefreshResultCallCount = updateWithRefreshResultCallCount; - } -} - -- (nullable GACAppCheckTokenRefreshResult *)lastRefreshResult { - @synchronized(self) { - return _lastRefreshResult; - } -} - -- (void)setLastRefreshResult:(nullable GACAppCheckTokenRefreshResult *)lastRefreshResult { - @synchronized(self) { - _lastRefreshResult = lastRefreshResult; - } -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACDeviceCheckAPIServiceFake.h b/AppCheckCore/Tests/Unit/Utils/GACDeviceCheckAPIServiceFake.h deleted file mode 100644 index a4b968ef..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACDeviceCheckAPIServiceFake.h +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import -#import "AppCheckCore/Sources/DeviceCheckProvider/API/GACDeviceCheckAPIService.h" - -@class FBLPromise; -@class GACAppCheckToken; - -NS_ASSUME_NONNULL_BEGIN - -@interface GACDeviceCheckAPIServiceFake : NSObject - -@property(nonatomic, strong) FBLPromise *appCheckTokenPromise; -@property(nonatomic, copy, nullable) NSData *passedDeviceToken; -@property(nonatomic, assign) BOOL passedLimitedUse; - -@property(nonatomic, copy, nullable) void (^appCheckTokenWithDeviceTokenHandler) - (NSData *deviceToken, BOOL limitedUse); - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACDeviceCheckAPIServiceFake.m b/AppCheckCore/Tests/Unit/Utils/GACDeviceCheckAPIServiceFake.m deleted file mode 100644 index 2d293c40..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACDeviceCheckAPIServiceFake.m +++ /dev/null @@ -1,106 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACDeviceCheckAPIServiceFake.h" - -#import "AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckToken.h" -#import "FBLPromise+Testing.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation GACDeviceCheckAPIServiceFake - -@synthesize appCheckTokenPromise = _appCheckTokenPromise; -@synthesize passedDeviceToken = _passedDeviceToken; -@synthesize passedLimitedUse = _passedLimitedUse; -@synthesize appCheckTokenWithDeviceTokenHandler = _appCheckTokenWithDeviceTokenHandler; - -- (instancetype)init { - self = [super init]; - if (self) { - _appCheckTokenPromise = [FBLPromise pendingPromise]; - } - return self; -} - -- (FBLPromise *)appCheckTokenWithDeviceToken:(NSData *)deviceToken - limitedUse:(BOOL)limitedUse { - FBLPromise *promise; - void (^handler)(NSData *, BOOL); - @synchronized(self) { - _passedDeviceToken = deviceToken; - _passedLimitedUse = limitedUse; - promise = _appCheckTokenPromise; - handler = _appCheckTokenWithDeviceTokenHandler; - } - if (handler) { - handler(deviceToken, limitedUse); - } - return promise ?: [FBLPromise pendingPromise]; -} - -- (FBLPromise *)appCheckTokenPromise { - @synchronized(self) { - return _appCheckTokenPromise; - } -} - -- (void)setAppCheckTokenPromise:(FBLPromise *)appCheckTokenPromise { - @synchronized(self) { - _appCheckTokenPromise = appCheckTokenPromise; - } -} - -- (nullable NSData *)passedDeviceToken { - @synchronized(self) { - return _passedDeviceToken; - } -} - -- (void)setPassedDeviceToken:(nullable NSData *)passedDeviceToken { - @synchronized(self) { - _passedDeviceToken = passedDeviceToken; - } -} - -- (BOOL)passedLimitedUse { - @synchronized(self) { - return _passedLimitedUse; - } -} - -- (void)setPassedLimitedUse:(BOOL)passedLimitedUse { - @synchronized(self) { - _passedLimitedUse = passedLimitedUse; - } -} - -- (nullable void (^)(NSData *, BOOL))appCheckTokenWithDeviceTokenHandler { - @synchronized(self) { - return _appCheckTokenWithDeviceTokenHandler; - } -} - -- (void)setAppCheckTokenWithDeviceTokenHandler: - (nullable void (^)(NSData *, BOOL))appCheckTokenWithDeviceTokenHandler { - @synchronized(self) { - _appCheckTokenWithDeviceTokenHandler = appCheckTokenWithDeviceTokenHandler; - } -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACDeviceCheckTokenGeneratorFake.h b/AppCheckCore/Tests/Unit/Utils/GACDeviceCheckTokenGeneratorFake.h deleted file mode 100644 index 8cb61161..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACDeviceCheckTokenGeneratorFake.h +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import -#import "AppCheckCore/Sources/DeviceCheckProvider/GACDeviceCheckTokenGenerator.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GACDeviceCheckTokenGeneratorFake : NSObject - -@property(nonatomic, assign, getter=isSupported) BOOL supported; - -@property(nonatomic, copy, nullable) NSData *tokenToReturn; -@property(nonatomic, strong, nullable) NSError *errorToReturn; - -@property(nonatomic, assign) BOOL generateTokenCalled; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACDeviceCheckTokenGeneratorFake.m b/AppCheckCore/Tests/Unit/Utils/GACDeviceCheckTokenGeneratorFake.m deleted file mode 100644 index 0aed0f33..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACDeviceCheckTokenGeneratorFake.m +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACDeviceCheckTokenGeneratorFake.h" - -NS_ASSUME_NONNULL_BEGIN - -@implementation GACDeviceCheckTokenGeneratorFake - -@synthesize supported = _supported; -@synthesize tokenToReturn = _tokenToReturn; -@synthesize errorToReturn = _errorToReturn; -@synthesize generateTokenCalled = _generateTokenCalled; - -- (void)generateTokenWithCompletionHandler:(void (^)(NSData *_Nullable token, - NSError *_Nullable error))completion { - NSData *token; - NSError *error; - @synchronized(self) { - _generateTokenCalled = YES; - token = _tokenToReturn; - error = _errorToReturn; - } - if (completion) { - completion(token, error); - } -} - -- (BOOL)isSupported { - @synchronized(self) { - return _supported; - } -} - -- (void)setSupported:(BOOL)supported { - @synchronized(self) { - _supported = supported; - } -} - -- (nullable NSData *)tokenToReturn { - @synchronized(self) { - return _tokenToReturn; - } -} - -- (void)setTokenToReturn:(nullable NSData *)tokenToReturn { - @synchronized(self) { - _tokenToReturn = tokenToReturn; - } -} - -- (nullable NSError *)errorToReturn { - @synchronized(self) { - return _errorToReturn; - } -} - -- (void)setErrorToReturn:(nullable NSError *)errorToReturn { - @synchronized(self) { - _errorToReturn = errorToReturn; - } -} - -- (BOOL)generateTokenCalled { - @synchronized(self) { - return _generateTokenCalled; - } -} - -- (void)setGenerateTokenCalled:(BOOL)generateTokenCalled { - @synchronized(self) { - _generateTokenCalled = generateTokenCalled; - } -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACFakeTimer.h b/AppCheckCore/Tests/Unit/Utils/GACFakeTimer.h deleted file mode 100644 index 0b2a8864..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACFakeTimer.h +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Core/TokenRefresh/GACAppCheckTimer.h" - -NS_ASSUME_NONNULL_BEGIN - -typedef void (^GACFakeTimerCreateHandler)(NSDate *fireDate); - -@interface GACFakeTimer : NSObject - -- (GACTimerProvider)fakeTimerProvider; - -/// `createHandler` is called each time the timer provider returned by `fakeTimerProvider` is asked -/// to create a timer. -@property(nonatomic, copy, nullable) GACFakeTimerCreateHandler createHandler; - -@property(nonatomic, copy, nullable) dispatch_block_t invalidationHandler; - -/// The timer handler passed in the timer provider returned by `fakeTimerProvider` method. -@property(nonatomic, copy, nullable) dispatch_block_t handler; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACFakeTimer.m b/AppCheckCore/Tests/Unit/Utils/GACFakeTimer.m deleted file mode 100644 index 548bc76f..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACFakeTimer.m +++ /dev/null @@ -1,92 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACFakeTimer.h" - -@implementation GACFakeTimer - -@synthesize createHandler = _createHandler; -@synthesize invalidationHandler = _invalidationHandler; -@synthesize handler = _handler; - -- (GACTimerProvider)fakeTimerProvider { - __weak __typeof__(self) weakSelf = self; - return ^id _Nullable(NSDate *fireDate, dispatch_queue_t queue, - dispatch_block_t handler) { - __typeof__(self) strongSelf = weakSelf; - if (!strongSelf) { - return nil; - } - - @synchronized(strongSelf) { - strongSelf->_handler = handler; - void (^createHandler)(NSDate *) = strongSelf->_createHandler; - if (createHandler) { - createHandler(fireDate); - } - } - - return strongSelf; - }; -} - -- (void)invalidate { - void (^invalidationHandler)(void); - @synchronized(self) { - invalidationHandler = _invalidationHandler; - } - if (invalidationHandler) { - invalidationHandler(); - } -} - -- (nullable GACFakeTimerCreateHandler)createHandler { - @synchronized(self) { - return _createHandler; - } -} - -- (void)setCreateHandler:(nullable GACFakeTimerCreateHandler)createHandler { - @synchronized(self) { - _createHandler = createHandler; - } -} - -- (nullable dispatch_block_t)invalidationHandler { - @synchronized(self) { - return _invalidationHandler; - } -} - -- (void)setInvalidationHandler:(nullable dispatch_block_t)invalidationHandler { - @synchronized(self) { - _invalidationHandler = invalidationHandler; - } -} - -- (nullable dispatch_block_t)handler { - @synchronized(self) { - return _handler; - } -} - -- (void)setHandler:(nullable dispatch_block_t)handler { - @synchronized(self) { - _handler = handler; - } -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACFixtureLoader.m b/AppCheckCore/Tests/Unit/Utils/GACFixtureLoader.m deleted file mode 100644 index 4f1d37ca..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACFixtureLoader.m +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2021 Google LLC -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -#import "AppCheckCore/Tests/Unit/Utils/GACFixtureLoader.h" - -@implementation GACFixtureLoader - -+ (NSData *)loadFixtureNamed:(NSString *)fileName { - NSURL *fileURL; - __auto_type possibleResourceBundles = [self possibleResourceBundles]; - for (NSBundle *bundle in possibleResourceBundles) { - fileURL = [bundle URLForResource:fileName withExtension:nil]; - if (fileURL != nil) { - NSLog(@"Fixture named: %@ was found at bundle %@", fileName, [bundle bundleIdentifier]); - break; - } - } - - if (fileURL == nil) { - NSLog(@"Fixture named %@ not found", fileName); - return nil; - } - - NSError *error; - NSData *data = [NSData dataWithContentsOfURL:fileURL options:0 error:&error]; - - return data; -} - -+ (NSArray *)possibleResourceBundles { - NSBundle *bundleForClass = [NSBundle bundleForClass:[self class]]; - - // Swift Package Manager packages resources into separate bundles inside the test bundle. - NSArray *enclosedBundleURLs = [bundleForClass URLsForResourcesWithExtension:@"bundle" - subdirectory:nil]; - - NSMutableArray *bundlesForResources = [@[ bundleForClass ] mutableCopy]; - for (NSURL *bundleURL in enclosedBundleURLs) { - NSBundle *bundle = [NSBundle bundleWithURL:bundleURL]; - if (bundle) { - [bundlesForResources addObject:bundle]; - } - } - - return [bundlesForResources copy]; -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACKeychainStorageFake.h b/AppCheckCore/Tests/Unit/Utils/GACKeychainStorageFake.h deleted file mode 100644 index 362cab3a..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACKeychainStorageFake.h +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import "AppCheckCore/Sources/Core/Storage/GACKeychainStorageProtocol.h" - -NS_ASSUME_NONNULL_BEGIN - -@interface GACKeychainStorageFake : NSObject - -@property(nonatomic, readonly) NSMutableDictionary> *storage; - -/// Set this property to simulate a keychain error for all operations. -@property(nonatomic, strong, nullable) NSError *keychainError; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACKeychainStorageFake.m b/AppCheckCore/Tests/Unit/Utils/GACKeychainStorageFake.m deleted file mode 100644 index 5a3c5ba2..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACKeychainStorageFake.m +++ /dev/null @@ -1,115 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACKeychainStorageFake.h" - -@implementation GACKeychainStorageFake - -@synthesize keychainError = _keychainError; - -- (instancetype)init { - self = [super init]; - if (self) { - _storage = [[NSMutableDictionary alloc] init]; - } - return self; -} - -- (void)getObjectForKey:(NSString *)key - objectClass:(Class)objectClass - accessGroup:(nullable NSString *)accessGroup - completionHandler:(void (^)(id _Nullable, NSError *_Nullable))handler { - NSError *error; - @synchronized(self) { - error = _keychainError; - } - if (error) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - handler(nil, error); - }); - return; - } - - id object; - @synchronized(self) { - object = _storage[key]; - } - if (object && ![(id)object isKindOfClass:objectClass]) { - object = nil; - } - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - handler(object, nil); - }); -} - -- (void)setObject:(id)object - forKey:(NSString *)key - accessGroup:(nullable NSString *)accessGroup - completionHandler:(void (^)(id _Nullable, NSError *_Nullable))handler { - NSError *error; - @synchronized(self) { - error = _keychainError; - } - if (error) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - handler(nil, error); - }); - return; - } - - @synchronized(self) { - _storage[key] = object; - } - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - handler(object, nil); - }); -} - -- (void)removeObjectForKey:(NSString *)key - accessGroup:(nullable NSString *)accessGroup - completionHandler:(void (^)(NSError *_Nullable))handler { - NSError *error; - @synchronized(self) { - error = _keychainError; - } - if (error) { - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - handler(error); - }); - return; - } - - @synchronized(self) { - [_storage removeObjectForKey:key]; - } - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - handler(nil); - }); -} - -- (nullable NSError *)keychainError { - @synchronized(self) { - return _keychainError; - } -} - -- (void)setKeychainError:(nullable NSError *)keychainError { - @synchronized(self) { - _keychainError = keychainError; - } -} - -@end diff --git a/AppCheckCore/Tests/Unit/Utils/GACURLSessionFake.h b/AppCheckCore/Tests/Unit/Utils/GACURLSessionFake.h deleted file mode 100644 index 1c1d94a5..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACURLSessionFake.h +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 - -@class FBLPromise; -@class _GACURLSessionDataResponse; - -NS_ASSUME_NONNULL_BEGIN - -typedef BOOL (^FIRRequestValidationBlock)(NSURLRequest *request); - -@interface GACURLSessionFake : NSObject - -@property(nonatomic, nullable) FBLPromise<_GACURLSessionDataResponse *> *resultPromise; -@property(nonatomic, nullable) NSURLRequest *lastRequest; -@property(nonatomic, copy, nullable) FIRRequestValidationBlock requestValidationBlock; -@property(nonatomic, assign) BOOL isInvoked; - -- (FBLPromise<_GACURLSessionDataResponse *> *)gac_dataTaskPromiseWithRequest: - (NSURLRequest *)URLRequest; - -+ (NSHTTPURLResponse *)HTTPResponseWithCode:(NSInteger)statusCode; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Unit/Utils/GACURLSessionFake.m b/AppCheckCore/Tests/Unit/Utils/GACURLSessionFake.m deleted file mode 100644 index 0176207b..00000000 --- a/AppCheckCore/Tests/Unit/Utils/GACURLSessionFake.m +++ /dev/null @@ -1,102 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Unit/Utils/GACURLSessionFake.h" -#import "AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.h" -#import "FBLPromise+Testing.h" - -@implementation GACURLSessionFake - -@synthesize resultPromise = _resultPromise; -@synthesize lastRequest = _lastRequest; -@synthesize requestValidationBlock = _requestValidationBlock; -@synthesize isInvoked = _isInvoked; - -- (FBLPromise<_GACURLSessionDataResponse *> *)gac_dataTaskPromiseWithRequest: - (NSURLRequest *)URLRequest { - FIRRequestValidationBlock validationBlock; - FBLPromise *promise; - @synchronized(self) { - _isInvoked = YES; - _lastRequest = URLRequest; - validationBlock = _requestValidationBlock; - promise = _resultPromise; - } - if (validationBlock) { - validationBlock(URLRequest); - } - if (promise) { - return promise; - } - return [FBLPromise pendingPromise]; -} - -- (nullable FBLPromise<_GACURLSessionDataResponse *> *)resultPromise { - @synchronized(self) { - return _resultPromise; - } -} - -- (void)setResultPromise:(nullable FBLPromise<_GACURLSessionDataResponse *> *)resultPromise { - @synchronized(self) { - _resultPromise = resultPromise; - } -} - -- (nullable NSURLRequest *)lastRequest { - @synchronized(self) { - return _lastRequest; - } -} - -- (void)setLastRequest:(nullable NSURLRequest *)lastRequest { - @synchronized(self) { - _lastRequest = lastRequest; - } -} - -- (nullable FIRRequestValidationBlock)requestValidationBlock { - @synchronized(self) { - return _requestValidationBlock; - } -} - -- (void)setRequestValidationBlock:(nullable FIRRequestValidationBlock)requestValidationBlock { - @synchronized(self) { - _requestValidationBlock = requestValidationBlock; - } -} - -- (BOOL)isInvoked { - @synchronized(self) { - return _isInvoked; - } -} - -- (void)setIsInvoked:(BOOL)isInvoked { - @synchronized(self) { - _isInvoked = isInvoked; - } -} - -+ (NSHTTPURLResponse *)HTTPResponseWithCode:(NSInteger)statusCode { - return [[NSHTTPURLResponse alloc] initWithURL:[NSURL URLWithString:@"https://url.com"] - statusCode:statusCode - HTTPVersion:@"HTTP/1.1" - headerFields:nil]; -} - -@end diff --git a/AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.h b/AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.h deleted file mode 100644 index 069a3876..00000000 --- a/AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.h +++ /dev/null @@ -1,54 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import - -#import - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -#import - -NS_ASSUME_NONNULL_BEGIN - -@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. -@property(nonatomic) BOOL isNextOperationAllowed; - -/// Result of the last performed operation if it succeeded. -@property(nonatomic, nullable, readonly) id operationResult; - -/// Error of the last performed operation if it failed. -@property(nonatomic, nullable, readonly) NSError *operationError; - -/// Default error handler. -@property(nonatomic, copy) GACAppCheckBackoffErrorHandler defaultErrorHandler; - -/// Assign expectation to fulfill on `[backoff:errorHandler:]` method call to this property. -@property(nonatomic, nullable) XCTestExpectation *backoffExpectation; - -/// Error returned when retry is not allowed. -@property(nonatomic, readonly) NSError *backoffError; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.m b/AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.m deleted file mode 100644 index ffbc3ad3..00000000 --- a/AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.m +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2021 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Utils/AppCheckBackoffWrapperFake/GACAppCheckBackoffWrapperFake.h" - -#if __has_include() -#import -#else -#import "FBLPromises.h" -#endif - -NS_ASSUME_NONNULL_BEGIN - -@implementation GACAppCheckBackoffWrapperFake - -- (FBLPromise *)applyBackoffToOperation:(GACAppCheckBackoffOperationProvider)operationProvider - errorHandler:(GACAppCheckBackoffErrorHandler)errorHandler { - [self.backoffExpectation fulfill]; - - if (self.isNextOperationAllowed) { - return operationProvider() - .then(^id(id value) { - self->_operationResult = value; - self->_operationError = nil; - return value; - }) - .recover(^id(NSError *error) { - self->_operationError = error; - self->_operationResult = nil; - - errorHandler(error); - - return error; - }); - } else { - FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; - [rejectedPromise reject:self.backoffError]; - return rejectedPromise; - } -} - -- (GACAppCheckBackoffErrorHandler)defaultAppCheckProviderErrorHandler { - if (_defaultErrorHandler) { - return _defaultErrorHandler; - } - - return ^GACAppCheckBackoffType(NSError *error) { - return GACAppCheckBackoffTypeNone; - }; -} - -- (NSError *)backoffError { - return [NSError errorWithDomain:@"GACAppCheckBackoffWrapperFake.backoff" code:-1 userInfo:nil]; -} - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Utils/Date/GACDateTestUtils.h b/AppCheckCore/Tests/Utils/Date/GACDateTestUtils.h deleted file mode 100644 index 8f65a76e..00000000 --- a/AppCheckCore/Tests/Utils/Date/GACDateTestUtils.h +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2020 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 - -NS_ASSUME_NONNULL_BEGIN - -@interface GACDateTestUtils : NSObject - -+ (BOOL)isDate:(NSDate *)date - approximatelyEqualCurrentPlusTimeInterval:(NSTimeInterval)timeInterval - precision:(NSTimeInterval)precision; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Utils/Date/GACDateTestUtils.m b/AppCheckCore/Tests/Utils/Date/GACDateTestUtils.m deleted file mode 100644 index dbfc417e..00000000 --- a/AppCheckCore/Tests/Utils/Date/GACDateTestUtils.m +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#import "AppCheckCore/Tests/Utils/Date/GACDateTestUtils.h" - -#import - -@implementation GACDateTestUtils - -+ (BOOL)isDate:(NSDate *)date - approximatelyEqualCurrentPlusTimeInterval:(NSTimeInterval)timeInterval - precision:(NSTimeInterval)precision { - NSDate *expectedDate = [NSDate dateWithTimeIntervalSinceNow:timeInterval]; - return ABS([date timeIntervalSinceDate:expectedDate]) <= precision; -} - -@end diff --git a/AppCheckRecaptchaProvider/Sources/Public/AppCheckRecaptchaProvider.swift b/AppCheckRecaptchaProvider/Sources/Public/AppCheckRecaptchaProvider.swift index 0ccc5f27..f4c6d52a 100644 --- a/AppCheckRecaptchaProvider/Sources/Public/AppCheckRecaptchaProvider.swift +++ b/AppCheckRecaptchaProvider/Sources/Public/AppCheckRecaptchaProvider.swift @@ -16,7 +16,6 @@ import AppCheckCore #endif import Foundation -import Promises import RecaptchaInterop /// Firebase App Check provider that verifies app integrity using the @@ -71,7 +70,7 @@ public final class AppCheckRecaptchaProvider: NSObject, AppCheckCoreProvider { return nil } - let backoffWrapper = _GACAppCheckBackoffWrapper() + let backoffWrapper = AppCheckCoreBackoffWrapper() let tokenGenerator = RecaptchaTokenGenerator( siteKey: siteKey, recaptchaAction: sdk.action, @@ -80,8 +79,8 @@ public final class AppCheckRecaptchaProvider: NSObject, AppCheckCoreProvider { ) let urlSession = URLSession(configuration: .ephemeral) - let appCheckAPIService = _GACAppCheckAPIService(urlSession: urlSession, - baseURL: nil, + let appCheckAPIService = AppCheckCoreAPIService(urlSession: urlSession, + baseURL: nil as String?, apiKey: APIKey, requestHooks: requestHooks) let apiService = RecaptchaAPIService( @@ -99,38 +98,48 @@ public final class AppCheckRecaptchaProvider: NSObject, AppCheckCoreProvider { super.init() } + public func getToken() async throws -> AppCheckCoreToken { + return try await getToken(limitedUse: false) + } + + public func getLimitedUseToken() async throws -> AppCheckCoreToken { + return try await getToken(limitedUse: true) + } + @objc(getTokenWithCompletion:) public func getToken(completion handler: @escaping (AppCheckCoreToken?, (any Error)?) -> Void) { - getToken(limitedUse: false) - .then { token in + Task { + do { + let token = try await getToken(limitedUse: false) handler(token, nil) - }.catch { error in + } catch { handler(nil, error) } + } } @objc(getLimitedUseTokenWithCompletion:) public func getLimitedUseToken(completion handler: @escaping (AppCheckCoreToken?, (any Error)?) -> Void) { - getToken(limitedUse: true) - .then { token in + Task { + do { + let token = try await getToken(limitedUse: true) handler(token, nil) - }.catch { error in + } catch { handler(nil, error) } + } } - private func getToken(limitedUse: Bool) -> Promise { + private func getToken(limitedUse: Bool) async throws -> AppCheckCoreToken { guard let tokenGenerator else { - return Promise(_GACAppCheckErrorUtil.missingRecaptchaSDKError()) + throw AppCheckCoreErrorUtil.missingRecaptchaSDKError() } - return tokenGenerator.getRecaptchaToken() - .then { recaptchaToken in - self.apiService.appCheckToken( - with: recaptchaToken, - limitedUse: limitedUse - ) - } + let recaptchaToken = try await tokenGenerator.getRecaptchaToken() + return try await apiService.appCheckToken( + with: recaptchaToken, + limitedUse: limitedUse + ) } } diff --git a/AppCheckRecaptchaProvider/Sources/RecaptchaAPIService.swift b/AppCheckRecaptchaProvider/Sources/RecaptchaAPIService.swift index 1e5d501a..9a86e988 100644 --- a/AppCheckRecaptchaProvider/Sources/RecaptchaAPIService.swift +++ b/AppCheckRecaptchaProvider/Sources/RecaptchaAPIService.swift @@ -16,7 +16,6 @@ import AppCheckCore #endif import Foundation -import Promises private enum Constants { static let contentTypeKey = "Content-Type" @@ -34,45 +33,36 @@ private enum Constants { @available(tvOS, unavailable) @available(watchOS, unavailable) final class RecaptchaAPIService: NSObject { - private let apiService: _GACAppCheckAPIServiceProtocol + private let apiService: AppCheckCoreAPIServiceProtocol private let resourceName: String - init(apiService: _GACAppCheckAPIServiceProtocol, resourceName: String) { + init(apiService: AppCheckCoreAPIServiceProtocol, resourceName: String) { self.apiService = apiService self.resourceName = resourceName } func appCheckToken(with recaptchaToken: String, - limitedUse: Bool) -> Promise { - let urlString = "\(apiService.baseURL)/\(resourceName):\(Constants.exchangeEndpoint)" + limitedUse: Bool) async throws -> AppCheckCoreToken { + let urlString = apiService.baseURL + "/" + resourceName + ":" + Constants.exchangeEndpoint guard let url = URL(string: urlString) else { - return Promise(_GACAppCheckErrorUtil - .error(withFailureReason: "Invalid URL string: \(urlString)")) + throw AppCheckCoreErrorUtil.error(withFailureReason: "Invalid URL string: \(urlString)") } - let httpBody: Data - do { - httpBody = try self.httpBody(with: recaptchaToken, limitedUse: limitedUse) - } catch { - return Promise(error) - } + let httpBody = try self.httpBody(with: recaptchaToken, limitedUse: limitedUse) + + let response = try await apiService.sendRequest(withURL: url, + httpMethod: Constants.httpMethodPost, + body: httpBody, + additionalHeaders: [Constants + .contentTypeKey: Constants.jsonContentType]) - 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)) - } + return try await 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") + throw AppCheckCoreErrorUtil.error(withFailureReason: "Recaptcha token cannot be empty") } let payload: [String: Any] = [ @@ -83,7 +73,7 @@ final class RecaptchaAPIService: NSObject { do { return try JSONSerialization.data(withJSONObject: payload, options: []) } catch { - throw _GACAppCheckErrorUtil.jsonSerializationError(error) + throw AppCheckCoreErrorUtil.jsonSerializationError(error) } } } diff --git a/AppCheckRecaptchaProvider/Sources/RecaptchaTokenGenerator.swift b/AppCheckRecaptchaProvider/Sources/RecaptchaTokenGenerator.swift index 9783157a..8560ff69 100644 --- a/AppCheckRecaptchaProvider/Sources/RecaptchaTokenGenerator.swift +++ b/AppCheckRecaptchaProvider/Sources/RecaptchaTokenGenerator.swift @@ -15,9 +15,7 @@ #if SWIFT_PACKAGE import AppCheckCore #endif -import FBLPromises import Foundation -import Promises import RecaptchaInterop @available(iOS 15.0, visionOS 1.0, *) @@ -26,94 +24,83 @@ import RecaptchaInterop @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 recaptchaClientTask: Task - private let backoffWrapper: _GACAppCheckBackoffWrapperProtocol + private let backoffWrapper: AppCheckBackoffWrapperProtocol init(siteKey: String, recaptchaAction: RCAActionProtocol, recaptchaClass: RCARecaptchaProtocol.Type, - backoffWrapper: _GACAppCheckBackoffWrapperProtocol) { + backoffWrapper: AppCheckBackoffWrapperProtocol) { 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")) + + recaptchaClientTask = Task { + try await withCheckedThrowingContinuation { continuation in + recaptchaClass.fetchClient(withSiteKey: siteKey) { client, error in + if let client { + continuation.resume(returning: client) + } else { + continuation.resume(throwing: error ?? AppCheckCoreErrorUtil + .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)) - } + func getRecaptchaToken() async throws -> String { + let client = try await recaptchaClientTask.value + + let operationProvider: () async throws -> Any = { + try await withCheckedThrowingContinuation { continuation in + let recaptchaAction = self.recaptchaAction + client.execute(withAction: recaptchaAction) { token, error in + if let token { + continuation.resume(returning: token as Any) + } else { + continuation.resume(throwing: 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 errorHandler: (Error) -> AppCheckBackoffType = { error in + let nsError = error as NSError + if nsError.domain == AppCheckCoreErrorDomain && nsError.code == AppCheckCoreErrorCode + .serverUnreachable.rawValue { + return .exponential } + return .none + } - let fblPromise = self.backoffWrapper.applyBackoff( - toOperation: operationProvider, - errorHandler: errorHandler - ) + let result = try await backoffWrapper.applyBackoffToOperation( + 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 - } + guard let token = result as? String else { + throw AppCheckCoreErrorUtil + .error( + withFailureReason: "Unexpected result type from reCAPTCHA token exchange: \\(type(of: result)). Expected String." + ) } + return token } - private func mapRecaptchaError(_ error: Error?) -> Error { + private static func mapRecaptchaError(_ error: Error?) -> Error { guard let error = error as NSError? else { - return _GACAppCheckErrorUtil.error(withFailureReason: "Failed to execute Recaptcha action") + return AppCheckCoreErrorUtil.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) + return AppCheckCoreErrorUtil.apiError(withNetworkError: error) } - // Preserve underlying error for others var userInfo: [String: Any] = [NSUnderlyingErrorKey: error] if let reason = error.userInfo[NSLocalizedFailureReasonErrorKey] { userInfo[NSLocalizedFailureReasonErrorKey] = reason diff --git a/AppCheckRecaptchaProvider/Tests/AppCheckRecaptchaProviderTests.swift b/AppCheckRecaptchaProvider/Tests/AppCheckRecaptchaProviderTests.swift index 636f1182..57f8b7ea 100644 --- a/AppCheckRecaptchaProvider/Tests/AppCheckRecaptchaProviderTests.swift +++ b/AppCheckRecaptchaProvider/Tests/AppCheckRecaptchaProviderTests.swift @@ -16,7 +16,6 @@ import XCTest @testable import AppCheckCore @testable import AppCheckRecaptchaProvider -import Promises @available(iOS 15.0, visionOS 1.0, *) @available(macOS, unavailable) @@ -50,49 +49,37 @@ final class AppCheckRecaptchaProviderTests: XCTestCase { XCTAssertFalse(AppCheckRecaptchaProvider.isSupported()) } - func testGetTokenWithoutRecaptchaSDK() { + func testGetTokenWithoutRecaptchaSDK() async { // 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) + do { + let _ = try await provider.getToken() + XCTFail("Expected getToken to fail without SDK") + } catch { + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain) + XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.unsupported.rawValue) XCTAssertEqual( - nsError?.localizedFailureReason, + nsError.localizedFailureReason, "The reCAPTCHA Enterprise SDK is not linked. See https://firebase.google.com/docs/app-check/ios/recaptcha-enterprise-provider#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) + func testGetLimitedUseTokenWithoutRecaptchaSDK() async { + do { + let _ = try await provider.getLimitedUseToken() + XCTFail("Expected getLimitedUseToken to fail without SDK") + } catch { + let nsError = error as NSError + XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain) + XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.unsupported.rawValue) XCTAssertEqual( - nsError?.localizedFailureReason, + nsError.localizedFailureReason, "The reCAPTCHA Enterprise SDK is not linked. See https://firebase.google.com/docs/app-check/ios/recaptcha-enterprise-provider#prepare-environment" ) - - expectation.fulfill() } - - waitForExpectations(timeout: 1.0) } func testInitReturnsNilWithoutRecaptchaSDK() { @@ -144,7 +131,7 @@ final class AppCheckRecaptchaProviderTests: XCTestCase { ) } - func testGetTokenSuccess() { + func testGetTokenSuccess() async throws { // Arrange let expectedAppCheckToken = AppCheckCoreToken( token: "app-check-token-456", @@ -152,21 +139,15 @@ final class AppCheckRecaptchaProviderTests: XCTestCase { ) 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() - } + let token = try await providerWithMocks.getToken() - waitForExpectations(timeout: 1.0) + // Assert + XCTAssertNotNil(token) + XCTAssertEqual(token.token, expectedAppCheckToken.token) } - func testGetLimitedUseTokenSuccess() { + func testGetLimitedUseTokenSuccess() async throws { // Arrange let expectedAppCheckToken = AppCheckCoreToken( token: "app-check-token-456", @@ -174,17 +155,11 @@ final class AppCheckRecaptchaProviderTests: XCTestCase { ) 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() - } + let token = try await providerWithMocks.getLimitedUseToken() - waitForExpectations(timeout: 1.0) + // Assert + XCTAssertNotNil(token) + XCTAssertEqual(token.token, expectedAppCheckToken.token) } } diff --git a/AppCheckRecaptchaProvider/Tests/MockRecaptchaSupport.swift b/AppCheckRecaptchaProvider/Tests/MockRecaptchaSupport.swift index e62162a6..7ecd13e8 100644 --- a/AppCheckRecaptchaProvider/Tests/MockRecaptchaSupport.swift +++ b/AppCheckRecaptchaProvider/Tests/MockRecaptchaSupport.swift @@ -14,9 +14,7 @@ @testable import AppCheckCore @testable import AppCheckRecaptchaProvider -import FBLPromises import Foundation -import Promises import RecaptchaInterop class MockRCAAction: NSObject, RCAActionProtocol { @@ -79,7 +77,7 @@ final class MockRecaptchaClient: NSObject, RCARecaptchaClientProtocol { } } -class MockAppCheckCoreAPIService: NSObject, _GACAppCheckAPIServiceProtocol { +class MockAppCheckCoreAPIService: NSObject, AppCheckCoreAPIServiceProtocol { var baseURL: String = "https://test.com" struct RequestData { @@ -90,12 +88,13 @@ class MockAppCheckCoreAPIService: NSObject, _GACAppCheckAPIServiceProtocol { } var lastRequest: RequestData? - var expectedResponse: _GACURLSessionDataResponse? + var expectedResponse: AppCheckCoreURLSessionDataResponse? var expectedToken: AppCheckCoreToken? var expectedError: Error? - func sendRequest(with url: URL, httpMethod: String, body: Data?, - additionalHeaders: [String: String]?) -> FBLPromise<_GACURLSessionDataResponse> { + func sendRequest(withURL url: URL, httpMethod: String, body: Data?, + additionalHeaders: [String: String]?) async throws + -> AppCheckCoreURLSessionDataResponse { lastRequest = RequestData( url: url, httpMethod: httpMethod, @@ -103,64 +102,54 @@ class MockAppCheckCoreAPIService: NSObject, _GACAppCheckAPIServiceProtocol { additionalHeaders: additionalHeaders ) - let promise = Promise<_GACURLSessionDataResponse>.pending() - if let expectedError { - promise.reject(expectedError) + throw expectedError } else { - let response = expectedResponse ?? _GACURLSessionDataResponse( + let response = expectedResponse ?? AppCheckCoreURLSessionDataResponse( response: HTTPURLResponse(), httpBody: Data() ) - promise.fulfill(response) + return response } - - return promise.asObjCPromise() } - func appCheckToken(withAPIResponse response: _GACURLSessionDataResponse) - -> FBLPromise { - let promise = Promise.pending() - + func appCheckToken(withAPIResponse response: AppCheckCoreURLSessionDataResponse) async throws + -> AppCheckCoreToken { if let expectedError { - promise.reject(expectedError) + throw expectedError } else { let token = expectedToken ?? AppCheckCoreToken( token: "placeholder_app_check_token", expirationDate: Date() ) - promise.fulfill(token) + return token } - - return promise.asObjCPromise() } } -class MockBackoffWrapper: NSObject, _GACAppCheckBackoffWrapperProtocol { +class MockBackoffWrapper: NSObject, AppCheckBackoffWrapperProtocol { var applyBackoffCalled = false var shouldReturnError = false var mockError: NSError? var mockResult: Any? - var capturedErrorHandler: GACAppCheckBackoffErrorHandler? + var capturedErrorHandler: ((Error) -> AppCheckBackoffType)? - func applyBackoff(toOperation operationProvider: @escaping GACAppCheckBackoffOperationProvider, - errorHandler: @escaping GACAppCheckBackoffErrorHandler) - -> FBLPromise { + func applyBackoffToOperation(_ operationProvider: @escaping () async throws -> Any, + errorHandler: @escaping (Error) -> AppCheckBackoffType) async throws + -> Any { 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() + throw error } if let mockResult { - let swiftPromise = Promise(mockResult as AnyObject) - return swiftPromise.asObjCPromise() + return mockResult } - return operationProvider() + return try await operationProvider() } - func defaultAppCheckProviderErrorHandler() -> GACAppCheckBackoffErrorHandler { - return { error in .typeExponential } + func defaultAppCheckProviderErrorHandler() -> (Error) -> AppCheckBackoffType { + return { error in .exponential } } } diff --git a/AppCheckRecaptchaProvider/Tests/RecaptchaAPIServiceTests.swift b/AppCheckRecaptchaProvider/Tests/RecaptchaAPIServiceTests.swift index 30da1982..91bda0e7 100644 --- a/AppCheckRecaptchaProvider/Tests/RecaptchaAPIServiceTests.swift +++ b/AppCheckRecaptchaProvider/Tests/RecaptchaAPIServiceTests.swift @@ -16,7 +16,6 @@ import XCTest @testable import AppCheckCore @testable import AppCheckRecaptchaProvider -import FBLPromises @available(iOS 15.0, visionOS 1.0, *) @available(macOS, unavailable) @@ -44,7 +43,7 @@ final class RecaptchaAPIServiceTests: XCTestCase { super.tearDown() } - func testAppCheckTokenSuccess() throws { + func testAppCheckTokenSuccess() async throws { // Arrange let expectedAppCheckToken = AppCheckCoreToken( token: "app-check-token-456", @@ -52,45 +51,39 @@ final class RecaptchaAPIServiceTests: XCTestCase { ) 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)") + do { + let token = try await apiService.appCheckToken(with: testRecaptchaToken, limitedUse: false) + // Assert + XCTAssertEqual(token.token, expectedAppCheckToken.token) + XCTAssertEqual(token.expirationDate, expectedAppCheckToken.expirationDate) + + // Verify request + guard let request = mockCoreAPIService.lastRequest else { + XCTFail("No request was sent") + return } - waitForExpectations(timeout: 1.0) + XCTAssertEqual( + request.url?.absoluteString, + "https://test.com/\(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, testRecaptchaToken) + XCTAssertEqual(json?["limited_use"] as? Bool, false) + } else { + XCTFail("Request body was empty") + } + } catch { + XCTFail("Unexpected error: \(error)") + } } - func testAppCheckTokenLimitedUseSuccess() throws { + func testAppCheckTokenLimitedUseSuccess() async throws { // Arrange let expectedAppCheckToken = AppCheckCoreToken( token: "app-check-token-456", @@ -98,62 +91,47 @@ final class RecaptchaAPIServiceTests: XCTestCase { ) 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)") + do { + let _ = try await apiService.appCheckToken(with: testRecaptchaToken, limitedUse: true) + // Assert + guard let request = mockCoreAPIService.lastRequest, let body = request.body else { + XCTFail("No request or body") + return } - waitForExpectations(timeout: 1.0) + let json = try? JSONSerialization.jsonObject(with: body, options: []) as? [String: Any] + XCTAssertEqual(json?["limited_use"] as? Bool, true) + } catch { + XCTFail("Unexpected error: \(error)") + } } - func testAppCheckTokenEmptyRecaptchaToken() { - let expectation = self.expectation(description: "Token exchange fails with empty token") - - apiService.appCheckToken(with: "", limitedUse: false).then { token in + func testAppCheckTokenEmptyRecaptchaToken() async { + do { + let _ = try await apiService.appCheckToken(with: "", limitedUse: false) XCTFail("Should not succeed with empty token") - }.catch { error in + } catch { XCTAssertNotNil(error) XCTAssertEqual((error as NSError).domain, AppCheckCoreErrorDomain) - expectation.fulfill() } - - waitForExpectations(timeout: 1.0) } - func testAppCheckTokenInvalidURL() { + func testAppCheckTokenInvalidURL() async { mockCoreAPIService.baseURL = "not a scheme://test.com" let apiService = RecaptchaAPIService( apiService: mockCoreAPIService, resourceName: "invalid_resource_name" ) - let expectation = self.expectation(description: "Token exchange fails with invalid URL") - - apiService.appCheckToken(with: testRecaptchaToken, limitedUse: false).then { token in + do { + let _ = try await apiService.appCheckToken(with: testRecaptchaToken, limitedUse: false) XCTFail("Should not succeed with invalid URL") - }.catch { error in + } catch { XCTAssertEqual((error as NSError).domain, AppCheckCoreErrorDomain) let expectedFailureReason = "Invalid URL string: not a scheme://test.com/invalid_resource_name:exchangeRecaptchaEnterpriseToken" XCTAssertEqual((error as NSError).localizedFailureReason, expectedFailureReason) - expectation.fulfill() } - - waitForExpectations(timeout: 1.0) } } diff --git a/AppCheckRecaptchaProvider/Tests/RecaptchaTokenGeneratorTests.swift b/AppCheckRecaptchaProvider/Tests/RecaptchaTokenGeneratorTests.swift index 2363dccb..eaca5f81 100644 --- a/AppCheckRecaptchaProvider/Tests/RecaptchaTokenGeneratorTests.swift +++ b/AppCheckRecaptchaProvider/Tests/RecaptchaTokenGeneratorTests.swift @@ -16,8 +16,6 @@ import XCTest @testable import AppCheckCore @testable import AppCheckRecaptchaProvider -import FBLPromises -import Promises import RecaptchaInterop @available(iOS 15.0, visionOS 1.0, *) @@ -36,7 +34,7 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { MockRecaptcha.mockError = nil } - func testGetRecaptchaTokenSuccess() { + func testGetRecaptchaTokenSuccess() async throws { // Arrange let mockClient = MockRecaptchaClient() mockClient.mockToken = "valid-recaptcha-token" @@ -49,21 +47,18 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { backoffWrapper: MockBackoffWrapper() ) - let expectation = self.expectation(description: "Generates token successfully") - // Act - generator.getRecaptchaToken().then { token in + do { + let token = try await generator.getRecaptchaToken() // Assert XCTAssertEqual(token, "valid-recaptcha-token") - expectation.fulfill() - }.catch { error in + + } catch { XCTFail("Unexpected error: \(error)") } - - waitForExpectations(timeout: 1.0) } - func testGetRecaptchaTokenFetchClientFailure() { + func testGetRecaptchaTokenFetchClientFailure() async throws { // Arrange let expectedError = NSError(domain: "test", code: -1, userInfo: nil) MockRecaptcha.mockError = expectedError @@ -75,22 +70,18 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { backoffWrapper: MockBackoffWrapper() ) - let expectation = self.expectation(description: "Fails when fetchClient fails") - // Act - generator.getRecaptchaToken().then { token in + do { + let _ = try await generator.getRecaptchaToken() XCTFail("Should not succeed when fetchClient fails") - }.catch { error in + } catch { // Assert XCTAssertEqual((error as NSError).domain, expectedError.domain) XCTAssertEqual((error as NSError).code, expectedError.code) - expectation.fulfill() } - - waitForExpectations(timeout: 1.0) } - func testGetRecaptchaTokenExecutionFailure() { + func testGetRecaptchaTokenExecutionFailure() async throws { // Arrange let mockClient = MockRecaptchaClient() let expectedError = NSError(domain: "test", code: -2, userInfo: nil) @@ -104,12 +95,11 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { backoffWrapper: MockBackoffWrapper() ) - let expectation = self.expectation(description: "Fails when execute fails") - // Act - generator.getRecaptchaToken().then { token in + do { + let _ = try await generator.getRecaptchaToken() XCTFail("Should not succeed when execute fails") - }.catch { error in + } catch { // Assert let nsError = error as NSError XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain) @@ -119,13 +109,10 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { XCTAssertNotNil(underlyingError) XCTAssertEqual(underlyingError?.domain, expectedError.domain) XCTAssertEqual(underlyingError?.code, expectedError.code) - expectation.fulfill() } - - waitForExpectations(timeout: 1.0) } - func testGetRecaptchaTokenCallsBackoffWrapper() { + func testGetRecaptchaTokenCallsBackoffWrapper() async throws { // Arrange let mockClient = MockRecaptchaClient() mockClient.mockToken = "valid-recaptcha-token" @@ -140,21 +127,18 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { backoffWrapper: mockBackoffWrapper ) - let expectation = self.expectation(description: "Calls backoff wrapper") - // Act - generator.getRecaptchaToken().then { token in + do { + let _ = try await generator.getRecaptchaToken() // Assert XCTAssertTrue(mockBackoffWrapper.applyBackoffCalled) - expectation.fulfill() - }.catch { error in + + } catch { XCTFail("Unexpected error: \(error)") } - - waitForExpectations(timeout: 1.0) } - func testGetRecaptchaTokenBackoffWrapperError() { + func testGetRecaptchaTokenBackoffWrapperError() async throws { // Arrange let mockClient = MockRecaptchaClient() MockRecaptcha.mockClient = mockClient @@ -171,22 +155,18 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { backoffWrapper: mockBackoffWrapper ) - let expectation = self.expectation(description: "Fails when backoff wrapper fails") - // Act - generator.getRecaptchaToken().then { token in + do { + let _ = try await generator.getRecaptchaToken() XCTFail("Should not succeed when backoff wrapper fails") - }.catch { error in + } catch { // Assert XCTAssertEqual((error as NSError).domain, expectedError.domain) XCTAssertEqual((error as NSError).code, expectedError.code) - expectation.fulfill() } - - waitForExpectations(timeout: 1.0) } - func testGetRecaptchaTokenMapsNetworkErrorToServerUnreachable() { + func testGetRecaptchaTokenMapsNetworkErrorToServerUnreachable() async throws { // Arrange let mockClient = MockRecaptchaClient() let recaptchaError = NSError( @@ -206,23 +186,19 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { backoffWrapper: mockBackoffWrapper ) - let expectation = self.expectation(description: "Maps NetworkError to ServerUnreachable") - // Act - generator.getRecaptchaToken().then { token in + do { + let _ = try await generator.getRecaptchaToken() XCTFail("Should not succeed when execute fails") - }.catch { error in + } catch { // Assert let nsError = error as NSError XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain) XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.serverUnreachable.rawValue) - expectation.fulfill() } - - waitForExpectations(timeout: 1.0) } - func testGetRecaptchaTokenMapsInternalErrorToServerUnreachable() { + func testGetRecaptchaTokenMapsInternalErrorToServerUnreachable() async throws { // Arrange let mockClient = MockRecaptchaClient() let recaptchaError = NSError( @@ -242,23 +218,19 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { backoffWrapper: mockBackoffWrapper ) - let expectation = self.expectation(description: "Maps InternalError to ServerUnreachable") - // Act - generator.getRecaptchaToken().then { token in + do { + let _ = try await generator.getRecaptchaToken() XCTFail("Should not succeed when execute fails") - }.catch { error in + } catch { // Assert let nsError = error as NSError XCTAssertEqual(nsError.domain, AppCheckCoreErrorDomain) XCTAssertEqual(nsError.code, AppCheckCoreErrorCode.serverUnreachable.rawValue) - expectation.fulfill() } - - waitForExpectations(timeout: 1.0) } - func testErrorHandlerTriggersBackoffForServerUnreachable() { + func testErrorHandlerTriggersBackoffForServerUnreachable() async throws { // Arrange let mockClient = MockRecaptchaClient() mockClient.mockToken = "valid-recaptcha-token" @@ -273,10 +245,9 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { backoffWrapper: mockBackoffWrapper ) - let expectation = self.expectation(description: "Calls backoff wrapper") - // Act - generator.getRecaptchaToken().then { _ in + do { + let _ = try await generator.getRecaptchaToken() // Assert XCTAssertNotNil(mockBackoffWrapper.capturedErrorHandler) if let errorHandler = mockBackoffWrapper.capturedErrorHandler { @@ -286,17 +257,15 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { userInfo: nil ) let backoffType = errorHandler(serverUnreachableError) - XCTAssertEqual(backoffType, .typeExponential) + XCTAssertEqual(backoffType, .exponential) } - expectation.fulfill() - }.catch { error in + + } catch { XCTFail("Unexpected error: \(error)") } - - waitForExpectations(timeout: 1.0) } - func testErrorHandlerDoesNotTriggerBackoffForOtherErrors() { + func testErrorHandlerDoesNotTriggerBackoffForOtherErrors() async throws { // Arrange let mockClient = MockRecaptchaClient() mockClient.mockToken = "valid-recaptcha-token" @@ -311,10 +280,9 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { backoffWrapper: mockBackoffWrapper ) - let expectation = self.expectation(description: "Calls backoff wrapper") - // Act - generator.getRecaptchaToken().then { _ in + do { + let _ = try await generator.getRecaptchaToken() // Assert XCTAssertNotNil(mockBackoffWrapper.capturedErrorHandler) if let errorHandler = mockBackoffWrapper.capturedErrorHandler { @@ -324,17 +292,15 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { userInfo: nil ) let backoffType = errorHandler(otherError) - XCTAssertEqual(backoffType, .typeNone) + XCTAssertEqual(backoffType, .none) } - expectation.fulfill() - }.catch { error in + + } catch { XCTFail("Unexpected error: \(error)") } - - waitForExpectations(timeout: 1.0) } - func testGetRecaptchaTokenExecutionNilNilFallback() { + func testGetRecaptchaTokenExecutionNilNilFallback() async throws { // Arrange let mockClient = MockRecaptchaClient() MockRecaptcha.mockClient = mockClient @@ -346,21 +312,16 @@ final class RecaptchaTokenGeneratorTests: XCTestCase { backoffWrapper: MockBackoffWrapper() ) - let expectation = self - .expectation(description: "Fails with fallback error when execute returns nil, nil") - // Act - generator.getRecaptchaToken().then { token in + do { + let _ = try await generator.getRecaptchaToken() XCTFail("Should not succeed when execute returns nil, nil") - }.catch { error in + } catch { // 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/Package.swift b/Package.swift index 13691bc5..2a161f40 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,7 @@ import PackageDescription let package = Package( name: "AppCheck", - platforms: [.iOS(.v12), .macCatalyst(.v13), .macOS(.v10_15), .tvOS(.v13), .watchOS(.v7)], + platforms: [.iOS(.v13), .macCatalyst(.v13), .macOS(.v10_15), .tvOS(.v13), .watchOS(.v7)], products: [ .library( name: "AppCheckCore", @@ -33,13 +33,9 @@ let package = Package( ], dependencies: [ - .package( - url: "https://github.com/google/promises.git", - "2.4.0" ..< "3.0.0" - ), .package( url: "https://github.com/google/GoogleUtilities.git", - "8.0.0" ..< "9.0.0" + "8.1.0" ..< "9.0.0" ), .package( url: "https://github.com/google/interop-ios-for-google-sdks.git", @@ -49,7 +45,6 @@ let package = Package( targets: [ .target(name: "AppCheckCore", dependencies: [ - .product(name: "FBLPromises", package: "Promises"), .product(name: "GULEnvironment", package: "GoogleUtilities"), .product(name: "GULUserDefaults", package: "GoogleUtilities"), ], @@ -68,7 +63,6 @@ let package = Package( dependencies: [ "AppCheckCore", .product(name: "RecaptchaInterop", package: "interop-ios-for-google-sdks"), - .product(name: "Promises", package: "Promises"), ], path: "AppCheckRecaptchaProvider/Sources"), .testTarget(