diff --git a/.github/workflows/app_check_core.yml b/.github/workflows/app_check_core.yml index 8aa172a1..7abfb2eb 100644 --- a/.github/workflows/app_check_core.yml +++ b/.github/workflows/app_check_core.yml @@ -17,12 +17,11 @@ jobs: pod_lib_lint: strategy: matrix: - # TODO: macos tests are blocked by https://github.com/erikdoe/ocmock/pull/532 - target: [ios, tvos, macos --skip-tests, watchos] + target: [ios, tvos, macos, watchos] os: [macos-15, macos-26] include: - os: macos-15 - xcode: Xcode_16.4 + xcode: Xcode_26.2 - os: macos-26 xcode: Xcode_26.2 runs-on: ${{ matrix.os }} diff --git a/.github/workflows/spm.yml b/.github/workflows/spm.yml index 667296e2..b9133155 100644 --- a/.github/workflows/spm.yml +++ b/.github/workflows/spm.yml @@ -18,12 +18,8 @@ jobs: strategy: matrix: os: [macos-15] - xcode: [Xcode_16.4] + xcode: [Xcode_26.2] platform: [iOS, tvOS, macOS, catalyst] - include: - - os: macos-14 - xcode: Xcode_16.2 - platform: iOS runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 @@ -36,3 +32,17 @@ jobs: run: xcodebuild -list - name: iOS Unit Tests run: scripts/third_party/travis/retry.sh scripts/build.sh AppCheck-Package ${{ matrix.platform }} spm + + # SwiftPM emits Swift diagnostics but still exits 0, so warnings scroll past + # unnoticed in the job above. `pod lib lint` escalates the same warnings to + # fatal, which previously made CocoaPods the only gate able to reject them -- + # a ~5 minute round-trip, and an accidental dependency rather than a designed + # one. This job makes the cheap gate as strict as the expensive one. + warnings-as-errors: + runs-on: macos-15 + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Xcode + run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer + - name: Build sources and tests with warnings as errors + run: swift build --build-tests -Xswiftc -warnings-as-errors diff --git a/AppCheckCore.podspec b/AppCheckCore.podspec index 56fc0f39..bcc10002 100644 --- a/AppCheckCore.podspec +++ b/AppCheckCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'AppCheckCore' - s.version = '11.3.2' + s.version = '12.0.0' s.summary = 'App Check Core SDK.' s.description = <<-DESC @@ -17,12 +17,12 @@ Pod::Spec.new do |s| } s.social_media_url = 'https://twitter.com/Firebase' - ios_deployment_target = '12.0' - osx_deployment_target = '10.15' - tvos_deployment_target = '13.0' - watchos_deployment_target = '7.0' + ios_deployment_target = '15.0' + osx_deployment_target = '11.0' + tvos_deployment_target = '15.0' + watchos_deployment_target = '8.0' - s.swift_version = '5.5' + s.swift_version = '5.9' s.ios.deployment_target = ios_deployment_target s.osx.deployment_target = osx_deployment_target @@ -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,8 +61,8 @@ 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', + base_dir + 'Tests/Unit/ObjC/**/*.m', ] unit_tests.resources = base_dir + 'Tests/Fixture/**/*' @@ -79,13 +76,13 @@ Pod::Spec.new do |s| :tvos => tvos_deployment_target } integration_tests.source_files = [ - base_dir + 'Tests/Integration/**/*.[mh]', - base_dir + 'Tests/Integration/**/*.[mh]', + base_dir + 'Tests/Integration/**/*.swift', ] 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..2bbd1850 --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/API/AppCheckCoreAppAttestAPIService.swift @@ -0,0 +1,196 @@ +// 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" + +protocol AppCheckCoreAppAttestAPIServiceProtocol: NSObjectProtocol { + func getRandomChallenge() async throws -> Data + + func attestKey(withAttestation attestation: Data, keyID: String, challenge: Data, + limitedUse: Bool) async throws -> AppCheckCoreAppAttestAttestationResponse + + func getAppCheckToken(withArtifact artifact: Data, challenge: Data, assertion: Data, + limitedUse: Bool) async throws -> AppCheckCoreToken +} + +class AppCheckCoreAppAttestAPIService: NSObject, AppCheckCoreAppAttestAPIServiceProtocol { + private let apiService: AppCheckCoreAPIServiceProtocol + private let resourceName: String + + init(apiService: AppCheckCoreAPIServiceProtocol, resourceName: String) { + self.apiService = apiService + self.resourceName = resourceName + super.init() + } + + // MARK: - API Calls + + func getRandomChallenge() async throws -> Data { + let url = try urlForEndpoint(kGenerateAppAttestChallengeEndpoint) + let response = try await apiService.sendRequest( + withURL: url, + httpMethod: kHTTPMethodPost, + body: nil, + additionalHeaders: nil + ) + return try randomChallengeWithAPIResponse(response) + } + + func attestKey(withAttestation attestation: Data, keyID: String, challenge: Data, + limitedUse: Bool) async throws -> AppCheckCoreAppAttestAttestationResponse { + let url = try 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: urlResponse.requestDate + ) + + return response + } + + func getAppCheckToken(withArtifact artifact: Data, challenge: Data, assertion: Data, + limitedUse: Bool) async throws -> AppCheckCoreToken { + let url = try 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 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) throws -> URL { + let urlString = "\(apiService.baseURL)/\(resourceName):\(endpoint)" + guard let url = URL(string: urlString) else { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Invalid URL.") + } + return url + } +} diff --git a/AppCheckCore/Sources/AppAttestProvider/API/AppCheckCoreAppAttestAttestationResponse.swift b/AppCheckCore/Sources/AppAttestProvider/API/AppCheckCoreAppAttestAttestationResponse.swift new file mode 100644 index 00000000..c70f8a13 --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/API/AppCheckCoreAppAttestAttestationResponse.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 kResponseFieldAppCheckTokenDict = "appCheckToken" +private let kResponseFieldArtifact = "artifact" + +class AppCheckCoreAppAttestAttestationResponse: NSObject { + let artifact: Data + let token: AppCheckCoreToken + + init(artifact: Data, token: AppCheckCoreToken) { + self.artifact = artifact + self.token = token + super.init() + } + + 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 10d4352b..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:URLResponse.requestDate - 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..ef086500 --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/AppCheckCoreAppAttestProvider.swift @@ -0,0 +1,559 @@ +// 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) +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: AppCheckCoreBackoffWrapperProtocol + + 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: AppCheckCoreBackoffWrapperProtocol) { + self.appAttestService = appAttestService + self.apiService = apiService + self.keyIDStorage = keyIDStorage + self.artifactStorage = artifactStorage + self.backoffWrapper = backoffWrapper + super.init() + } + + /// - Parameter requestHooks: Hooks invoked on each outgoing request. From Swift, pass + /// `[AppCheckCoreAPIRequestHook]`. From Objective-C, pass an `NSArray` of blocks with the + /// signature `void (^)(NSMutableURLRequest *)`; the signature is not checked at compile + /// time and a mismatch will crash when the hook is invoked. + /// + /// Typed `[Any]?` rather than `[AppCheckCoreAPIRequestHook]?` deliberately: Swift cannot + /// bridge an `NSArray` into a Swift `Array` whose element is a function type, so the typed + /// signature traps at runtime for any non-nil array passed from Objective-C. Do not + /// "simplify" this type — see PR #111. + @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 + + @objc(getTokenWithCompletion:) + public func getToken(completion handler: @escaping (AppCheckCoreToken?, Error?) -> Void) { + getToken(limitedUse: false, completion: handler) + } + + @objc(getLimitedUseTokenWithCompletion:) + 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) + AppCheckCore.deliverOnMainQueue(token, error: nil as Error?, to: handler) + } catch { + AppCheckCore.deliverOnMainQueue(nil, error: error, to: handler) + } + } + } + + 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 { () throws -> AppCheckCoreToken in + // Release the ongoing operation from *within* the operation, so the + // slot is already cleared by the time any chained waiter resumes. The + // Objective-C implementation did this by chaining `.thenOn`/ + // `.recoverOn` onto the operation itself. Clearing it in the + // originating caller instead lets a waiter observe the completed task + // still parked in the slot and spin through repeated `.retry` + // recursions until the originator happens to run. + defer { + self.lock.execute { + self.ongoingGetTokenOperationTask = nil + } + } + return try await self.createGetTokenSequenceWithBackoff(limitedUse: limitedUse) + } + ongoingGetTokenOperationTask = newTask + return .run(newTask) + } + + switch action { + case let .retry(ongoingTask): + // Wait for the in-flight operation, then start a fresh sequence. + // + // This must NOT swallow the in-flight error. Objective-C chained with + // `.thenOn`, which only runs on success, so when the ongoing operation + // failed the chaining caller was rejected with that same error instead + // of kicking off another full attestation sequence. + _ = try await ongoingTask.value + return try await getToken(limitedUse: limitedUse) + case let .wait(ongoingTask): + return try await ongoingTask.value + case let .run(newTask): + return try await newTask.value + } + } + + private func createGetTokenSequenceWithBackoff(limitedUse: Bool) async throws + -> AppCheckCoreToken { + return try await backoffWrapper.applyBackoffToOperation({ + try await self.createGetTokenSequence(limitedUse: limitedUse) + }, errorHandler: backoffWrapper.defaultAppCheckProviderErrorHandler()) + } + + 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 withSafeCheckedThrowingContinuation { (continuation: SafeContinuation< + 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 { + continuation + .resume(throwing: AppCheckCoreErrorUtil.error(withFailureReason: "Unknown error.")) + } + } + } + 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 withSafeCheckedThrowingContinuation { (continuation: SafeContinuation< + 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 { + continuation + .resume(throwing: AppCheckCoreErrorUtil.error(withFailureReason: "Unknown error.")) + } + } + } + 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) + } + + // 2. Check for stored key ID of the generated App Attest key pair. + // + // A missing key ID is reported by the storage as a thrown + // `appAttestKeyIDNotFound` error rather than as `nil`. Treat any failure to + // read the key ID as "no key yet" and fall back to the initial state so a + // new key pair is generated, matching the behavior of the Objective-C + // implementation (`FBLPromiseAwait` returned `nil` and the error was + // deliberately ignored). + let appAttestKeyID = try? await keyIDStorage.getAppAttestKeyID() + guard let keyID = appAttestKeyID ?? nil else { + return AppCheckCoreAppAttestProviderState(supportedInitialState: ()) + } + + // 3. Check for a stored attestation artifact received from the backend. + // + // As above, a failure to read the artifact (e.g. a transient Keychain + // error) degrades to re-attesting the existing key rather than failing the + // whole token fetch. + let attestationArtifact = try? await artifactStorage.getArtifact(forKey: keyID) + guard let artifact = attestationArtifact ?? nil 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 withSafeCheckedThrowingContinuation { (continuation: SafeContinuation< + 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 { + continuation + .resume(throwing: AppCheckCoreErrorUtil.error(withFailureReason: "Unknown error.")) + } + } + } + _ = 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..e553b031 --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/AppCheckCoreAppAttestProviderState.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 + +enum AppCheckCoreAppAttestAttestationState: Int { + case unsupported + case supportedInitial + case keyGenerated + case keyRegistered +} + +class AppCheckCoreAppAttestProviderState: NSObject { + let state: AppCheckCoreAppAttestAttestationState + let appAttestUnsupportedError: Error? + let appAttestKeyID: String? + let attestationArtifact: Data? + + init(unsupportedWithError error: Error) { + state = .unsupported + appAttestUnsupportedError = error + appAttestKeyID = nil + attestationArtifact = nil + super.init() + } + + init(supportedInitialState: Void = ()) { + state = .supportedInitial + appAttestUnsupportedError = nil + appAttestKeyID = nil + attestationArtifact = nil + super.init() + } + + init(generatedKeyID keyID: String) { + state = .keyGenerated + appAttestKeyID = keyID + appAttestUnsupportedError = nil + attestationArtifact = nil + super.init() + } + + 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..17624f26 --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/AppCheckCoreAppAttestService.swift @@ -0,0 +1,31 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import DeviceCheck +import Foundation + +protocol AppCheckCoreAppAttestService: NSObjectProtocol { + var isSupported: Bool { get } + + func generateKey(completionHandler: @escaping @Sendable (String?, Error?) -> Void) + + func attestKey(_ keyId: String, clientDataHash: Data, + completionHandler: @escaping @Sendable (Data?, Error?) -> Void) + + 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..0c89cbbd --- /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) +class AppCheckCoreAppAttestRejectionError: NSError, @unchecked Sendable { + @objc + var underlyingError: Error? { + return userInfo[NSUnderlyingErrorKey] as? Error + } + + @objc(initWithUnderlyingError:) + init(underlyingError: Error) { + super.init( + domain: AppCheckCoreErrorDomain, + code: AppCheckCoreErrorCode.unknown.rawValue, + 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..8d57725d --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/Storage/AppCheckCoreAppAttestArtifactStorage.swift @@ -0,0 +1,132 @@ +// 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 + +protocol AppCheckCoreAppAttestArtifactStorageProtocol: NSObjectProtocol { + func setArtifact(_ artifact: Data?, forKey keyID: String) async throws -> Data? + func getArtifact(forKey keyID: String) async throws -> Data? +} + +final class AppCheckCoreAppAttestArtifactStorage: NSObject, + AppCheckCoreAppAttestArtifactStorageProtocol { + /// Storage service name for Keychain. + /// Internal scope exists for testing purposes. + /// Do not rename: retains value for compatibility with existing stored data from v11 or lower. + static let keychainService = "com.firebase.app_check.app_attest_artifact_storage" + + private let keySuffix: String + private let keychainStorage: GULKeychainStorage + private let accessGroup: String? + + init(keySuffix: String, keychainStorage: GULKeychainStorage, accessGroup: String?) { + self.keySuffix = keySuffix + self.keychainStorage = keychainStorage + self.accessGroup = accessGroup + super.init() + } + + convenience init(keySuffix: String, accessGroup: String?) { + let keychainStorage = GULKeychainStorage(service: Self.keychainService) + self.init(keySuffix: keySuffix, keychainStorage: keychainStorage, accessGroup: accessGroup) + } + + func getArtifact(forKey keyID: String) async throws -> Data? { + do { + let storedArtifact = + try await withSafeCheckedThrowingContinuation { (continuation: SafeContinuation< + AppCheckCoreAppAttestStoredArtifact?, + 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 as? AppCheckCoreAppAttestStoredArtifact) + } + } + } + + if let artifact = storedArtifact, + artifact.keyID == keyID { + return artifact.artifact + } else { + return nil + } + } catch { + throw AppCheckCoreErrorUtil.keychainError(with: error) + } + } + + 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 withSafeCheckedThrowingContinuation { (continuation: SafeContinuation< + 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 withSafeCheckedThrowingContinuation { (continuation: SafeContinuation< + 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..3540e640 --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/Storage/AppCheckCoreAppAttestKeyIDStorage.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 +#if COCOAPODS + import GoogleUtilities +#else + import GoogleUtilities_UserDefaults +#endif + +protocol AppCheckCoreAppAttestKeyIDStorageProtocol: NSObjectProtocol { + func setAppAttestKeyID(_ keyID: String?) async throws -> String? + func getAppAttestKeyID() async throws -> String? +} + +final class AppCheckCoreAppAttestKeyIDStorage: NSObject, + AppCheckCoreAppAttestKeyIDStorageProtocol { + /// Suite name for the app attest key ID in UserDefaults. + /// Internal scope exists for testing purposes. + /// Do not rename: retains the "GAC" prefix for compatibility with existing stored data from v11 + /// or lower. + static let keyIDStorageDefaultsSuiteName = "com.firebase.GACAppAttestKeyIDStorage" + + private let keySuffix: String + private let userDefaults: GULUserDefaults + + init(keySuffix: String) { + self.keySuffix = keySuffix + userDefaults = GULUserDefaults(suiteName: Self.keyIDStorageDefaultsSuiteName) + super.init() + } + + func setAppAttestKeyID(_ keyID: String?) async throws -> String? { + if let keyID = keyID { + userDefaults.setObject(keyID, forKey: keyIDStorageKey) + } else { + userDefaults.removeObject(forKey: keyIDStorageKey) + } + return keyID + } + + 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..71610c0b --- /dev/null +++ b/AppCheckCore/Sources/AppAttestProvider/Storage/AppCheckCoreAppAttestStoredArtifact.swift @@ -0,0 +1,72 @@ +// 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 + +/// Explicit Objective-C class name registration for NSSecureCoding backward compatibility. +/// Do not rename or remove: required to unarchive legacy data stored by Objective-C versions of the +/// SDK. +@objc(GACAppAttestStoredArtifact) +final class AppCheckCoreAppAttestStoredArtifact: NSObject, NSSecureCoding, @unchecked Sendable { + let keyID: String + let artifact: Data + + var storageVersion: Int { + return kStorageVersion + } + + init(keyID: String, artifact: Data) { + self.keyID = keyID + self.artifact = artifact + super.init() + } + + static var supportsSecureCoding: Bool { + return true + } + + func encode(with coder: NSCoder) { + coder.encode(keyID, forKey: kKeyIDKey) + coder.encode(artifact, forKey: kArtifactKey) + coder.encode(storageVersion, forKey: kStorageVersionKey) + } + + 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..ae948fc5 --- /dev/null +++ b/AppCheckCore/Sources/Core/APIService/AppCheckCoreAPIService.swift @@ -0,0 +1,224 @@ +// 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 kAPIKeyHeaderKey = "X-Goog-Api-Key" +private let kBundleIdKey = "X-Ios-Bundle-Identifier" +private let kProdBaseURL = "https://firebaseappcheck.googleapis.com/v1" + +#if DEBUG + private let kStagingBaseURL = "https://staging-firebaseappcheck.sandbox.googleapis.com/v1" + private let kAppCheckUseStagingEnvKey = "_AppCheckUseStaging" +#endif + +public protocol AppCheckCoreAPIServiceProtocol: NSObjectProtocol { + var baseURL: String { get } + + func sendRequest(withURL requestURL: URL, + httpMethod: String, + body: Data?, + additionalHeaders: [String: String]?) async throws + -> AppCheckCoreURLSessionDataResponse + + func appCheckToken(withAPIResponse response: AppCheckCoreURLSessionDataResponse) throws + -> AppCheckCoreToken +} + +@objc(_GACAppCheckAPIService) +public class AppCheckCoreAPIService: NSObject, + AppCheckCoreAPIServiceProtocol { + private static let nsBlockClass: AnyClass? = NSClassFromString("NSBlock") + + public let baseURL: String + private let urlSession: URLSession + private let apiKey: String? + private let requestHooks: [AppCheckCoreAPIRequestHook] + + /// - Parameter requestHooks: Hooks invoked on each outgoing request. From Swift, pass + /// `[AppCheckCoreAPIRequestHook]`. From Objective-C, pass an `NSArray` of blocks with the + /// signature `void (^)(NSMutableURLRequest *)`; the signature is not checked at compile + /// time and a mismatch will crash when the hook is invoked. + /// + /// Typed `[Any]?` rather than `[AppCheckCoreAPIRequestHook]?` deliberately: Swift cannot + /// bridge an `NSArray` into a Swift `Array` whose element is a function type, so the typed + /// signature traps at runtime for any non-nil array passed from Objective-C. Do not + /// "simplify" this type — see PR #111. + @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 + ) + } + + /// - Parameter requestHooks: Hooks invoked on each outgoing request. From Swift, pass + /// `[AppCheckCoreAPIRequestHook]`. From Objective-C, pass an `NSArray` of blocks with the + /// signature `void (^)(NSMutableURLRequest *)`; the signature is not checked at compile + /// time and a mismatch will crash when the hook is invoked. + /// + /// Typed `[Any]?` rather than `[AppCheckCoreAPIRequestHook]?` deliberately: Swift cannot + /// bridge an `NSArray` into a Swift `Array` whose element is a function type, so the typed + /// signature traps at runtime for any non-nil array passed from Objective-C. Do not + /// "simplify" this type — see PR #111. + public init(urlSession: URLSession, + baseURL: String?, + apiKey: String?, + requestHooks: [Any]?, + environment: [String: String]) { + self.urlSession = urlSession + self.apiKey = apiKey + self.requestHooks = requestHooks?.compactMap { obj in + if let hook = obj as? AppCheckCoreAPIRequestHook { + return hook + } else if let cls = Self.nsBlockClass, (obj as AnyObject).isKind(of: cls) { + // Recover Objective-C blocks that fail the dynamic cast bridging + return unsafeBitCast(obj as AnyObject, to: AppCheckCoreAPIRequestHook.self) + } + // Dropping a hook is otherwise invisible: the request still succeeds, + // just without whatever the hook would have contributed. + AppCheckCoreLogger.log( + code: .invalidRequestHook, + logLevel: .error, + message: "Ignoring a request hook that is not a block: \(type(of: obj))." + ) + return nil + } ?? [] + + var resolvedBaseURL = baseURL + + #if DEBUG + 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)." + AppCheckCoreLogger.log(code: .stagingModeEnabled, logLevel: .info, message: logMessage) + } + } + #endif + + self.baseURL = resolvedBaseURL ?? kProdBaseURL + super.init() + } + + @objc + 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 requestDate = Date() + let data: Data + let response: URLResponse + + if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) { + (data, response) = try await urlSession.data(for: request) + } else { + (data, response) = try await withCheckedThrowingContinuation { continuation in + let task = urlSession.dataTask(with: request) { data, response, error in + if let error = error { + continuation.resume(throwing: error) + } else if let data = data, let response = response { + continuation.resume(returning: (data, response)) + } else { + continuation.resume(throwing: URLError(.badServerResponse)) + } + } + task.resume() + } + } + + guard let httpResponse = response as? HTTPURLResponse else { + throw AppCheckCoreErrorUtil.apiError(withNetworkError: URLError(.badServerResponse)) + } + return AppCheckCoreURLSessionDataResponse( + response: httpResponse, + httpBody: data, + requestDate: requestDate + ) + } 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 + } + + public func appCheckToken(withAPIResponse response: AppCheckCoreURLSessionDataResponse) throws + -> AppCheckCoreToken { + return try AppCheckCoreToken( + tokenExchangeResponse: response.httpBody ?? Data(), + requestDate: response.requestDate + ) + } +} diff --git a/AppCheckCore/Sources/Core/APIService/AppCheckCoreToken+APIResponse.swift b/AppCheckCore/Sources/Core/APIService/AppCheckCoreToken+APIResponse.swift new file mode 100644 index 00000000..484db282 --- /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" + +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..6a9264e1 --- /dev/null +++ b/AppCheckCore/Sources/Core/APIService/AppCheckCoreURLSessionDataResponse.swift @@ -0,0 +1,34 @@ +// 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 { + public let httpResponse: HTTPURLResponse + public let httpBody: Data? + public let requestDate: Date + + public init(response: HTTPURLResponse, httpBody: Data?, requestDate: Date) { + httpResponse = response + self.httpBody = httpBody + self.requestDate = requestDate + super.init() + } + + public convenience init(response: HTTPURLResponse, httpBody: Data?) { + self.init(response: response, httpBody: httpBody, requestDate: Date()) + } +} diff --git a/AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.m b/AppCheckCore/Sources/Core/APIService/GACAppCheckAPIService.m deleted file mode 100644 index bd439137..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:response.requestDate - 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 82232437..00000000 --- a/AppCheckCore/Sources/Core/APIService/GACURLSessionDataResponse.m +++ /dev/null @@ -1,33 +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 - requestDate:(NSDate *)requestDate { - self = [super init]; - if (self) { - _HTTPResponse = response; - _HTTPBody = body; - _requestDate = requestDate; - } - 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 c960d1f5..00000000 --- a/AppCheckCore/Sources/Core/APIService/NSURLSession+GACPromises.m +++ /dev/null @@ -1,48 +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 { - NSDate *requestDate = [NSDate date]; - 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 - requestDate:requestDate]); - } - }] resume]; - }]; -} - -@end diff --git a/AppCheckCore/Sources/Core/AppCheckCore.swift b/AppCheckCore/Sources/Core/AppCheckCore.swift new file mode 100644 index 00000000..b58e8c8d --- /dev/null +++ b/AppCheckCore/Sources/Core/AppCheckCore.swift @@ -0,0 +1,300 @@ +// 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(GACAppCheckProtocol) +public protocol AppCheckCoreProtocol: NSObjectProtocol { + @objc(tokenForcingRefresh:completion:) + func token(forcingRefresh: Bool, completion: @escaping AppCheckCoreTokenHandler) + + @objc(limitedUseTokenWithCompletion:) + func limitedUseToken(completion: @escaping AppCheckCoreTokenHandler) +} + +@objc(GACAppCheck) +public class AppCheckCore: NSObject, AppCheckCoreProtocol { + let serviceName: String + let appCheckProvider: AppCheckCoreProvider + let settings: AppCheckCoreSettingsProtocol + weak var tokenDelegate: AppCheckCoreTokenDelegate? + let storage: AppCheckCoreStorageProtocol + let tokenRefresher: AppCheckCoreTokenRefresherProtocol + + @objc( + initWithServiceName:resourceName:appCheckProvider:settings:tokenDelegate:keychainAccessGroup: + ) + 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 { + let refreshResult: AppCheckCoreTokenRefreshResult + do { + let token = try await self.token(forcingRefresh: false) + refreshResult = AppCheckCoreTokenRefreshResult(status: .success, + expirationDate: token.expirationDate, + receivedAtDate: token.receivedAtDate) + } catch { + refreshResult = AppCheckCoreTokenRefreshResult(status: .failure, + expirationDate: nil, + receivedAtDate: nil) + } + // Parity with v11: `-[GACAppCheck periodicTokenRefreshWithCompletion:]` + // used bare `.then` / `.catch`, so this ran on the main queue. + // + // `nonisolated(unsafe)` is an explicit acknowledgement that the result + // crosses a queue boundary without compiler-checked isolation. That is + // the same boundary v11 crossed via `dispatch_async`, with the same + // absence of synchronization, and the value is an immutable holder. + nonisolated(unsafe) let result = refreshResult + nonisolated(unsafe) let completion = completion + DispatchQueue.main.async { + completion(result) + } + } + } + + 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) + + // Parity with v11: both of these ran inside a bare `FBLPromise.then`, which + // dispatches onto `FBLPromise.defaultDispatchQueue` (the main queue). They + // are awaited rather than fire-and-forget because the v11 promise only + // resolved *after* this block completed, so callers were guaranteed the + // delegate had already been notified by the time they received the token. + await notifyTokenUpdateOnMainQueue(token, refreshResult: refreshResult) + + return token + } + + @objc(tokenForcingRefresh:completion:) + public func token(forcingRefresh: Bool, completion: @escaping AppCheckCoreTokenHandler) { + Task { + do { + let token = try await self.token(forcingRefresh: forcingRefresh) + Self.deliverOnMainQueue(AppCheckCoreTokenResult(token: token), to: completion) + } catch { + Self.deliverOnMainQueue(AppCheckCoreTokenResult(error: error), to: completion) + } + } + } + + 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() + Self.deliverOnMainQueue(AppCheckCoreTokenResult(token: token), to: completion) + } catch { + Self.deliverOnMainQueue(AppCheckCoreTokenResult(error: error), to: completion) + } + } + } + + // MARK: - Main-queue delivery (v11 parity) + + /// Delivers a completion handler on the main queue. + /// + /// In v11 every public completion handler was invoked from a bare + /// `FBLPromise` `.then` / `.catch`, which dispatches onto + /// `FBLPromise.defaultDispatchQueue`. That default is `dispatch_get_main_queue()` + /// (set in `+[FBLPromise initialize]`) and is never reassigned by this library + /// or its known consumers, so handlers were always delivered on the main + /// queue — and always asynchronously, since `FBLPromise` used an + /// unconditional `dispatch_group_async` with no same-queue fast path. + /// + /// `DispatchQueue.main.async` is used rather than `MainActor.run` to reproduce + /// that "always async" behavior exactly, including when the caller is already + /// on the main thread. + /// + /// Note this applies only to the completion-handler API. The `async` variants + /// resume on the cooperative pool as normal; `async` callers are expected to + /// hop to the main actor themselves, and forcing a hop would be a new + /// divergence rather than parity. + static func deliverOnMainQueue(_ result: T, + to completion: @escaping (T) -> Void) { + // See the note in `notifyTokenUpdateOnMainQueue` — the caller's handler crosses + // the same queue boundary v11 crossed, and the handler is arbitrary + // caller-supplied code that cannot be declared `Sendable` on their behalf. + nonisolated(unsafe) let completion = completion + DispatchQueue.main.async { + completion(result) + } + } + + static func deliverOnMainQueue(_ result: T, + error: E?, + to completion: @escaping (T, E?) + -> Void) { + nonisolated(unsafe) let completion = completion + DispatchQueue.main.async { + completion(result, error) + } + } + + /// Notifies the token refresher and token delegate on the main queue. + /// + /// Mirrors the v11 bare `.then` in `-[GACAppCheck refreshToken]`, which ran + /// both of these on the main queue before resolving the promise. Suspends + /// until they have run so that ordering relative to the returned token is + /// preserved. + private func notifyTokenUpdateOnMainQueue( + _ token: AppCheckCoreToken, + refreshResult: AppCheckCoreTokenRefreshResult + ) async { + // `nonisolated(unsafe)` is an explicit acknowledgement that these cross a + // queue boundary without compiler-checked isolation. The refresher and + // delegate are arbitrary caller-supplied objects, so neither can honestly + // be declared `Sendable` here. This is the same hand-off v11 performed via + // `dispatch_async` with no synchronization at all, so it is parity rather + // than a new hazard. + nonisolated(unsafe) let tokenRefresher = self.tokenRefresher + nonisolated(unsafe) let tokenDelegate = self.tokenDelegate + nonisolated(unsafe) let result = refreshResult + let serviceName = self.serviceName + + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + tokenRefresher.updateWithRefreshResult(result) + tokenDelegate?.tokenDidUpdate(token, serviceName: serviceName) + continuation.resume() + } + } + } +} diff --git a/AppCheckCore/Sources/Core/AppCheckCoreLogger.swift b/AppCheckCore/Sources/Core/AppCheckCoreLogger.swift new file mode 100644 index 00000000..33629995 --- /dev/null +++ b/AppCheckCore/Sources/Core/AppCheckCoreLogger.swift @@ -0,0 +1,76 @@ +// 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) +public class AppCheckCoreLogger: NSObject { + private static let logLevelLock = NSLock() + private static var _logLevel: AppCheckCoreLogLevel = .warning + + /// The current log level. + /// + /// Access is serialized by a lock to match the `atomic` semantics of the + /// Objective-C `GACAppCheckLogger.logLevel` class property, which was backed + /// by a `volatile` static. + @objc public static var logLevel: AppCheckCoreLogLevel { + get { + logLevelLock.lock() + defer { logLevelLock.unlock() } + return _logLevel + } + set { + logLevelLock.lock() + defer { logLevelLock.unlock() } + _logLevel = newValue + } + } + + public static func log(code: AppCheckCoreMessageCode, logLevel: AppCheckCoreLogLevel, + message: String) { + // Don't log anything in non-debug builds. + // + // Note: this must be `DEBUG`, not `!NDEBUG`. `NDEBUG` is a C preprocessor + // macro and is never defined as a Swift compilation condition, so + // `#if !NDEBUG` is unconditionally true in Swift and would leak logging + // (including the App Check debug token) into Release builds. + #if DEBUG + 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) + // `NSLog` (rather than `print`) so output reaches the system log and is + // visible in Console.app without a debugger attached, matching the + // Objective-C implementation. + NSLog("<%@> [AppCheckCore][%@] %@", levelString, codeString, message) + } + #endif + } +} diff --git a/AppCheckCore/Sources/Core/AppCheckCoreProvider.swift b/AppCheckCore/Sources/Core/AppCheckCoreProvider.swift new file mode 100644 index 00000000..de07ef0b --- /dev/null +++ b/AppCheckCore/Sources/Core/AppCheckCoreProvider.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. + +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 { + func getToken() async throws -> AppCheckCoreToken { + return try await withSafeCheckedThrowingContinuation { 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: AppCheckCoreErrorUtil + .error(withFailureReason: "No token and no error returned.")) + } + } + } + } + + func getLimitedUseToken() async throws -> AppCheckCoreToken { + return try await withSafeCheckedThrowingContinuation { 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: AppCheckCoreErrorUtil + .error(withFailureReason: "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..91427271 --- /dev/null +++ b/AppCheckCore/Sources/Core/AppCheckCoreSettings.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 + +@objc(GACAppCheckSettingsProtocol) +public protocol AppCheckCoreSettingsProtocol: NSObjectProtocol { + @objc var isTokenAutoRefreshEnabled: Bool { get set } +} + +@objc(GACAppCheckSettings) +open class AppCheckCoreSettings: NSObject, AppCheckCoreSettingsProtocol { + @objc 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..1446e8f9 --- /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) +public class AppCheckCoreToken: NSObject, @unchecked Sendable { + @objc public let token: String + @objc public let expirationDate: Date + @objc 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() + } + + @objc(initWithToken:expirationDate:) + 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..ae785766 --- /dev/null +++ b/AppCheckCore/Sources/Core/AppCheckCoreTokenResult.swift @@ -0,0 +1,45 @@ +// 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) +public final class AppCheckCoreTokenResult: NSObject, @unchecked Sendable { + @objc public let token: AppCheckCoreToken + @objc public let error: Error? + + @objc(initWithToken:error:) + public init(token: AppCheckCoreToken, error: Error?) { + self.token = token + self.error = error + super.init() + } + + @objc(initWithToken:) + public convenience init(token: AppCheckCoreToken) { + self.init(token: token, error: nil) + } + + @objc(initWithError:) + 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..e92f62f7 --- /dev/null +++ b/AppCheckCore/Sources/Core/Backoff/AppCheckCoreBackoffWrapper.swift @@ -0,0 +1,181 @@ +// 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 + +// Note: Swift derives ObjC enum constants from the case names, emitting +// GACAppCheckBackoffTypeOneDay instead of v11's GACAppCheckBackoffType1Day. No consumer references +// it, so this divergence is accepted. +@objc(GACAppCheckBackoffType) +public enum AppCheckCoreBackoffType: UInt { + case none + case oneDay + case exponential +} + +public typealias AppCheckCoreBackoffErrorHandler = (Error) -> AppCheckCoreBackoffType +public typealias AppCheckCoreDateProvider = () -> Date + +public protocol AppCheckCoreBackoffWrapperProtocol: NSObjectProtocol { + func applyBackoffToOperation(_ operationProvider: @escaping () async throws -> T, + errorHandler: @escaping (Error) + -> AppCheckCoreBackoffType) async throws -> T + + func defaultAppCheckProviderErrorHandler() -> (Error) -> AppCheckCoreBackoffType +} + +private let k24Hours: TimeInterval = 24 * 60 * 60 +private let kMaxJitterCoefficient = 0.5 +private let kMaxExponentialBackoffInterval: TimeInterval = 4 * 60 * 60 + +private class AppCheckCoreBackoffOperationFailure: NSObject { + let finishDate: Date + let error: Error + let backoffType: AppCheckCoreBackoffType + let retryCount: Int + + init(finishDate: Date, error: Error, backoffType: AppCheckCoreBackoffType, retryCount: Int) { + self.finishDate = finishDate + self.error = error + self.backoffType = backoffType + self.retryCount = retryCount + super.init() + } + + static func nextRetryFailure(with previousFailure: AppCheckCoreBackoffOperationFailure?, + finishDate: Date, error: Error, + backoffType: AppCheckCoreBackoffType) + -> AppCheckCoreBackoffOperationFailure { + return AppCheckCoreBackoffOperationFailure( + finishDate: finishDate, + error: error, + backoffType: backoffType, + retryCount: (previousFailure?.retryCount ?? 0) + 1 + ) + } +} + +@objc(_GACAppCheckBackoffWrapper) +public class AppCheckCoreBackoffWrapper: NSObject, AppCheckCoreBackoffWrapperProtocol { + private let dateProvider: AppCheckCoreDateProvider + private var lastFailure: AppCheckCoreBackoffOperationFailure? + private let lock = NSLock() + + @objc + override public convenience init() { + self.init(dateProvider: AppCheckCoreBackoffWrapper.currentDateProvider()) + } + + @objc(initWithDateProvider:) + public init(dateProvider: @escaping AppCheckCoreDateProvider) { + self.dateProvider = dateProvider + super.init() + } + + @objc + public static func currentDateProvider() -> AppCheckCoreDateProvider { + return { Date() } + } + + public func applyBackoffToOperation(_ operationProvider: @escaping () async throws -> T, + errorHandler: @escaping (Error) + -> AppCheckCoreBackoffType) async throws -> T { + 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 = AppCheckCoreBackoffOperationFailure.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: AppCheckCoreBackoffOperationFailure) + -> 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) -> AppCheckCoreBackoffType { + 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..dbdbabd4 --- /dev/null +++ b/AppCheckCore/Sources/Core/Errors/AppCheckCoreErrorUtil.swift @@ -0,0 +1,211 @@ +// 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 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..34b2617a --- /dev/null +++ b/AppCheckCore/Sources/Core/Errors/AppCheckCoreErrors.swift @@ -0,0 +1,95 @@ +// 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" + +/// Objective-C accessor for the App Check error domain. +/// +/// Swift global constants are not bridged to Objective-C, so the v11 +/// `GACAppCheckErrorDomain` global is no longer visible there. Objective-C +/// callers should use `GACAppCheckErrors.errorDomain` instead: +/// +/// ```objc +/// if ([error.domain isEqualToString:GACAppCheckErrors.errorDomain]) { ... } +/// ``` +@objc(GACAppCheckErrors) +public final class AppCheckCoreErrorsObjC: NSObject { + /// The App Check error domain. Equivalent to the Swift + /// `AppCheckCoreErrorDomain` global. + @objc public static var errorDomain: String { return AppCheckCoreErrorDomain } + + @available(*, unavailable) + override private init() { + super.init() + } +} + +@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 +} + +extension AppCheckCoreErrorCode: CustomNSError { + public static var errorDomain: String { AppCheckCoreErrorDomain } +} + +extension AppCheckCoreErrorCode: _ObjectiveCBridgeableError { + public init?(_bridgedNSError error: NSError) { + if error.domain == AppCheckCoreErrorDomain, + let code = AppCheckCoreErrorCode(rawValue: error.code) { + self = code + } else { + return nil + } + } +} + +@objc(GACAppCheckMessageCode) +public enum AppCheckCoreMessageCode: Int { + case unknown = 1001 + + // App Check + case providerIsMissing = 2002 + case stagingModeEnabled = 2003 + case unexpectedHTTPCode = 3001 + case invalidRequestHook = 3002 + + // 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..ad639239 --- /dev/null +++ b/AppCheckCore/Sources/Core/Storage/AppCheckCoreStorage.swift @@ -0,0 +1,99 @@ +// 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) +protocol AppCheckCoreStorageProtocol: NSObjectProtocol { + func setToken(_ token: AppCheckCoreToken?) async throws -> AppCheckCoreToken? + func getToken() async throws -> AppCheckCoreToken? +} + +@objc(GACAppCheckStorage) +final class AppCheckCoreStorage: NSObject, AppCheckCoreStorageProtocol { + /// Storage service name for Keychain. + /// Internal scope exists for testing purposes. + /// Do not rename: retains value for compatibility with existing stored data from v11 or lower. + static let keychainService = "com.google.app_check_core.token_storage" + + let tokenKey: String + let keychainStorage: GULKeychainStorage + let accessGroup: String? + + init(tokenKey: String, + keychainStorage: GULKeychainStorage, + accessGroup: String?) { + self.tokenKey = tokenKey + self.keychainStorage = keychainStorage + self.accessGroup = accessGroup + super.init() + } + + convenience init(tokenKey: String, accessGroup: String?) { + let keychainStorage = GULKeychainStorage(service: Self.keychainService) + self.init(tokenKey: tokenKey, keychainStorage: keychainStorage, accessGroup: accessGroup) + } + + func getToken() async throws -> AppCheckCoreToken? { + return try await withSafeCheckedThrowingContinuation { 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) + } + } + } + } + + func setToken(_ token: AppCheckCoreToken?) async throws -> AppCheckCoreToken? { + return try await withSafeCheckedThrowingContinuation { continuation in + if let token = token { + let storedToken = AppCheckCoreStoredToken() + storedToken.update(with: token) + keychainStorage + .setObject(storedToken, forKey: tokenKey, accessGroup: accessGroup) { _, error in + if let error = error { + 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..f1b48cae --- /dev/null +++ b/AppCheckCore/Sources/Core/Storage/AppCheckCoreStoredToken.swift @@ -0,0 +1,88 @@ +// 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 + +/// Explicit Objective-C class name registration for NSSecureCoding backward compatibility. +/// Do not rename or remove: required to unarchive legacy data stored by Objective-C versions of the +/// SDK. +@objc(GACAppCheckStoredToken) +final 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 + + var token: String? + var expirationDate: Date? + var receivedAtDate: Date? + + var storageVersion: Int { + return Self.kStorageVersion + } + + static var supportsSecureCoding: Bool { + return true + } + + override init() { + super.init() + } + + 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) + } + + required init?(coder: NSCoder) { + super.init() + let decodedStorageVersion = coder.decodeInteger(forKey: Self.kStorageVersionKey) + if decodedStorageVersion > Self.kStorageVersion { + // TODO: Log a message. + } + + // Decodes known fields leniently across schema versions to support forward and backward + // compatibility without strict version branching. + 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? + } +} + +extension AppCheckCoreStoredToken { + func update(with token: AppCheckCoreToken) { + self.token = token.token + expirationDate = token.expirationDate + receivedAtDate = token.receivedAtDate + } + + func appCheckToken() -> AppCheckCoreToken? { + // Returns nil if any property is missing. A nil token represents an expired or corrupted + // cache entry, prompting the SDK to acquire a fresh token from the backend. + 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..83eacbad --- /dev/null +++ b/AppCheckCore/Sources/Core/TokenRefresh/AppCheckCoreTimer.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 + +protocol AppCheckCoreTimerProtocol: NSObjectProtocol { + func invalidate() +} + +typealias AppCheckCoreTimerProvider = (Date, DispatchQueue, @escaping () -> Void) + -> AppCheckCoreTimerProtocol? + +class AppCheckCoreTimer: NSObject, AppCheckCoreTimerProtocol { + private var timer: DispatchSourceTimer? + + static func timerProvider() -> AppCheckCoreTimerProvider { + return { fireDate, queue, handler in + AppCheckCoreTimer(fireDate: fireDate, dispatchQueue: queue, block: handler) + } + } + + 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() + } + + 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..73e49612 --- /dev/null +++ b/AppCheckCore/Sources/Core/TokenRefresh/AppCheckCoreTokenRefreshResult.swift @@ -0,0 +1,55 @@ +// 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) +enum AppCheckCoreTokenRefreshStatus: Int { + case never = 0 + case success = 1 + case failure = 2 +} + +@objc(GACAppCheckTokenRefreshResult) +class AppCheckCoreTokenRefreshResult: NSObject { + @objc let status: AppCheckCoreTokenRefreshStatus + @objc let tokenExpirationDate: Date? + @objc let tokenReceivedAtDate: Date? + + init(status: AppCheckCoreTokenRefreshStatus, + expirationDate tokenExpirationDate: Date?, + receivedAtDate tokenReceivedAtDate: Date?) { + self.status = status + self.tokenExpirationDate = tokenExpirationDate + self.tokenReceivedAtDate = tokenReceivedAtDate + super.init() + } + + convenience init(statusNever: ()) { + self.init(status: .never, expirationDate: nil, receivedAtDate: nil) + } + + convenience init(statusFailure: ()) { + self.init(status: .failure, expirationDate: nil, receivedAtDate: nil) + } + + 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..dcc024d3 --- /dev/null +++ b/AppCheckCore/Sources/Core/TokenRefresh/AppCheckCoreTokenRefresher.swift @@ -0,0 +1,191 @@ +// 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 + +typealias AppCheckCoreTokenRefreshCompletion = (AppCheckCoreTokenRefreshResult) -> Void +typealias AppCheckCoreTokenRefreshBlock = (@escaping AppCheckCoreTokenRefreshCompletion) + -> Void + +protocol AppCheckCoreTokenRefresherProtocol: NSObjectProtocol { + var tokenRefreshHandler: AppCheckCoreTokenRefreshBlock? { get set } + func updateWithRefreshResult(_ refreshResult: AppCheckCoreTokenRefreshResult) +} + +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() + + init(refreshResult: AppCheckCoreTokenRefreshResult, + timerProvider: @escaping AppCheckCoreTimerProvider, + settings: AppCheckCoreSettingsProtocol) { + initialRefreshResult = refreshResult + self.timerProvider = timerProvider + self.settings = settings + super.init() + } + + convenience init(refreshResult: AppCheckCoreTokenRefreshResult, + settings: AppCheckCoreSettingsProtocol) { + self.init(refreshResult: refreshResult, + timerProvider: AppCheckCoreTimer.timerProvider(), + settings: settings) + } + + deinit { + cancelTimer() + } + + 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) + } + } + } + + 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..26acc4ce --- /dev/null +++ b/AppCheckCore/Sources/Core/Utils/AppCheckCoreCryptoUtils.swift @@ -0,0 +1,28 @@ +// 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) +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/Utils/SafeContinuation.swift b/AppCheckCore/Sources/Core/Utils/SafeContinuation.swift new file mode 100644 index 00000000..c862bb5a --- /dev/null +++ b/AppCheckCore/Sources/Core/Utils/SafeContinuation.swift @@ -0,0 +1,53 @@ +// 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 + +/// A wrapper around `CheckedContinuation` that ensures it is resumed exactly once. +/// If the underlying completion block is invoked multiple times (e.g. by a misbehaving +/// third-party provider), subsequent resumes are safely ignored instead of trapping. +/// Note: Like `CheckedContinuation`, this does not enforce that a resume is ever +/// called. If the underlying block never invokes the handler, the awaiting task will hang. +public final class SafeContinuation: @unchecked Sendable { + private var continuation: CheckedContinuation? + private let lock = NSLock() + + init(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + public func resume(returning value: T) { + lock.lock() + let cont = continuation + continuation = nil + lock.unlock() + cont?.resume(returning: value) + } + + public func resume(throwing error: E) { + lock.lock() + let cont = continuation + continuation = nil + lock.unlock() + cont?.resume(throwing: error) + } +} + +public func withSafeCheckedThrowingContinuation(_ body: (SafeContinuation) + -> Void) async throws -> T { + return try await withCheckedThrowingContinuation { continuation in + let safeContinuation = SafeContinuation(continuation) + body(safeContinuation) + } +} 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..10e8a5f3 --- /dev/null +++ b/AppCheckCore/Sources/DebugProvider/AppCheckCoreDebugProvider.swift @@ -0,0 +1,239 @@ +// 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 + +private let kDebugTokenEnvKey = "AppCheckDebugToken" +private let kFirebaseDebugTokenEnvKey = "FIRAAppCheckDebugToken" + +@objc(GACAppCheckDebugProvider) +public class AppCheckCoreDebugProvider: NSObject, AppCheckCoreProvider { + /// Storage key for the debug token in UserDefaults. + /// Internal scope exists for testing purposes. + /// Do not rename: retains the "GAC" prefix for compatibility with existing stored data from v11 + /// or lower. + static let debugTokenUserDefaultsKey = "GACAppCheckDebugToken" + + /// Storage key prefix for registered debug tokens in UserDefaults. + /// Internal scope exists for testing purposes. + /// Do not rename: retains the "GAC" prefix for compatibility with existing stored data from v11 + /// or lower. + static let debugTokenRegisteredUserDefaultsKeyPrefix = "GACAppCheckDebugTokenRegistered" + + 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() + } + + /// - Parameter requestHooks: Hooks invoked on each outgoing request. From Swift, pass + /// `[AppCheckCoreAPIRequestHook]`. From Objective-C, pass an `NSArray` of blocks with the + /// signature `void (^)(NSMutableURLRequest *)`; the signature is not checked at compile + /// time and a mismatch will crash when the hook is invoked. + /// + /// Typed `[Any]?` rather than `[AppCheckCoreAPIRequestHook]?` deliberately: Swift cannot + /// bridge an `NSArray` into a Swift `Array` whose element is a function type, so the typed + /// signature traps at runtime for any non-nil array passed from Objective-C. Do not + /// "simplify" this type — see PR #111. + @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) + } + + @objc public func localDebugToken() -> String { + return Self.localDebugToken() + } + + @objc 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) + } + + @objc(getTokenWithCompletion:) + public func getToken(completion handler: @escaping (AppCheckCoreToken?, Error?) -> Void) { + getToken(limitedUse: false, completion: handler) + } + + @objc(getLimitedUseTokenWithCompletion:) + 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) + AppCheckCore.deliverOnMainQueue(token, error: nil as Error?, to: handler) + } catch { + AppCheckCore.deliverOnMainQueue(nil, error: error, to: handler) + } + } + } + + private static func localDebugToken() -> String { + if let token = GULUserDefaults.standard().string(forKey: debugTokenUserDefaultsKey) { + return token + } else { + let token = UUID().uuidString + GULUserDefaults.standard().setObject(token, forKey: debugTokenUserDefaultsKey) + return token + } + } + + /// Internal scope exists for testing purposes. + 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 "\(debugTokenRegisteredUserDefaultsKeyPrefix)_\(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..ab1ebd92 --- /dev/null +++ b/AppCheckCore/Sources/DebugProvider/AppCheckCoreDebugProviderAPIService.swift @@ -0,0 +1,69 @@ +// 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 + +protocol AppCheckCoreDebugProviderAPIServiceProtocol: NSObjectProtocol { + func appCheckToken(debugToken: String, limitedUse: Bool) async throws -> AppCheckCoreToken +} + +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" + + init(apiService: AppCheckCoreAPIServiceProtocol, resourceName: String) { + self.apiService = apiService + self.resourceName = resourceName + super.init() + } + + 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 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..6533209f --- /dev/null +++ b/AppCheckCore/Sources/DeviceCheckProvider/API/AppCheckCoreDeviceCheckAPIService.swift @@ -0,0 +1,63 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import Foundation + +protocol AppCheckCoreDeviceCheckAPIServiceProtocol: NSObjectProtocol { + func appCheckToken(deviceToken: Data, limitedUse: Bool) async throws -> AppCheckCoreToken +} + +class AppCheckCoreDeviceCheckAPIService: NSObject, AppCheckCoreDeviceCheckAPIServiceProtocol { + private let apiService: AppCheckCoreAPIServiceProtocol + private let resourceName: String + + init(apiService: AppCheckCoreAPIServiceProtocol, resourceName: String) { + self.apiService = apiService + self.resourceName = resourceName + super.init() + } + + 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 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..528e51ce --- /dev/null +++ b/AppCheckCore/Sources/DeviceCheckProvider/AppCheckCoreDeviceCheckProvider.swift @@ -0,0 +1,116 @@ +// 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: AppCheckCoreBackoffWrapperProtocol + + /// - Parameter requestHooks: Hooks invoked on each outgoing request. From Swift, pass + /// `[AppCheckCoreAPIRequestHook]`. From Objective-C, pass an `NSArray` of blocks with the + /// signature `void (^)(NSMutableURLRequest *)`; the signature is not checked at compile + /// time and a mismatch will crash when the hook is invoked. + /// + /// Typed `[Any]?` rather than `[AppCheckCoreAPIRequestHook]?` deliberately: Swift cannot + /// bridge an `NSArray` into a Swift `Array` whose element is a function type, so the typed + /// signature traps at runtime for any non-nil array passed from Objective-C. Do not + /// "simplify" this type — see PR #111. + @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: AppCheckCoreBackoffWrapperProtocol) { + 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) + AppCheckCore.deliverOnMainQueue(token, error: nil as Error?, to: handler) + } catch { + AppCheckCore.deliverOnMainQueue(nil, error: error, to: handler) + } + } + } + + public func getLimitedUseToken(completion handler: @escaping (AppCheckCoreToken?, Error?) + -> Void) { + Task { + do { + let token = try await getToken(limitedUse: true) + AppCheckCore.deliverOnMainQueue(token, error: nil as Error?, to: handler) + } catch { + AppCheckCore.deliverOnMainQueue(nil, error: error, to: handler) + } + } + } + + // MARK: - Internal + + private func getToken(limitedUse: Bool) async throws -> AppCheckCoreToken { + return try await backoffWrapper.applyBackoffToOperation({ [weak self] in + guard let self = self else { + throw AppCheckCoreErrorUtil.error(withFailureReason: "Self is nil") + } + return try await self.getTokenPromise(limitedUse: limitedUse) + }, errorHandler: backoffWrapper.defaultAppCheckProviderErrorHandler()) + } + + 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..1e1f248f --- /dev/null +++ b/AppCheckCore/Sources/DeviceCheckProvider/AppCheckCoreDeviceCheckTokenGenerator.swift @@ -0,0 +1,41 @@ +// 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 withSafeCheckedThrowingContinuation { 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 { + continuation + .resume(throwing: AppCheckCoreErrorUtil + .error(withFailureReason: "No token and no error.")) + } + } + } + } +} 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 15329f37..00000000 --- a/AppCheckCore/Sources/Public/AppCheckCore/_GACURLSessionDataResponse.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 - -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; -@property(nonatomic, readonly) NSDate *requestDate; - -- (instancetype)initWithResponse:(NSHTTPURLResponse *)response - HTTPBody:(nullable NSData *)body - requestDate:(NSDate *)requestDate; - -@end - -NS_ASSUME_NONNULL_END diff --git a/AppCheckCore/Tests/Fixture/GACAppAttestStoredArtifact.bin b/AppCheckCore/Tests/Fixture/GACAppAttestStoredArtifact.bin new file mode 100644 index 00000000..f0437d51 Binary files /dev/null and b/AppCheckCore/Tests/Fixture/GACAppAttestStoredArtifact.bin differ diff --git a/AppCheckCore/Tests/Fixture/GACAppCheckStoredToken.bin b/AppCheckCore/Tests/Fixture/GACAppCheckStoredToken.bin new file mode 100644 index 00000000..4e967014 Binary files /dev/null and b/AppCheckCore/Tests/Fixture/GACAppCheckStoredToken.bin differ 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..6e955fa7 --- /dev/null +++ b/AppCheckCore/Tests/Unit/AppAttestProvider/AppCheckCoreAppAttestAPIServiceTests.swift @@ -0,0 +1,551 @@ +// 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) 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 + ) + } + + func testAttestKeySuccessUsesRequestDate() async throws { + let attestation = generateRandomData() + let challenge = generateRandomData() + let keyID = UUID().uuidString + let requestDate = Date(timeIntervalSince1970: 100_000) + + let responseDict: [String: Any] = [ + "artifact": "artifact".data(using: .utf8)!.base64EncodedString(), + "appCheckToken": [ + "token": "valid_token", + "ttl": "1800s", + ], + ] + let responseBody = try JSONSerialization.data(withJSONObject: responseDict, options: []) + let httpResponse = HTTPURLResponse( + url: URL(string: "https://test.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )! + let apiResponse = AppCheckCoreURLSessionDataResponse( + response: httpResponse, + httpBody: responseBody, + requestDate: requestDate + ) + fakeAPIService.sendRequestResult = .success(apiResponse) + + let response = try await appAttestAPIService.attestKey( + withAttestation: attestation, + keyID: keyID, + challenge: challenge, + limitedUse: false + ) + + let expectedExpiration = requestDate.addingTimeInterval(1800) + XCTAssertEqual(response.token.expirationDate, expectedExpiration) + } + + // MARK: - Malformed server responses + + // Backfilled from the v11 Objective-C suite (`GACAppAttestAPIServiceTests.m`), + // which covered these paths but had no Swift equivalent. These are all + // "server returned 200 but the body is wrong" cases — the ones most likely to + // regress silently, since the happy path and the HTTP-error path both still + // pass without them. + + func testGetRandomChallengeWhenAPIResponseEmpty() async { + // 1. Prepare an empty 200 response. + let emptyAPIResponse = APIResponse(code: 200, responseBody: Data()) + + // 2. Stub API Service Request. + fakeAPIService.sendRequestResult = .success(emptyAPIResponse) + + // 3. Request the random challenge and verify results. + do { + _ = try await appAttestAPIService.getRandomChallenge() + XCTFail("Expected error to be thrown") + } catch let error as NSError { + let failureReason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String + XCTAssertEqual(failureReason, "Empty server response body.") + } + + let expectedRequestURL = "\(fakeAPIService.baseURL)/\(kResourceName):generateAppAttestChallenge" + XCTAssertEqual(fakeAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(fakeAPIService.passedHTTPMethod, "POST") + } + + func testGetRandomChallengeWhenAPIResponseInvalidFormat() async { + // 1. Prepare a 200 response whose body is not JSON. + let responseBodyString = "Generate challenge failed with invalid format." + let responseBody = responseBodyString.data(using: .utf8)! + let invalidAPIResponse = APIResponse(code: 200, responseBody: responseBody) + + // 2. Stub API Service Request. + fakeAPIService.sendRequestResult = .success(invalidAPIResponse) + + // 3. Request the random challenge and verify results. + do { + _ = try await appAttestAPIService.getRandomChallenge() + XCTFail("Expected error to be thrown") + } catch let error as NSError { + let failureReason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String + XCTAssertEqual(failureReason, "JSON serialization error.") + } + + let expectedRequestURL = "\(fakeAPIService.baseURL)/\(kResourceName):generateAppAttestChallenge" + XCTAssertEqual(fakeAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(fakeAPIService.passedHTTPMethod, "POST") + } + + func testGetRandomChallengeWhenResponseMissingField() async throws { + // 1. Prepare a well-formed JSON 200 response that omits `challenge`. + let missingFieldBody = try AppCheckCoreFixtureLoader + .loadFixture(named: "AppAttestResponseMissingChallenge.json") + let incompleteAPIResponse = APIResponse(code: 200, responseBody: missingFieldBody) + + // 2. Stub API Service Request. + fakeAPIService.sendRequestResult = .success(incompleteAPIResponse) + + // 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) + + // The missing field name must be named in the error, otherwise the error + // is not actionable. + let failureReason = error.userInfo[NSLocalizedFailureReasonErrorKey] as? String + XCTAssertTrue( + failureReason?.contains("`challenge`") ?? false, + "Expected the missing field `challenge` to be named in the failure " + + "reason, got: \(failureReason ?? "nil")" + ) + } + } + + func testGetAppCheckTokenNetworkError() async { + let artifact = generateRandomData() + let challenge = generateRandomData() + let assertion = generateRandomData() + + // 1. Stub the API service to fail with a network error. + let networkError = NSError(domain: "AppCheckCoreAppAttestAPIServiceTests", + code: 0, userInfo: nil) + fakeAPIService.sendRequestResult = .failure(networkError) + + // 2. Send request and verify the error propagates unchanged — v11 did not + // wrap or translate transport errors here. + do { + _ = try await appAttestAPIService.getAppCheckToken( + withArtifact: artifact, + challenge: challenge, + assertion: assertion, + limitedUse: false + ) + XCTFail("Expected error to be thrown") + } catch let error as NSError { + XCTAssertEqual(error.domain, networkError.domain) + XCTAssertEqual(error.code, networkError.code) + } + + let expectedRequestURL = + "\(fakeAPIService.baseURL)/\(kResourceName):exchangeAppAttestAssertion" + XCTAssertEqual(fakeAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(fakeAPIService.passedHTTPMethod, "POST") + XCTAssertEqual(fakeAPIService.passedAdditionalHeaders?["Content-Type"], "application/json") + } + + func testGetAppCheckTokenUnexpectedResponse() async throws { + let artifact = generateRandomData() + let challenge = generateRandomData() + let assertion = generateRandomData() + + // 1. Return a 200 whose body cannot be parsed into a token. + let responseBody = "Unexpected response.".data(using: .utf8)! + let unexpectedAPIResponse = APIResponse(code: 200, responseBody: responseBody) + fakeAPIService.sendRequestResult = .success(unexpectedAPIResponse) + fakeAPIService.appCheckTokenResult = .failure( + NSError(domain: AppCheckCoreErrorDomain, + code: AppCheckCoreErrorCode.unknown.rawValue, + userInfo: [NSLocalizedFailureReasonErrorKey: "JSON serialization error."]) + ) + + // 2. Send request and verify an error surfaces. + do { + _ = try await appAttestAPIService.getAppCheckToken( + withArtifact: artifact, + challenge: challenge, + assertion: assertion, + limitedUse: false + ) + XCTFail("Expected error to be thrown") + } catch { + // Expected. + } + + let expectedRequestURL = + "\(fakeAPIService.baseURL)/\(kResourceName):exchangeAppAttestAssertion" + XCTAssertEqual(fakeAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(fakeAPIService.passedHTTPMethod, "POST") + XCTAssertEqual(fakeAPIService.passedAdditionalHeaders?["Content-Type"], "application/json") + } + + func testAttestKeyNetworkError() async { + let attestation = generateRandomData() + let challenge = generateRandomData() + let keyID = "test_key_id" + + // 1. Stub the API service to fail with a network error. + let networkError = NSError(domain: "AppCheckCoreAppAttestAPIServiceTests", + code: 0, userInfo: nil) + fakeAPIService.sendRequestResult = .failure(networkError) + + // 2. Send request and verify the error propagates unchanged. + do { + _ = try await appAttestAPIService.attestKey( + withAttestation: attestation, + keyID: keyID, + challenge: challenge, + limitedUse: false + ) + XCTFail("Expected error to be thrown") + } catch let error as NSError { + XCTAssertEqual(error.domain, networkError.domain) + XCTAssertEqual(error.code, networkError.code) + } + + let expectedRequestURL = + "\(fakeAPIService.baseURL)/\(kResourceName):exchangeAppAttestAttestation" + XCTAssertEqual(fakeAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(fakeAPIService.passedHTTPMethod, "POST") + XCTAssertEqual(fakeAPIService.passedAdditionalHeaders?["Content-Type"], "application/json") + } + + func testAttestKeyUnexpectedResponse() async { + let attestation = generateRandomData() + let challenge = generateRandomData() + let keyID = "test_key_id" + + // 1. Return a 200 whose body is not the expected attestation response. + let responseBody = "Unexpected response.".data(using: .utf8)! + let unexpectedAPIResponse = APIResponse(code: 200, responseBody: responseBody) + fakeAPIService.sendRequestResult = .success(unexpectedAPIResponse) + + // 2. Send request and verify an error surfaces rather than a malformed + // artifact being accepted. + do { + _ = try await appAttestAPIService.attestKey( + withAttestation: attestation, + keyID: keyID, + challenge: challenge, + limitedUse: false + ) + XCTFail("Expected error to be thrown") + } catch { + // Expected. + } + + let expectedRequestURL = + "\(fakeAPIService.baseURL)/\(kResourceName):exchangeAppAttestAttestation" + XCTAssertEqual(fakeAPIService.passedRequestURL?.absoluteString, expectedRequestURL) + XCTAssertEqual(fakeAPIService.passedHTTPMethod, "POST") + XCTAssertEqual(fakeAPIService.passedAdditionalHeaders?["Content-Type"], "application/json") + } + + // 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..e7d9be30 --- /dev/null +++ b/AppCheckCore/Tests/Unit/AppAttestProvider/AppCheckCoreAppAttestProviderTests.swift @@ -0,0 +1,961 @@ +// 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) + } + } +} + +/// Lets a test hold an in-flight operation open until it chooses to release +/// it, so coalescing behavior can be exercised deterministically. +actor AsyncGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + if isOpen { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func open() { + isOpen = true + let resumable = waiters + waiters = [] + for continuation in resumable { + continuation.resume() + } + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 15.0, watchOS 9.0, *) +class MockAppAttestAPIService: NSObject, AppCheckCoreAppAttestAPIServiceProtocol { + /// When set, `getRandomChallenge()` blocks until the gate is opened. + var getRandomChallengeGate: AsyncGate? + + var getRandomChallengeResults: [Result] = [] + var getRandomChallengeCallCount = 0 + func getRandomChallenge() async throws -> Data { + let result = getRandomChallengeResults[getRandomChallengeCallCount] + getRandomChallengeCallCount += 1 + await getRandomChallengeGate?.wait() + 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)) + try? await Task.sleep(nanoseconds: 20_000_000) + 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, AppCheckCoreBackoffWrapperProtocol { + var isNextOperationAllowed: Bool = true + var backoffCalledCount = 0 + var defaultErrorHandler: ((Error) -> AppCheckCoreBackoffType)? + + func defaultAppCheckProviderErrorHandler() -> (Error) -> AppCheckCoreBackoffType { + return { error in .none } + } + + func applyBackoffToOperation(_ operation: @escaping () async throws -> T, + errorHandler: @escaping (Error) + -> AppCheckCoreBackoffType) async throws -> T { + 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 + // The real `AppCheckCoreAppAttestKeyIDStorage` signals "no key stored" by + // throwing `appAttestKeyIDNotFound`, never by returning `nil`. Mirror that + // here so the suite exercises the actual storage contract. + mockStorage.getAppAttestKeyIDResults + .append(.failure(AppCheckCoreErrorUtil.appAttestKeyIDNotFound())) + } + + 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() + } + + // MARK: - Concurrent request handling (coalescing) + + /// Documented contract (docs/providers/app-attest.md, "Concurrent Request + /// Handling"): a limited-use request chains behind an in-flight operation. + /// If that in-flight operation fails, the chaining caller must fail with the + /// same error. Objective-C got this from `.thenOn`, which only runs on + /// success, so the rejection propagated to the chained caller. + func testGetToken_WhenChainedBehindFailingOperation_ThenFailsWithSameError() async throws { + let gate = AsyncGate() + mockAPIService.getRandomChallengeGate = gate + + let networkError = NSError(domain: NSURLErrorDomain, code: NSURLErrorTimedOut, userInfo: nil) + + // First (standard) sequence: fresh install -> generate key -> challenge fails. + mockAppCheckCoreAppAttestService.isSupportedResult = true + mockStorage.getAppAttestKeyIDResults + .append(.failure(AppCheckCoreErrorUtil.appAttestKeyIDNotFound())) + mockAppCheckCoreAppAttestService.generateKeyResults.append(.success("key_id")) + mockStorage.setAppAttestKeyIDResults.append(.success("key_id")) + mockAPIService.getRandomChallengeResults.append(.failure(networkError)) + + // Deliberately script a *second*, fully successful sequence. If the + // chaining caller incorrectly swallows the first error and starts over, it + // would consume these and succeed - which is exactly what we assert + // against. + mockStorage.getAppAttestKeyIDResults + .append(.failure(AppCheckCoreErrorUtil.appAttestKeyIDNotFound())) + mockAppCheckCoreAppAttestService.generateKeyResults.append(.success("key_id_2")) + mockStorage.setAppAttestKeyIDResults.append(.success("key_id_2")) + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + + // Start the standard request and wait until it is genuinely in flight. + async let standardResult: AppCheckCoreToken = provider.getToken() + while mockAPIService.getRandomChallengeCallCount < 1 { + await Task.yield() + } + + // Now issue a limited-use request, which must chain behind it. + async let limitedResult: AppCheckCoreToken = provider.getLimitedUseToken() + + // Give the limited-use request a moment to reach the chaining branch, + // then let the in-flight operation fail. + try await Task.sleep(nanoseconds: 50_000_000) + await gate.open() + + var standardError: NSError? + do { + _ = try await standardResult + XCTFail("Expected the standard request to fail") + } catch { + standardError = error as NSError + } + + var limitedError: NSError? + do { + _ = try await limitedResult + XCTFail("Expected the chained limited-use request to fail with the same error") + } catch { + limitedError = error as NSError + } + + XCTAssertEqual(standardError?.domain, NSURLErrorDomain) + XCTAssertEqual(standardError?.code, NSURLErrorTimedOut) + + // The chained caller must surface the in-flight failure, not start over. + XCTAssertEqual(limitedError?.domain, NSURLErrorDomain) + XCTAssertEqual(limitedError?.code, NSURLErrorTimedOut) + + // And no second attestation sequence should have been attempted. + XCTAssertEqual(mockAPIService.getRandomChallengeCallCount, 1) + } + + /// Two concurrent *standard* requests must be coalesced into a single fetch + /// and both receive the same token. + func testGetToken_WhenTwoConcurrentStandardRequests_ThenOperationIsCoalesced() async throws { + let gate = AsyncGate() + mockAPIService.getRandomChallengeGate = gate + + let keyID = "key_id" + let attestationData = "attestation".data(using: .utf8)! + let artifact = "artifact".data(using: .utf8)! + let expectedToken = AppCheckCoreToken(token: "coalesced_token", + expirationDate: .distantFuture) + + mockAppCheckCoreAppAttestService.isSupportedResult = true + mockStorage.getAppAttestKeyIDResults + .append(.failure(AppCheckCoreErrorUtil.appAttestKeyIDNotFound())) + mockAppCheckCoreAppAttestService.generateKeyResults.append(.success(keyID)) + mockStorage.setAppAttestKeyIDResults.append(.success(keyID)) + mockAPIService.getRandomChallengeResults.append(.success(randomChallenge)) + mockAppCheckCoreAppAttestService.attestKeyResults.append(.success(attestationData)) + mockAPIService.attestKeyResults.append(.success( + AppCheckCoreAppAttestAttestationResponse(artifact: artifact, token: expectedToken) + )) + mockArtifactStorage.setArtifactResults.append(.success(artifact)) + + async let firstResult: AppCheckCoreToken = provider.getToken() + while mockAPIService.getRandomChallengeCallCount < 1 { + await Task.yield() + } + + async let secondResult: AppCheckCoreToken = provider.getToken() + try await Task.sleep(nanoseconds: 50_000_000) + await gate.open() + + let firstToken = try await firstResult + let secondToken = try await secondResult + + XCTAssertEqual(firstToken.token, expectedToken.token) + XCTAssertEqual(secondToken.token, expectedToken.token) + + // Exactly one underlying attestation sequence should have run. + XCTAssertEqual(mockAPIService.getRandomChallengeCallCount, 1) + XCTAssertEqual(mockAppCheckCoreAppAttestService.generateKeyCallCount, 1) + } +} diff --git a/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestAPIServiceTests.m b/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestAPIServiceTests.m deleted file mode 100644 index 4773c727..00000000 --- a/AppCheckCore/Tests/Unit/AppAttestProvider/GACAppAttestAPIServiceTests.m +++ /dev/null @@ -1,684 +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 - requestDate:[NSDate date]]; - 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..03bb5f53 --- /dev/null +++ b/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/AppCheckCoreAppAttestKeyIDStorageTests.swift @@ -0,0 +1,180 @@ +// 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) + } + + // IMMUTABLE TEST: Do not edit, update, or remove under any circumstance. + // This test locks in an immutable backwards compatibility contract with App Check 11. + // If this test fails, revert changes to the implementation instead of modifying this test. + func testKeyIDStorageDefaultsSuiteName_MatchesContract() { + XCTAssertEqual( + AppCheckCoreAppAttestKeyIDStorage.keyIDStorageDefaultsSuiteName, + "com.firebase.GACAppAttestKeyIDStorage", + "Suite name mismatch breaks App Check 11 migration compatibility." + ) + } + + func testGetAppAttestKeyID_CompatibilityWithLegacyGACStorage() async throws { + let legacySuiteName = AppCheckCoreAppAttestKeyIDStorage.keyIDStorageDefaultsSuiteName + let legacyDefaults = try XCTUnwrap(UserDefaults(suiteName: legacySuiteName)) + let legacyKey = "app_attest_keyID.\(keySuffix!)" + let expectedKeyID = "legacy_test_key_id" + legacyDefaults.set(expectedKeyID, forKey: legacyKey) + defer { + legacyDefaults.removeObject(forKey: legacyKey) + } + + let retrievedKeyID = try await storage.getAppAttestKeyID() + XCTAssertEqual(retrievedKeyID, expectedKeyID) + } + + func testSetAppAttestKeyID_CompatibilityWithLegacyGACStorage() async throws { + let legacySuiteName = AppCheckCoreAppAttestKeyIDStorage.keyIDStorageDefaultsSuiteName + let legacyDefaults = try XCTUnwrap(UserDefaults(suiteName: legacySuiteName)) + let legacyKey = "app_attest_keyID.\(keySuffix!)" + let newKeyID = "new_test_key_id" + legacyDefaults.removeObject(forKey: legacyKey) + defer { + legacyDefaults.removeObject(forKey: legacyKey) + } + + _ = try await storage.setAppAttestKeyID(newKeyID) + + let storedValue = legacyDefaults.string(forKey: legacyKey) + XCTAssertEqual(storedValue, newKeyID) + } + + 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/AppCheckCoreAppAttestStoredArtifactTests.swift b/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/AppCheckCoreAppAttestStoredArtifactTests.swift new file mode 100644 index 00000000..d236afe3 --- /dev/null +++ b/AppCheckCore/Tests/Unit/AppAttestProvider/Storage/AppCheckCoreAppAttestStoredArtifactTests.swift @@ -0,0 +1,166 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +@testable import AppCheckCore +import XCTest + +class AppCheckCoreAppAttestStoredArtifactTests: XCTestCase { + // IMMUTABLE TEST: Do not edit, update, or remove under any circumstance. + // This test locks in an immutable backwards compatibility contract with App Check 11. + // If this test fails, revert changes to the implementation instead of modifying this test. + func testKeychainService_MatchesContract() { + XCTAssertEqual( + AppCheckCoreAppAttestArtifactStorage.keychainService, + "com.firebase.app_check.app_attest_artifact_storage", + "Keychain service name mismatch breaks App Check 11 migration compatibility." + ) + } + + func testLegacyStoredArtifactCompatibility() throws { + let fixtureData = try AppCheckCoreFixtureLoader + .loadFixture(named: "GACAppAttestStoredArtifact.bin") + + let unarchived = try XCTUnwrap( + NSKeyedUnarchiver.unarchivedObject( + ofClass: AppCheckCoreAppAttestStoredArtifact.self, + from: fixtureData + ), + "Failed to unarchive legacy GACAppAttestStoredArtifact binary fixture." + ) + + XCTAssertEqual( + unarchived.keyID, + "test_legacy_key_id_12345", + "Key ID mismatch when unarchiving legacy GACAppAttestStoredArtifact." + ) + + let expectedArtifactData = try XCTUnwrap("test_legacy_artifact_data_bytes".data(using: .utf8)) + XCTAssertEqual( + unarchived.artifact, + expectedArtifactData, + "Artifact data mismatch when unarchiving legacy GACAppAttestStoredArtifact." + ) + XCTAssertEqual( + unarchived.storageVersion, + 1, + "Storage version mismatch when unarchiving legacy GACAppAttestStoredArtifact." + ) + } + + func testStoredArtifactRoundTrip() throws { + let expectedData = try XCTUnwrap("round_trip_artifact_payload".data(using: .utf8)) + let artifact = AppCheckCoreAppAttestStoredArtifact( + keyID: "round_trip_key_id", + artifact: expectedData + ) + + let archivedData = try NSKeyedArchiver.archivedData( + withRootObject: artifact, + requiringSecureCoding: true + ) + + let unarchived = try XCTUnwrap( + NSKeyedUnarchiver.unarchivedObject( + ofClass: AppCheckCoreAppAttestStoredArtifact.self, + from: archivedData + ) + ) + + XCTAssertEqual(unarchived.keyID, artifact.keyID) + XCTAssertEqual(unarchived.artifact, artifact.artifact) + XCTAssertEqual(unarchived.storageVersion, 1) + } + + func testSecureCoding_RejectsEmptyKeyID() throws { + let invalidArtifact = AppCheckCoreAppAttestStoredArtifact( + keyID: "", + artifact: Data("valid_payload".utf8) + ) + + let archivedData = try NSKeyedArchiver.archivedData( + withRootObject: invalidArtifact, + requiringSecureCoding: true + ) + + let unarchived = try? NSKeyedUnarchiver.unarchivedObject( + ofClass: AppCheckCoreAppAttestStoredArtifact.self, + from: archivedData + ) + XCTAssertNil(unarchived, "Unarchiving must reject empty keyID string.") + } + + func testSecureCoding_RejectsEmptyArtifactData() throws { + let invalidArtifact = AppCheckCoreAppAttestStoredArtifact( + keyID: "valid_key_id", + artifact: Data() + ) + + let archivedData = try NSKeyedArchiver.archivedData( + withRootObject: invalidArtifact, + requiringSecureCoding: true + ) + + let unarchived = try? NSKeyedUnarchiver.unarchivedObject( + ofClass: AppCheckCoreAppAttestStoredArtifact.self, + from: archivedData + ) + XCTAssertNil(unarchived, "Unarchiving must reject zero-byte artifact payload.") + } + + func testUnarchiving_CorruptedDataFailsGracefully() { + let corruptedBytes = Data([0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE]) + XCTAssertThrowsError( + try NSKeyedUnarchiver.unarchivedObject( + ofClass: AppCheckCoreAppAttestStoredArtifact.self, + from: corruptedBytes + ) + ) + + let truncatedBytes = Data("bplist00".utf8) + XCTAssertThrowsError( + try NSKeyedUnarchiver.unarchivedObject( + ofClass: AppCheckCoreAppAttestStoredArtifact.self, + from: truncatedBytes + ) + ) + } + + func testDowngradeCompatibility_RuntimeClassNameAndEncodedPropertyTypes() throws { + XCTAssertEqual( + NSStringFromClass(AppCheckCoreAppAttestStoredArtifact.self), + "GACAppAttestStoredArtifact", + "Runtime class name must equal GACAppAttestStoredArtifact for Objective-C 11 unarchiving." + ) + + let artifact = AppCheckCoreAppAttestStoredArtifact( + keyID: "test_key", + artifact: Data("test_payload".utf8) + ) + + let archivedData = try NSKeyedArchiver.archivedData( + withRootObject: artifact, + requiringSecureCoding: true + ) + + let unarchived = try XCTUnwrap( + NSKeyedUnarchiver.unarchivedObject( + ofClass: AppCheckCoreAppAttestStoredArtifact.self, + from: archivedData + ) + ) + XCTAssertTrue((unarchived.keyID as Any) is String) + XCTAssertTrue((unarchived.artifact as Any) is Data) + XCTAssertTrue((unarchived.storageVersion as Any) is Int) + } +} 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..77644a7c --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreAPIServiceTests.swift @@ -0,0 +1,499 @@ +// 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 apiService.appCheckToken(withAPIResponse: apiResponse) + + XCTAssertEqual(token.token, expectedFACToken) + XCTAssertEqual(token.expirationDate.timeIntervalSinceNow, 1800, accuracy: 10) + } + + func testAppCheckTokenWithAPIResponseUsesRequestDate() async throws { + // 1. Prepare input parameters. + let responseBody = try AppCheckCoreFixtureLoader + .loadFixture(named: "FACTokenExchangeResponseSuccess.json") + let httpResponse = AppCheckCoreURLSessionFake.httpResponse(withCode: 200) + let requestDate = Date(timeIntervalSince1970: 100_000) + let apiResponse = AppCheckCoreURLSessionDataResponse( + response: httpResponse, + httpBody: responseBody, + requestDate: requestDate + ) + + // 2. Expected result. + let expectedFACToken = "valid_app_check_token" + + // 3. Parse API response. + let token = try apiService.appCheckToken(withAPIResponse: apiResponse) + + // 4. Verify. + XCTAssertEqual(token.token, expectedFACToken) + let expectedExpiration = requestDate.addingTimeInterval(1800) + XCTAssertEqual(token.expirationDate, expectedExpiration) + } + + func testSendRequestCapturesRequestDateBeforeNetworkDispatch() async throws { + let requestInitiationDate = Date() + let httpResponseBody = "A response".data(using: .utf8)! + let httpResponse = AppCheckCoreURLSessionFake.httpResponse(withCode: 200) + + let artificialNetworkDelay: TimeInterval = 0.25 + stubURLSessionDataTask( + response: httpResponse, + body: httpResponseBody, + error: nil, + requestValidationBlock: { _ in + // Simulate network or suspension latency. + Thread.sleep(forTimeInterval: artificialNetworkDelay) + return true + } + ) + + let url = URL(string: "https://test.com")! + let result = try await apiService.sendRequest( + withURL: url, + httpMethod: "POST", + body: nil, + additionalHeaders: nil + ) + let responseReceiptDate = Date() + + // Verify requestDate captures pre-dispatch initiation date, not post-response date. + XCTAssertGreaterThanOrEqual(result.requestDate, requestInitiationDate) + XCTAssertLessThan(result.requestDate, responseReceiptDate.addingTimeInterval(-0.2)) + } + + func testAppCheckTokenCalculatesExpirationFromRequestInitiationDate() async throws { + let requestInitiationDate = Date() + let responseBody = try AppCheckCoreFixtureLoader + .loadFixture(named: "FACTokenExchangeResponseSuccess.json") + let httpResponse = AppCheckCoreURLSessionFake.httpResponse(withCode: 200) + + let artificialNetworkDelay: TimeInterval = 0.25 + stubURLSessionDataTask( + response: httpResponse, + body: responseBody, + error: nil, + requestValidationBlock: { _ in + // Simulate network or suspension latency. + Thread.sleep(forTimeInterval: artificialNetworkDelay) + return true + } + ) + + let url = URL(string: "https://test.com")! + let apiResponse = try await apiService.sendRequest( + withURL: url, + httpMethod: "POST", + body: nil, + additionalHeaders: nil + ) + let responseReceiptDate = Date() + + let token = try apiService.appCheckToken(withAPIResponse: apiResponse) + + // Verify token expiration is calculated from pre-dispatch requestDate, + // distinct from post-response receipt time. + let expectedExpiration = apiResponse.requestDate.addingTimeInterval(1800) + XCTAssertEqual(token.expirationDate, expectedExpiration) + XCTAssertGreaterThanOrEqual( + token.expirationDate, + requestInitiationDate.addingTimeInterval(1800) + ) + XCTAssertLessThan(token.expirationDate, responseReceiptDate.addingTimeInterval(1800 - 0.2)) + } + + 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 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 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..d039b502 --- /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: AppCheckCoreBackoffType) { + 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/AppCheckCoreErrorsTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreErrorsTests.swift new file mode 100644 index 00000000..a3cf32be --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreErrorsTests.swift @@ -0,0 +1,42 @@ +// 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 AppCheckCoreErrorsTests: XCTestCase { + func testErrorDomainIsUnchanged() { + XCTAssertEqual(AppCheckCoreErrorDomain, "com.google.app_check_core") + XCTAssertEqual(AppCheckCoreErrorCode.errorDomain, "com.google.app_check_core") + } + + func testErrorCodePatternMatching() { + let error = NSError( + domain: AppCheckCoreErrorDomain, + code: AppCheckCoreErrorCode.keychain.rawValue, + userInfo: nil + ) + + var matched = false + do { + throw error + } catch AppCheckCoreErrorCode.keychain { + matched = true + } catch { + XCTFail("Failed to pattern match AppCheckCoreErrorCode") + } + + XCTAssertTrue(matched) + } +} diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreLoggerTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreLoggerTests.swift new file mode 100644 index 00000000..b5e2ca0e --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreLoggerTests.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. + +@testable import AppCheckCore +import XCTest + +class AppCheckCoreLoggerTests: XCTestCase { + override func tearDown() { + // `logLevel` is global state; restore the default so tests can't leak into + // one another (test execution order is not guaranteed). + AppCheckCoreLogger.logLevel = .warning + super.tearDown() + } + + func testDefaultLogLevel() { + let defaultLogLevel = AppCheckCoreLogger.logLevel + + XCTAssertEqual(defaultLogLevel, .warning) + } + + func testSetLogLevel() { + let expectedLogLevel: AppCheckCoreLogLevel = .debug + + AppCheckCoreLogger.logLevel = expectedLogLevel + + XCTAssertEqual(AppCheckCoreLogger.logLevel, expectedLogLevel) + } + + /// The Objective-C `logLevel` class property was `atomic`, backed by a + /// `volatile` static. Concurrent reads and writes must stay race-free. + /// Run with the Thread Sanitizer enabled to get full value from this test. + func testLogLevelConcurrentAccessIsRaceFree() { + let iterations = 1000 + let group = DispatchGroup() + + for index in 0 ..< iterations { + DispatchQueue.global().async(group: group) { + AppCheckCoreLogger.logLevel = index.isMultiple(of: 2) ? .debug : .fault + } + DispatchQueue.global().async(group: group) { + _ = AppCheckCoreLogger.logLevel + } + } + + XCTAssertEqual(group.wait(timeout: .now() + 30), .success) + + // Whatever the interleaving, the final value must be one that was written. + let finalLogLevel = AppCheckCoreLogger.logLevel + XCTAssertTrue(finalLogLevel == .debug || finalLogLevel == .fault) + } +} diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreMainThreadDeliveryTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreMainThreadDeliveryTests.swift new file mode 100644 index 00000000..cefd40f9 --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreMainThreadDeliveryTests.swift @@ -0,0 +1,284 @@ +// 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 + +/// Locks in the main-thread callback-delivery contract inherited from the +/// Objective-C (v11) implementation. +/// +/// In v11 every completion handler was delivered via `FBLPromise`'s bare +/// `.then` / `.catch`, which dispatch onto `FBLPromise.defaultDispatchQueue`. +/// That default is set to `dispatch_get_main_queue()` in `+[FBLPromise +/// initialize]` and is never overridden by this library or its known consumers, +/// so the effective contract was: **completion handlers always arrive on the +/// main queue.** +/// +/// The contract is invisible at the v11 call site (nothing there mentions a +/// queue), so it is easy to drop during a port. These tests make it explicit. +/// +/// Note: the `async` variants intentionally do *not* have this guarantee — +/// `async` callers are expected to hop to the main actor themselves, and +/// forcing a hop on them would be a new divergence. +final class AppCheckCoreMainThreadDeliveryTests: XCTestCase { + private var fakeStorage: AppCheckCoreStorageFake! + private var fakeAppCheckProvider: AppCheckCoreProviderFake! + private var fakeTokenRefresher: AppCheckCoreTokenRefresherFake! + private var fakeSettings: AppCheckCoreSettingsFake! + private var fakeTokenDelegate: ThreadRecordingTokenDelegateFake! + private var appCheck: AppCheckCore! + + override func setUp() { + super.setUp() + + fakeStorage = AppCheckCoreStorageFake() + fakeAppCheckProvider = AppCheckCoreProviderFake() + fakeTokenRefresher = AppCheckCoreTokenRefresherFake() + fakeSettings = AppCheckCoreSettingsFake() + fakeTokenDelegate = ThreadRecordingTokenDelegateFake() + + appCheck = AppCheckCore(serviceName: "AppCheckCoreMainThreadDeliveryTests", + 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: - token(forcingRefresh:completion:) + + func testTokenForcingRefreshCompletion_DeliversOnMainThread_WhenSuccess() { + configureSuccess() + + let expectation = expectation(description: "completion called") + var wasMainThread = false + + appCheck.token(forcingRefresh: false) { _ in + wasMainThread = Thread.isMainThread + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + XCTAssertTrue( + wasMainThread, + "token(forcingRefresh:completion:) must deliver its completion handler on " + + "the main thread, matching the v11 FBLPromise default-queue behavior." + ) + } + + func testTokenForcingRefreshCompletion_DeliversOnMainThread_WhenError() { + configureFailure() + + let expectation = expectation(description: "completion called") + var wasMainThread = false + + appCheck.token(forcingRefresh: false) { _ in + wasMainThread = Thread.isMainThread + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + XCTAssertTrue( + wasMainThread, + "The error path must also deliver on the main thread; v11 used a bare " + + "`.catch`, which dispatches to the main queue." + ) + } + + // MARK: - limitedUseToken(completion:) + + func testLimitedUseTokenCompletion_DeliversOnMainThread_WhenSuccess() { + fakeAppCheckProvider.limitedUseTokenToReturn = + AppCheckCoreToken(token: UUID().uuidString, expirationDate: .distantFuture) + + let expectation = expectation(description: "completion called") + var wasMainThread = false + + appCheck.limitedUseToken { _ in + wasMainThread = Thread.isMainThread + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + XCTAssertTrue( + wasMainThread, + "limitedUseToken(completion:) must deliver on the main thread." + ) + } + + func testLimitedUseTokenCompletion_DeliversOnMainThread_WhenError() { + fakeAppCheckProvider.limitedUseErrorToReturn = NSError( + domain: "TestDomain", code: 1, userInfo: nil + ) + + let expectation = expectation(description: "completion called") + var wasMainThread = false + + appCheck.limitedUseToken { _ in + wasMainThread = Thread.isMainThread + expectation.fulfill() + } + + wait(for: [expectation], timeout: 5.0) + XCTAssertTrue( + wasMainThread, + "The limited-use error path must also deliver on the main thread." + ) + } + + // MARK: - Delivery is still main-thread when invoked from a background queue + + func testTokenForcingRefreshCompletion_DeliversOnMainThread_WhenCalledOffMain() { + configureSuccess() + + let expectation = expectation(description: "completion called") + var wasMainThread = false + + DispatchQueue.global(qos: .userInitiated).async { + self.appCheck.token(forcingRefresh: false) { _ in + wasMainThread = Thread.isMainThread + expectation.fulfill() + } + } + + wait(for: [expectation], timeout: 5.0) + XCTAssertTrue( + wasMainThread, + "Delivery must be on the main thread regardless of the calling queue; " + + "FBLPromise dispatched to the main queue unconditionally." + ) + } + + // MARK: - Token delegate + + func testTokenDidUpdateDelegate_CalledOnMainThread() { + configureSuccess() + + let expectation = expectation(description: "delegate notified") + fakeTokenDelegate.onTokenDidUpdate = { expectation.fulfill() } + + appCheck.token(forcingRefresh: false) { _ in } + + wait(for: [expectation], timeout: 5.0) + XCTAssertEqual( + fakeTokenDelegate.tokenDidUpdateWasMainThread, true, + "tokenDidUpdate(_:serviceName:) was delivered on the main queue in v11 " + + "because it was invoked from inside a bare `.then`." + ) + } + + // MARK: - Helpers + + private func configureSuccess() { + let token = AppCheckCoreToken( + token: UUID().uuidString, expirationDate: .distantFuture + ) + fakeStorage.getTokenHandler = { nil } + fakeAppCheckProvider.tokenToReturn = token + fakeStorage.setTokenHandler = { _ in token } + } + + private func configureFailure() { + fakeStorage.getTokenHandler = { nil } + fakeAppCheckProvider.errorToReturn = NSError( + domain: "TestDomain", code: 1, userInfo: nil + ) + } + + // MARK: - Providers Delivery Tests + + func testDebugProvider_DeliversOnMainThread() { + let provider = AppCheckCoreDebugProvider( + serviceName: "test", + resourceName: "test", + baseURL: nil, + apiKey: "test", + requestHooks: nil + ) + let expect = expectation(description: "completion called") + var wasMainThread = false + DispatchQueue.global(qos: .userInitiated).async { + provider.getToken { _, _ in + wasMainThread = Thread.isMainThread + expect.fulfill() + } + } + wait(for: [expect], timeout: 5.0) + XCTAssertTrue(wasMainThread, "AppCheckCoreDebugProvider must deliver on the main thread") + } + + func testDeviceCheckProvider_DeliversOnMainThread() { + let provider = AppCheckCoreDeviceCheckProvider( + serviceName: "test", + resourceName: "test", + apiKey: "test", + requestHooks: nil + ) + let expect = expectation(description: "completion called") + var wasMainThread = false + DispatchQueue.global(qos: .userInitiated).async { + provider.getToken { _, _ in + wasMainThread = Thread.isMainThread + expect.fulfill() + } + } + wait(for: [expect], timeout: 5.0) + XCTAssertTrue(wasMainThread, "AppCheckCoreDeviceCheckProvider must deliver on the main thread") + } + + func testAppAttestProvider_DeliversOnMainThread() { + let provider = AppCheckCoreAppAttestProvider( + serviceName: "test", + resourceName: "test", + baseURL: nil, + apiKey: "test", + keychainAccessGroup: nil, + requestHooks: nil + ) + let expect = expectation(description: "completion called") + var wasMainThread = false + DispatchQueue.global(qos: .userInitiated).async { + provider.getToken { _, _ in + wasMainThread = Thread.isMainThread + expect.fulfill() + } + } + wait(for: [expect], timeout: 5.0) + XCTAssertTrue(wasMainThread, "AppCheckCoreAppAttestProvider must deliver on the main thread") + } +} + +// MARK: - Fakes + +/// Records the thread on which the delegate callback arrived. +private final class ThreadRecordingTokenDelegateFake: NSObject, + AppCheckCoreTokenDelegate { + var tokenDidUpdateWasMainThread: Bool? + var onTokenDidUpdate: (() -> Void)? + + func tokenDidUpdate(_ token: AppCheckCoreToken, serviceName: String) { + tokenDidUpdateWasMainThread = Thread.isMainThread + onTokenDidUpdate?() + } +} diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreStorageTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreStorageTests.swift new file mode 100644 index 00000000..de270359 --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreStorageTests.swift @@ -0,0 +1,217 @@ +// 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() + + // Scope the key to this specific test instance. Tests in different + // classes can execute concurrently and share one keychain, so a fixed + // key lets them collide on the same item and surface as + // errSecDuplicateItem (-25299) write failures. + tokenKey = tokenKey(withGoogleAppID: kGoogleAppID) + storage = AppCheckCoreStorage(tokenKey: tokenKey, accessGroup: nil) + } + + override func tearDown() { + // Don't leave the item behind for the next run. + let storageToClean = storage + let cleanupExpectation = expectation(description: "keychain cleanup") + Task { + _ = try? await storageToClean?.setToken(nil as AppCheckCoreToken?) + cleanupExpectation.fulfill() + } + wait(for: [cleanupExpectation], timeout: 5.0) + + storage = nil + tokenKey = 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 + + /// Unique per test instance so that concurrently executing tests, and + /// items left over from earlier runs, cannot collide on the same + /// keychain entry. + private lazy var uniqueRunID = UUID().uuidString + + private func tokenKey(withGoogleAppID googleAppID: String) -> String { + return "app_check_token.\(kAppName).\(googleAppID).\(uniqueRunID)" + } + } + + #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..da7f241b --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreStoredTokenTests.swift @@ -0,0 +1,192 @@ +// 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 { + // IMMUTABLE TEST: Do not edit, update, or remove under any circumstance. + // This test locks in an immutable backwards compatibility contract with App Check 11. + // If this test fails, revert changes to the implementation instead of modifying this test. + func testKeychainService_MatchesContract() { + XCTAssertEqual( + AppCheckCoreStorage.keychainService, + "com.google.app_check_core.token_storage", + "Keychain service name mismatch breaks App Check 11 migration compatibility." + ) + } + + func testLegacyStoredTokenCompatibility() throws { + let fixtureData = try AppCheckCoreFixtureLoader.loadFixture(named: "GACAppCheckStoredToken.bin") + + let unarchived = try XCTUnwrap( + NSKeyedUnarchiver.unarchivedObject( + ofClass: AppCheckCoreStoredToken.self, + from: fixtureData + ), + "Failed to unarchive legacy GACAppCheckStoredToken binary fixture." + ) + + XCTAssertEqual( + unarchived.token, + "test_legacy_app_check_token_value", + "Token mismatch when unarchiving legacy GACAppCheckStoredToken." + ) + XCTAssertEqual( + unarchived.expirationDate, + Date(timeIntervalSince1970: 1_800_000_000), + "Expiration date mismatch when unarchiving legacy GACAppCheckStoredToken." + ) + XCTAssertEqual( + unarchived.receivedAtDate, + Date(timeIntervalSince1970: 1_700_000_000), + "Received at date mismatch when unarchiving legacy GACAppCheckStoredToken." + ) + XCTAssertEqual( + unarchived.storageVersion, + 2, + "Storage version mismatch when unarchiving legacy GACAppCheckStoredToken." + ) + + let appCheckToken = try XCTUnwrap(unarchived.appCheckToken()) + XCTAssertEqual(appCheckToken.token, "test_legacy_app_check_token_value") + XCTAssertEqual(appCheckToken.expirationDate, Date(timeIntervalSince1970: 1_800_000_000)) + XCTAssertEqual(appCheckToken.receivedAtDate, Date(timeIntervalSince1970: 1_700_000_000)) + } + + 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) + } + + func testAppCheckToken_ReturnsNilWhenPropertiesAreMissing() { + let storedToken = AppCheckCoreStoredToken() + XCTAssertNil(storedToken.appCheckToken(), "Should return nil when all fields are nil.") + + storedToken.token = "token" + storedToken.expirationDate = Date() + storedToken.receivedAtDate = nil + XCTAssertNil(storedToken.appCheckToken(), "Should return nil when receivedAtDate is nil.") + + storedToken.receivedAtDate = Date() + storedToken.expirationDate = nil + XCTAssertNil(storedToken.appCheckToken(), "Should return nil when expirationDate is nil.") + + storedToken.expirationDate = Date() + storedToken.token = nil + XCTAssertNil(storedToken.appCheckToken(), "Should return nil when token is nil.") + } + + func testSecureCoding_WithNilProperties() throws { + let tokenToArchive = AppCheckCoreStoredToken() + + let archivedData = try NSKeyedArchiver.archivedData( + withRootObject: tokenToArchive, + requiringSecureCoding: true + ) + + let unarchived = try XCTUnwrap( + NSKeyedUnarchiver.unarchivedObject( + ofClass: AppCheckCoreStoredToken.self, + from: archivedData + ) + ) + XCTAssertNil(unarchived.token) + XCTAssertNil(unarchived.expirationDate) + XCTAssertNil(unarchived.receivedAtDate) + XCTAssertEqual(unarchived.storageVersion, 2) + } + + func testUnarchiving_CorruptedDataFailsGracefully() { + let corruptedBytes = Data([0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01]) + XCTAssertThrowsError( + try NSKeyedUnarchiver.unarchivedObject( + ofClass: AppCheckCoreStoredToken.self, + from: corruptedBytes + ) + ) + + let truncatedBytes = Data("bplist00".utf8) + XCTAssertThrowsError( + try NSKeyedUnarchiver.unarchivedObject( + ofClass: AppCheckCoreStoredToken.self, + from: truncatedBytes + ) + ) + } + + func testDowngradeCompatibility_RuntimeClassNameAndEncodedPropertyTypes() throws { + XCTAssertEqual( + NSStringFromClass(AppCheckCoreStoredToken.self), + "GACAppCheckStoredToken", + "Runtime class name must equal GACAppCheckStoredToken for Objective-C 11 unarchiving." + ) + + let tokenToArchive = AppCheckCoreStoredToken() + tokenToArchive.token = "test_token" + tokenToArchive.expirationDate = Date() + tokenToArchive.receivedAtDate = Date() + + let archivedData = try NSKeyedArchiver.archivedData( + withRootObject: tokenToArchive, + requiringSecureCoding: true + ) + + let unarchived = try XCTUnwrap( + NSKeyedUnarchiver.unarchivedObject( + ofClass: AppCheckCoreStoredToken.self, + from: archivedData + ) + ) + XCTAssertTrue((unarchived.token as Any) is String) + XCTAssertTrue((unarchived.expirationDate as Any) is Date) + XCTAssertTrue((unarchived.receivedAtDate as Any) is Date) + XCTAssertTrue((unarchived.storageVersion as Any) is Int) + } +} diff --git a/AppCheckCore/Tests/Unit/Core/AppCheckCoreTests.swift b/AppCheckCore/Tests/Unit/Core/AppCheckCoreTests.swift new file mode 100644 index 00000000..6f02cc58 --- /dev/null +++ b/AppCheckCore/Tests/Unit/Core/AppCheckCoreTests.swift @@ -0,0 +1,376 @@ +// 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 + + let continuationSetExpectation = expectation(description: "continuation set") + var storeTokenContinuation: CheckedContinuation? + fakeStorage.setTokenHandler = { token in + try await withCheckedThrowingContinuation { continuation in + storeTokenContinuation = continuation + continuationSetExpectation.fulfill() + } + } + + let getTokenCallsCount = 10 + + // Request token several times concurrently + Task { + await fulfillment(of: [continuationSetExpectation], timeout: 5.0) + 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 + + let continuationSetExpectation = expectation(description: "continuation set") + var storeTokenContinuation: CheckedContinuation? + fakeStorage.setTokenHandler = { token in + try await withCheckedThrowingContinuation { continuation in + storeTokenContinuation = continuation + continuationSetExpectation.fulfill() + } + } + + let storageError = NSError(domain: name, code: 0, userInfo: nil) + let getTokenCallsCount = 10 + + Task { + await fulfillment(of: [continuationSetExpectation], timeout: 5.0) + 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 7b72c06a..00000000 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckAPIServiceTests.m +++ /dev/null @@ -1,516 +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 - requestDate:[NSDate date]]; - - // 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)testAppCheckTokenWithAPIResponseUsesRequestDate { - // 1. Prepare input parameters. - NSData *responseBody = - [GACFixtureLoader loadFixtureNamed:@"FACTokenExchangeResponseSuccess.json"]; - XCTAssertNotNil(responseBody); - NSHTTPURLResponse *HTTPResponse = [GACURLSessionFake HTTPResponseWithCode:200]; - NSDate *requestDate = [NSDate dateWithTimeIntervalSince1970:100000]; - _GACURLSessionDataResponse *APIResponse = - [[_GACURLSessionDataResponse alloc] initWithResponse:HTTPResponse - HTTPBody:responseBody - requestDate:requestDate]; - - // 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); - - NSDate *expectedExpiration = [requestDate dateByAddingTimeInterval:1800]; - XCTAssertEqualObjects(tokenPromise.value.expirationDate, expectedExpiration); -} - -- (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 - requestDate:[NSDate date]]; - - // 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 - requestDate:[NSDate date]]; - - // 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 - requestDate:[NSDate date]]; - [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..fe74a308 --- /dev/null +++ b/AppCheckCore/Tests/Unit/DebugProvider/AppCheckCoreDebugProviderAPIServiceTests.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 XCTest + +@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) 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..3205ea3d --- /dev/null +++ b/AppCheckCore/Tests/Unit/DebugProvider/AppCheckCoreDebugProviderTests.swift @@ -0,0 +1,552 @@ +// 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 = AppCheckCoreDebugProvider.debugTokenUserDefaultsKey + let kDebugTokenRegisteredUserDefaultsKey = AppCheckCoreDebugProvider + .debugTokenRegisteredUserDefaultsKeyPrefix + + 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 + + // IMMUTABLE TEST: Do not edit, update, or remove under any circumstance. + // This test locks in an immutable backwards compatibility contract with App Check 11. + // If this test fails, revert changes to the implementation instead of modifying this test. + func testDebugTokenUserDefaultsKeys_MatchContract() { + XCTAssertEqual( + AppCheckCoreDebugProvider.debugTokenUserDefaultsKey, + "GACAppCheckDebugToken", + "Debug token key mismatch breaks App Check 11 migration compatibility." + ) + XCTAssertEqual( + AppCheckCoreDebugProvider.debugTokenRegisteredUserDefaultsKeyPrefix, + "GACAppCheckDebugTokenRegistered", + "Registered token key prefix mismatch breaks App Check 11 migration compatibility." + ) + } + + func testLegacyDebugTokenCompatibility() { + let legacyKey = kDebugTokenUserDefaultsKey + let legacyToken = "legacy_stored_debug_token" + UserDefaults.standard.set(legacyToken, forKey: legacyKey) + defer { + UserDefaults.standard.removeObject(forKey: legacyKey) + } + + let debugProvider = AppCheckCoreDebugProvider( + apiService: fakeAPIService, + serviceName: "test-service", + resourceName: "projects/test-project/apps/test-app", + environment: [:] + ) + + XCTAssertEqual( + debugProvider.currentDebugToken(), + legacyToken, + "Debug token mismatch breaks App Check 11 migration compatibility." + ) + } + + func testSetDebugToken_CompatibilityWithLegacyGACStorage() { + let legacyKey = kDebugTokenUserDefaultsKey + UserDefaults.standard.removeObject(forKey: legacyKey) + defer { + UserDefaults.standard.removeObject(forKey: legacyKey) + } + + let debugProvider = AppCheckCoreDebugProvider( + apiService: fakeAPIService, + serviceName: "test-service", + resourceName: "projects/test-project/apps/test-app", + environment: [:] + ) + + let generatedToken = debugProvider.currentDebugToken() + let storedLegacyValue = UserDefaults.standard.string(forKey: legacyKey) + XCTAssertEqual( + storedLegacyValue, + generatedToken, + "Generated debug token was not stored under legacy key." + ) + } + + func testLegacyRegisteredDebugTokenCompatibility() async throws { + let serviceName = "test-service" + let resourceName = "projects/test-project/apps/test-app" + let expectedLegacyRegisteredKey = + "GACAppCheckDebugTokenRegistered_test-service_projects_test-project_apps_test-app" + + // 1. Verify key resolution matches legacy v11 format. + let resolvedKey = AppCheckCoreDebugProvider.registeredUserDefaultsKey( + forServiceName: serviceName, + resourceName: resourceName + ) + XCTAssertEqual( + resolvedKey, + expectedLegacyRegisteredKey, + "Resolved registered token key does not match legacy v11 format." + ) + + // 2. Verify write-back persists to UserDefaults using legacy key format. + UserDefaults.standard.removeObject(forKey: expectedLegacyRegisteredKey) + defer { + UserDefaults.standard.removeObject(forKey: expectedLegacyRegisteredKey) + } + + let validToken = AppCheckCoreToken( + token: "valid_token", + expirationDate: Date().addingTimeInterval(3600) + ) + fakeAPIService.tokenResult = .success(validToken) + + let debugProvider = AppCheckCoreDebugProvider( + apiService: fakeAPIService, + serviceName: serviceName, + resourceName: resourceName, + environment: [:] + ) + + XCTAssertFalse( + UserDefaults.standard.bool(forKey: expectedLegacyRegisteredKey), + "Precondition: Key should not be registered before getToken()." + ) + + _ = try await debugProvider.getToken() + + XCTAssertTrue( + UserDefaults.standard.bool(forKey: expectedLegacyRegisteredKey), + "Registered flag was not stored under legacy key format GACAppCheckDebugTokenRegistered__." + ) + } + + 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) + } + + // MARK: - Registered UserDefaults key construction + + /// Backfilled from the v11 suite + /// (`testRegisteredUserDefaultsKeyForServiceName_resourceName`). This key + /// namespaces the "debug token already registered" flag, so a change to its + /// construction would silently orphan the flag for every existing install and + /// cause a spurious re-registration. + func testRegisteredUserDefaultsKeyForServiceNameResourceName() { + // Slashes in the resource name must be sanitized to underscores. + XCTAssertEqual( + AppCheckCoreDebugProvider.registeredUserDefaultsKey( + forServiceName: "app1", resourceName: "projects/p1/apps/a1" + ), + "GACAppCheckDebugTokenRegistered_app1_projects_p1_apps_a1" + ) + XCTAssertEqual( + AppCheckCoreDebugProvider.registeredUserDefaultsKey( + forServiceName: "app2", resourceName: "projects/p2/apps/a2" + ), + "GACAppCheckDebugTokenRegistered_app2_projects_p2_apps_a2" + ) + + // Empty components fall back to "default". + // + // Note: v11 also covered `nil` for both parameters. Swift's type system + // makes that unrepresentable here (both are non-optional `String`), so the + // empty-string case is the only reachable fallback. + XCTAssertEqual( + AppCheckCoreDebugProvider.registeredUserDefaultsKey( + forServiceName: "", resourceName: "" + ), + "GACAppCheckDebugTokenRegistered_default_default" + ) + } +} diff --git a/AppCheckCore/Tests/Unit/DebugProvider/GACAppCheckDebugProviderAPIServiceTests.m b/AppCheckCore/Tests/Unit/DebugProvider/GACAppCheckDebugProviderAPIServiceTests.m deleted file mode 100644 index 1ab8f28d..00000000 --- a/AppCheckCore/Tests/Unit/DebugProvider/GACAppCheckDebugProviderAPIServiceTests.m +++ /dev/null @@ -1,201 +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 - requestDate:[NSDate date]]; - - 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 - requestDate:[NSDate date]]; - - 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..a27f64b1 --- /dev/null +++ b/AppCheckCore/Tests/Unit/DeviceCheckProvider/AppCheckCoreDeviceCheckAPIServiceTests.swift @@ -0,0 +1,220 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest + +@testable import AppCheckCore + +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) 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..84539d0d --- /dev/null +++ b/AppCheckCore/Tests/Unit/DeviceCheckProvider/AppCheckCoreDeviceCheckProviderTests.swift @@ -0,0 +1,411 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import XCTest + +@testable import AppCheckCore + +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, AppCheckCoreBackoffWrapperProtocol { + var isNextOperationAllowed: Bool = true + var backoffError: Error = NSError(domain: "BackoffError", code: -1, userInfo: nil) + + var backoffExpectation: XCTestExpectation? + var defaultErrorHandlerCalled = false + var defaultErrorHandler: ((Error) -> AppCheckCoreBackoffType)? + + var operationResult: Any? + var operationError: Error? + + func applyBackoffToOperation(_ operationProvider: @escaping () async throws -> T, + errorHandler: @escaping (Error) + -> AppCheckCoreBackoffType) async throws -> T { + 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) -> AppCheckCoreBackoffType { + 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 ceb484c9..00000000 --- a/AppCheckCore/Tests/Unit/DeviceCheckProvider/GACDeviceCheckAPIServiceTests.m +++ /dev/null @@ -1,245 +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 - requestDate:[NSDate date]]; - - 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 - requestDate:[NSDate date]]; - - 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/ObjC/AppCheckCoreObjCAPITests.m b/AppCheckCore/Tests/Unit/ObjC/AppCheckCoreObjCAPITests.m new file mode 100644 index 00000000..7cdfe986 --- /dev/null +++ b/AppCheckCore/Tests/Unit/ObjC/AppCheckCoreObjCAPITests.m @@ -0,0 +1,361 @@ +// 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; +#if SWIFT_PACKAGE && (TARGET_OS_IOS || TARGET_OS_VISION) && !TARGET_OS_MACCATALYST +@import AppCheckRecaptchaProvider; +#endif + +#pragma mark - Protocol Conformance Dummies + +@interface GACDummyAppCheckProvider : NSObject +@end + +@implementation GACDummyAppCheckProvider + +- (void)getTokenWithCompletion:(void (^)(GACAppCheckToken *_Nullable token, + NSError *_Nullable error))handler { + GACAppCheckToken *token = [[GACAppCheckToken alloc] initWithToken:@"dummy_token" + expirationDate:[NSDate distantFuture]]; + handler(token, nil); +} + +- (void)getLimitedUseTokenWithCompletion:(void (^)(GACAppCheckToken *_Nullable token, + NSError *_Nullable error))handler { + GACAppCheckToken *token = [[GACAppCheckToken alloc] initWithToken:@"dummy_limited_use_token" + expirationDate:[NSDate distantFuture]]; + handler(token, nil); +} + +@end + +@interface GACDummyAppCheckSettings : NSObject +@property(nonatomic, assign) BOOL isTokenAutoRefreshEnabled; +@end + +@implementation GACDummyAppCheckSettings +@end + +@interface GACDummyTokenDelegate : NSObject +@end + +@implementation GACDummyTokenDelegate +- (void)tokenDidUpdate:(GACAppCheckToken *)token serviceName:(NSString *)serviceName { +} +@end + +#pragma mark - API Build Tests + +@interface AppCheckCoreObjCAPITests : XCTestCase +@end + +@interface GACAppCheckMockURLProtocol : NSURLProtocol +@end +@implementation GACAppCheckMockURLProtocol ++ (BOOL)canInitWithRequest:(NSURLRequest *)request { + return YES; +} ++ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request { + return request; +} +- (void)startLoading { + [self.client URLProtocol:self + didFailWithError:[NSError errorWithDomain:@"test" code:-1 userInfo:nil]]; +} +- (void)stopLoading { +} +@end + +@implementation AppCheckCoreObjCAPITests + +- (void)testPublicAPICompileAndLink { + NSString *serviceName = @"test_service"; + NSString *resourceName = @"projects/test/apps/test"; + NSString *apiKey = @"test_api_key"; + NSString *keychainGroup = @"test_group"; + + // MARK: - GACAppCheckToken + NSDate *expirationDate = [NSDate distantFuture]; + NSDate *receivedAtDate = [NSDate date]; + GACAppCheckToken *token = [[GACAppCheckToken alloc] initWithToken:@"test_token" + expirationDate:expirationDate]; + XCTAssertNotNil(token); + XCTAssertEqualObjects(token.token, @"test_token"); + XCTAssertEqualObjects(token.expirationDate, expirationDate); + XCTAssertNotNil(token.receivedAtDate); + + GACAppCheckToken *tokenWithReceivedAt = [[GACAppCheckToken alloc] initWithToken:@"test_token" + expirationDate:expirationDate + receivedAtDate:receivedAtDate]; + XCTAssertEqualObjects(tokenWithReceivedAt.receivedAtDate, receivedAtDate); + + // MARK: - GACAppCheckTokenResult + NSError *testError = [NSError errorWithDomain:@"test_domain" code:100 userInfo:nil]; + GACAppCheckTokenResult *successResult = [[GACAppCheckTokenResult alloc] initWithToken:token]; + XCTAssertEqualObjects(successResult.token, token); + XCTAssertNil(successResult.error); + + GACAppCheckTokenResult *failureResult = [[GACAppCheckTokenResult alloc] initWithError:testError]; + XCTAssertNotNil(failureResult.token); + XCTAssertEqualObjects(failureResult.error, testError); + + GACAppCheckTokenResult *designatedResult = + [[GACAppCheckTokenResult alloc] initWithToken:token error:testError]; + XCTAssertEqualObjects(designatedResult.token, token); + XCTAssertEqualObjects(designatedResult.error, testError); + + // MARK: - GACAppCheckSettings + GACAppCheckSettings *settings = [[GACAppCheckSettings alloc] init]; + settings.isTokenAutoRefreshEnabled = YES; + XCTAssertTrue(settings.isTokenAutoRefreshEnabled); + + GACDummyAppCheckSettings *customSettings = [[GACDummyAppCheckSettings alloc] init]; + customSettings.isTokenAutoRefreshEnabled = NO; + XCTAssertFalse(customSettings.isTokenAutoRefreshEnabled); + + // MARK: - GACAppCheckProvider & Delegate + id provider = [[GACDummyAppCheckProvider alloc] init]; + id tokenDelegate = [[GACDummyTokenDelegate alloc] init]; + + // MARK: - GACAppCheck & GACAppCheckProtocol + GACAppCheck *appCheck = [[GACAppCheck alloc] initWithServiceName:serviceName + resourceName:resourceName + appCheckProvider:provider + settings:settings + tokenDelegate:tokenDelegate + keychainAccessGroup:keychainGroup]; + XCTAssertNotNil(appCheck); + + id appCheckProtocol = appCheck; + XCTAssertNotNil(appCheckProtocol); + + [appCheck tokenForcingRefresh:NO + completion:^(GACAppCheckTokenResult *result) { + XCTAssertNotNil(result); + }]; + + [appCheck limitedUseTokenWithCompletion:^(GACAppCheckTokenResult *result) { + XCTAssertNotNil(result); + }]; + + // MARK: - GACAppCheckDebugProvider + GACAppCheckDebugProvider *debugProvider = + [[GACAppCheckDebugProvider alloc] initWithServiceName:serviceName + resourceName:resourceName + baseURL:nil + APIKey:apiKey + requestHooks:nil]; + XCTAssertNotNil(debugProvider); + XCTAssertNotNil([debugProvider localDebugToken]); + XCTAssertNotNil([debugProvider currentDebugToken]); + + [debugProvider getTokenWithCompletion:^(GACAppCheckToken *debugToken, NSError *error){ + }]; + [debugProvider getLimitedUseTokenWithCompletion:^(GACAppCheckToken *debugToken, NSError *error){ + }]; + + // MARK: - GACDeviceCheckProvider +#if !TARGET_OS_WATCH + GACDeviceCheckProvider *deviceCheckProvider = + [[GACDeviceCheckProvider alloc] initWithServiceName:serviceName + resourceName:resourceName + APIKey:apiKey + requestHooks:nil]; + XCTAssertNotNil(deviceCheckProvider); + [deviceCheckProvider getTokenWithCompletion:^(GACAppCheckToken *dToken, NSError *error){ + }]; + [deviceCheckProvider getLimitedUseTokenWithCompletion:^(GACAppCheckToken *dToken, NSError *error){ + }]; +#endif + + // MARK: - GACAppAttestProvider + if (@available(iOS 14.0, macOS 11.0, tvOS 15.0, watchOS 9.0, *)) { + GACAppAttestProvider *appAttestProvider = + [[GACAppAttestProvider alloc] initWithServiceName:serviceName + resourceName:resourceName + baseURL:nil + APIKey:apiKey + keychainAccessGroup:keychainGroup + requestHooks:nil]; + XCTAssertNotNil(appAttestProvider); + [appAttestProvider getTokenWithCompletion:^(GACAppCheckToken *aToken, NSError *error){ + }]; + [appAttestProvider getLimitedUseTokenWithCompletion:^(GACAppCheckToken *aToken, NSError *error){ + }]; + } + + // MARK: - GACAppCheckLogger + GACAppCheckLogger.logLevel = GACAppCheckLogLevelDebug; + XCTAssertEqual(GACAppCheckLogger.logLevel, GACAppCheckLogLevelDebug); + GACAppCheckLogger.logLevel = GACAppCheckLogLevelInfo; + GACAppCheckLogger.logLevel = GACAppCheckLogLevelWarning; + GACAppCheckLogger.logLevel = GACAppCheckLogLevelError; + GACAppCheckLogger.logLevel = GACAppCheckLogLevelFault; + + // MARK: - GACAppCheckErrorCode + GACAppCheckErrorCode unknownCode = GACAppCheckErrorCodeUnknown; + GACAppCheckErrorCode serverUnreachableCode = GACAppCheckErrorCodeServerUnreachable; + GACAppCheckErrorCode invalidConfigurationCode = GACAppCheckErrorCodeInvalidConfiguration; + GACAppCheckErrorCode keychainCode = GACAppCheckErrorCodeKeychain; + GACAppCheckErrorCode unsupportedCode = GACAppCheckErrorCodeUnsupported; + XCTAssertEqual(unknownCode, 0); + XCTAssertEqual(serverUnreachableCode, 1); + XCTAssertEqual(invalidConfigurationCode, 2); + XCTAssertEqual(keychainCode, 3); + XCTAssertEqual(unsupportedCode, 4); + + // MARK: - GACAppCheckMessageCode + GACAppCheckMessageCode msgUnknown = GACAppCheckMessageCodeUnknown; + GACAppCheckMessageCode msgProviderMissing = GACAppCheckMessageCodeProviderIsMissing; + GACAppCheckMessageCode msgStaging = GACAppCheckMessageCodeStagingModeEnabled; + GACAppCheckMessageCode msgHTTP = GACAppCheckMessageCodeUnexpectedHTTPCode; + GACAppCheckMessageCode msgInvalidRequestHook = GACAppCheckMessageCodeInvalidRequestHook; + GACAppCheckMessageCode msgLocalToken = GACAppCheckMessageCodeLocalDebugToken; + GACAppCheckMessageCode msgEnvToken = GACAppCheckMessageCodeEnvironmentVariableDebugToken; + GACAppCheckMessageCode msgFirebaseEnv = + GACAppCheckMessageCodeDebugProviderFirebaseEnvironmentVariable; + GACAppCheckMessageCode msgFailedExchange = GACAppCheckMessageCodeDebugProviderFailedExchange; + GACAppCheckMessageCode msgAttestNotSupported = GACAppCheckMessageCodeAppAttestNotSupported; + GACAppCheckMessageCode msgAttestationRejected = GACAppCheckMessageCodeAttestationRejected; + GACAppCheckMessageCode msgAssertionRejected = GACAppCheckMessageCodeAssertionRejected; + XCTAssertEqual(msgUnknown, 1001); + XCTAssertEqual(msgProviderMissing, 2002); + XCTAssertEqual(msgStaging, 2003); + XCTAssertEqual(msgHTTP, 3001); + XCTAssertEqual(msgInvalidRequestHook, 3002); + XCTAssertEqual(msgLocalToken, 4001); + XCTAssertEqual(msgEnvToken, 4002); + XCTAssertEqual(msgFirebaseEnv, 4003); + XCTAssertEqual(msgFailedExchange, 4004); + XCTAssertEqual(msgAttestNotSupported, 7001); + XCTAssertEqual(msgAttestationRejected, 7002); + XCTAssertEqual(msgAssertionRejected, 7003); +} + +/// The error domain must remain reachable from Objective-C and must keep its +/// v11 string value; shipped apps compare against it when handling errors. +- (void)testErrorDomainIsAvailableToObjectiveCAndUnchanged { + XCTAssertEqualObjects(GACAppCheckErrors.errorDomain, @"com.google.app_check_core"); +} + +/// Builds a session that never touches the network. +- (NSURLSession *)stubSession { + NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + config.protocolClasses = @[ [GACAppCheckMockURLProtocol class] ]; + return [NSURLSession sessionWithConfiguration:config]; +} + +/// Drives a request with a non-empty array of Objective-C blocks. +/// +/// Elsewhere this file passes `requestHooks:nil`, which never builds an +/// `NSArray` and so never crosses the bridge, and the Swift tests pass Swift +/// closures, which never bridge either. Neither performs the motion Firebase +/// actually performs. +/// +/// Constructing the service is not enough to catch a bridging regression: +/// `NSArray` to `Array` bridging is lazy, so the element cast is forced on +/// first access, which happens while building a request. +/// +/// The hook is shaped like `FIRHeartbeatLogger`'s App Check request hook, the +/// only non-nil hook Firebase passes in production. Asserting that its header +/// survives is stronger than asserting it ran: it shows the recovered block +/// was invoked with the correct ABI and handed a usable request, not merely +/// that control reached it. +- (void)testRequestHooksBridging { + XCTestExpectation *hookExpectation = [self expectationWithDescription:@"request hook called"]; + XCTestExpectation *completionExpectation = [self expectationWithDescription:@"completion called"]; + + __block NSMutableURLRequest *capturedRequest = nil; + void (^heartbeatHook)(NSMutableURLRequest *) = ^(NSMutableURLRequest *request) { + [request setValue:@"test-heartbeat" forHTTPHeaderField:@"X-firebase-client"]; + capturedRequest = request; + [hookExpectation fulfill]; + }; + + _GACAppCheckAPIService *apiService = + [[_GACAppCheckAPIService alloc] initWithURLSession:[self stubSession] + baseURL:nil + APIKey:@"key" + requestHooks:@[ heartbeatHook ]]; + + [apiService sendRequestWithURL:[NSURL URLWithString:@"https://test.local"] + httpMethod:@"GET" + body:nil + additionalHeaders:nil + completionHandler:^(id response, NSError *_Nullable error) { + [completionExpectation fulfill]; + }]; + + [self waitForExpectations:@[ hookExpectation, completionExpectation ] timeout:2.0]; + + XCTAssertTrue([capturedRequest isKindOfClass:[NSMutableURLRequest class]]); + XCTAssertEqualObjects([capturedRequest valueForHTTPHeaderField:@"X-firebase-client"], + @"test-heartbeat"); +} + +/// Objects that are not blocks must be ignored rather than reinterpreted. +/// +/// `NSBlockOperation` is the case that matters, and the only one that tells +/// the two candidate filters apart. A class-name substring check admits it, +/// because "NSBlockOperation" contains "Block", and then bit-casts an +/// `NSOperation` into a callable; invoking that exits the process with +/// SIGSEGV. An `NSBlock` ancestry check rejects it. A non-block that is also +/// not named "...Block...", such as an `NSString`, is rejected by both and so +/// pins neither. +/// +/// The valid hook is last so that rejecting an element cannot be mistaken for +/// abandoning the rest of the array. +- (void)testNonBlockRequestHooksAreIgnored { + XCTestExpectation *hookExpectation = [self expectationWithDescription:@"request hook called"]; + XCTestExpectation *completionExpectation = [self expectationWithDescription:@"completion called"]; + + void (^hook)(NSMutableURLRequest *) = ^(NSMutableURLRequest *request) { + [hookExpectation fulfill]; + }; + + NSBlockOperation *blockOperation = [NSBlockOperation blockOperationWithBlock:^{ + XCTFail(@"A non-block request hook must never be invoked."); + }]; + + _GACAppCheckAPIService *apiService = + [[_GACAppCheckAPIService alloc] initWithURLSession:[self stubSession] + baseURL:nil + APIKey:@"key" + requestHooks:@[ blockOperation, @"not a block", hook ]]; + + [apiService sendRequestWithURL:[NSURL URLWithString:@"https://test.local"] + httpMethod:@"GET" + body:nil + additionalHeaders:nil + completionHandler:^(id response, NSError *_Nullable error) { + [completionExpectation fulfill]; + }]; + + [self waitForExpectations:@[ hookExpectation, completionExpectation ] timeout:2.0]; +} + +#if SWIFT_PACKAGE && (TARGET_OS_IOS || TARGET_OS_VISION) && !TARGET_OS_MACCATALYST +- (void)testRecaptchaProviderRequestHooksBridging { + void (^hook)(NSMutableURLRequest *) = ^(NSMutableURLRequest *r) { + }; + (void)[[GACRecaptchaProvider alloc] initWithSiteKey:@"key" + resourceName:@"projects/p/apps/a" + APIKey:@"key" + requestHooks:@[ hook ]]; +} +#endif + +@end diff --git a/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift b/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift index a36fab5f..6481cf05 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 { @@ -60,8 +60,20 @@ final class AppCheckAPITests { keychainAccessGroup: appGroupID ) + let appCheckProtocol: AppCheckCoreProtocol = appCheck + _ = appCheckProtocol + + // MARK: - AppCheckCoreSettings + + let settings = AppCheckCoreSettings() + settings.isTokenAutoRefreshEnabled = true + _ = settings.isTokenAutoRefreshEnabled + + let customSettings = CustomSubclassSettings() + _ = customSettings.isTokenAutoRefreshEnabled + // 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 +81,20 @@ 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 - // ... - } + Task { + 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,33 +102,30 @@ 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 - // ... - } + Task { + do { + let token = try await appCheck.limitedUseToken() + _ /* token */ = token.token + } catch { + _ /* error */ = error } } // MARK: - `AppCheckDebugProvider` + let dummyHook: AppCheckCoreAPIRequestHook = { _ in } + let requestHooks: [AppCheckCoreAPIRequestHook] = [dummyHook] + // `AppCheckDebugProvider` initializer - // TODO(andrewheard): Add `requestHooks` in API tests. let debugProvider = AppCheckCoreDebugProvider( serviceName: serviceName, resourceName: resourceName, baseURL: nil, apiKey: apiKey, - requestHooks: nil + requestHooks: requestHooks ) // Get token debugProvider.getToken { token, error in @@ -133,14 +137,11 @@ final class AppCheckAPITests { } // 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 { - do { - _ = try await debugProvider.getToken() - } catch { - // ... - } + Task { + do { + _ = try await debugProvider.getToken() + } catch { + // ... } } @@ -152,10 +153,36 @@ final class AppCheckAPITests { let token = AppCheckCoreToken(token: "token", expirationDate: Date.distantFuture) _ = token.token _ = token.expirationDate + _ = token.receivedAtDate + + let tokenWithReceivedAt = AppCheckCoreToken( + token: "token", + expirationDate: Date.distantFuture, + receivedAt: Date() + ) + _ = tokenWithReceivedAt.receivedAtDate + + // MARK: - AppCheckCoreTokenResult + + let successResult = AppCheckCoreTokenResult(token: token) + _ = successResult.token + _ = successResult.error + + let failureResult = AppCheckCoreTokenResult(error: NSError(domain: "test", code: 1)) + _ = failureResult.token + _ = failureResult.error + + let designatedResult = AppCheckCoreTokenResult(token: token, error: nil) + _ = designatedResult.token + _ = designatedResult.error + + _ = AppCheckCoreTokenResult.placeholderToken() // MARK: - AppCheckErrors - appCheck.token(forcingRefresh: false) { result in + _ = AppCheckCoreErrorDomain + + appCheck.token(forcingRefresh: false, completion: { result in if let error = result.error { switch error { case AppCheckCoreErrorCode.unknown: @@ -173,7 +200,7 @@ final class AppCheckAPITests { } } // ... - } + }) // MARK: - AppCheckProvider @@ -186,34 +213,29 @@ final class AppCheckAPITests { // `DeviceCheckProvider` initializer #if !os(watchOS) - if #available(iOS 11.0, macOS 10.15, macCatalyst 13.0, tvOS 11.0, *) { - // TODO(andrewheard): Add `requestHooks` in API tests. - let deviceCheckProvider = AppCheckCoreDeviceCheckProvider( - serviceName: serviceName, - resourceName: resourceName, - apiKey: apiKey, - requestHooks: nil - ) - // Get token - deviceCheckProvider.getToken { token, error in - if let _ /* error */ = error { - // ... - } else if let _ /* token */ = token { - // ... - } + // TODO(andrewheard): Add `requestHooks` in API tests. + let deviceCheckProvider = AppCheckCoreDeviceCheckProvider( + serviceName: serviceName, + resourceName: resourceName, + apiKey: apiKey, + requestHooks: nil as [AppCheckCoreAPIRequestHook]? + ) + // Get token + deviceCheckProvider.getToken { token, error in + if let _ /* error */ = error { + // ... + } else if let _ /* token */ = token { + // ... } - // Get token (async/await) - if #available(iOS 13.0, tvOS 13.0, *) { - // async/await is only available on iOS 13+ - Task { - do { - _ = try await deviceCheckProvider.getToken() - } catch AppCheckCoreErrorCode.unsupported { - // ... - } catch { - // ... - } - } + } + // Get token (async/await) + Task { + do { + _ = try await deviceCheckProvider.getToken() + } catch AppCheckCoreErrorCode.unsupported { + // ... + } catch { + // ... } } #endif // !os(watchOS) @@ -223,21 +245,22 @@ 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 .invalidRequestHook: 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 } } @@ -261,6 +284,8 @@ class DummyAppCheckSettings: NSObject, AppCheckCoreSettingsProtocol { var isTokenAutoRefreshEnabled: Bool = true } +class CustomSubclassSettings: AppCheckCoreSettings {} + class DummyAppCheckTokenDelegate: NSObject, AppCheckCoreTokenDelegate { func tokenDidUpdate(_ token: AppCheckCoreToken, serviceName: String) {} } 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..097d18a4 --- /dev/null +++ b/AppCheckCore/Tests/Unit/Utils/AppCheckCoreFixtureLoader.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 + +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) + } + } + } + #if SWIFT_PACKAGE + bundlesForResources.append(Bundle.module) + #endif + + 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..1e90f4c8 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 @@ -47,13 +46,17 @@ public final class AppCheckRecaptchaProvider: NSObject, AppCheckCoreProvider { /// - resourceName: The name of the resource protected by App Check; for a Firebase App this is /// "projects/{project_id}/apps/{app_id}". /// - APIKey: The Google Cloud Platform API key. - /// - requestHooks: Hooks that will be invoked on requests through this service. - // `@convention(block)` is required because the Swift compiler cannot automatically - // bridge collections of closures (like an Array) to Objective-C blocks. This attribute - // changes the closure's representation to match the Objective-C block heap layout. + /// - requestHooks: Hooks invoked on each outgoing request. From Swift, pass + /// `[AppCheckCoreAPIRequestHook]`. From Objective-C, pass an `NSArray` of blocks with the + /// signature `void (^)(NSMutableURLRequest *)`; the signature is not checked at compile + /// time and a mismatch will crash when the hook is invoked. + /// + /// Typed `[Any]?` rather than `[AppCheckCoreAPIRequestHook]?` deliberately: Swift cannot + /// bridge an `NSArray` into a Swift `Array` whose element is a function type, so the typed + /// signature traps at runtime for any non-nil array passed from Objective-C. Do not + /// "simplify" this type — see PR #111. @objc public convenience init?(siteKey: String, resourceName: String, APIKey: String, - requestHooks: [@convention(block) (NSMutableURLRequest) -> Void]? = - nil) { + requestHooks: [Any]? = nil) { self.init( siteKey: siteKey, resourceName: resourceName, @@ -63,15 +66,29 @@ public final class AppCheckRecaptchaProvider: NSObject, AppCheckCoreProvider { ) } + /// - Parameters: + /// - siteKey: The reCAPTCHA site key. + /// - resourceName: The name of the resource protected by App Check; for a Firebase App this is + /// "projects/{project_id}/apps/{app_id}". + /// - APIKey: The Google Cloud Platform API key. + /// - requestHooks: Hooks invoked on each outgoing request. From Swift, pass + /// `[AppCheckCoreAPIRequestHook]`. From Objective-C, pass an `NSArray` of blocks with the + /// signature `void (^)(NSMutableURLRequest *)`; the signature is not checked at compile + /// time and a mismatch will crash when the hook is invoked. + /// + /// Typed `[Any]?` rather than `[AppCheckCoreAPIRequestHook]?` deliberately: Swift cannot + /// bridge an `NSArray` into a Swift `Array` whose element is a function type, so the typed + /// signature traps at runtime for any non-nil array passed from Objective-C. Do not + /// "simplify" this type — see PR #111. + /// - actionName: The reCAPTCHA custom action name. @objc public convenience init?(siteKey: String, resourceName: String, APIKey: String, - requestHooks: [@convention(block) (NSMutableURLRequest) -> Void]? = - nil, + requestHooks: [Any]? = nil, actionName: String) { guard let sdk = RecaptchaEnterpriseSDKLoader(customAction: actionName) else { return nil } - let backoffWrapper = _GACAppCheckBackoffWrapper() + let backoffWrapper = AppCheckCoreBackoffWrapper() let tokenGenerator = RecaptchaTokenGenerator( siteKey: siteKey, recaptchaAction: sdk.action, @@ -80,8 +97,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 +116,58 @@ 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 - handler(token, nil) - }.catch { error in - handler(nil, error) + Task { + do { + let token = try await getToken(limitedUse: false) + Self.deliverOnMainQueue(token, error: nil as Error?, to: handler) + } catch { + Self.deliverOnMainQueue(nil, error: error, to: handler) } + } } @objc(getLimitedUseTokenWithCompletion:) public func getLimitedUseToken(completion handler: @escaping (AppCheckCoreToken?, (any Error)?) -> Void) { - getToken(limitedUse: true) - .then { token in - handler(token, nil) - }.catch { error in - handler(nil, error) + Task { + do { + let token = try await getToken(limitedUse: true) + Self.deliverOnMainQueue(token, error: nil as Error?, to: handler) + } catch { + Self.deliverOnMainQueue(nil, error: error, to: handler) } + } } - 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() + } + let recaptchaToken = try await tokenGenerator.getRecaptchaToken() + return try await apiService.appCheckToken( + with: recaptchaToken, + limitedUse: limitedUse + ) + } + + private static func deliverOnMainQueue(_ result: T, + error: E?, + to completion: @escaping (T, E?) + -> Void) { + nonisolated(unsafe) let completion = completion + DispatchQueue.main.async { + completion(result, error) } - return tokenGenerator.getRecaptchaToken() - .then { recaptchaToken in - self.apiService.appCheckToken( - with: recaptchaToken, - limitedUse: limitedUse - ) - } } } diff --git a/AppCheckRecaptchaProvider/Sources/RecaptchaAPIService.swift b/AppCheckRecaptchaProvider/Sources/RecaptchaAPIService.swift index 1e5d501a..855c2d95 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 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..321ceefd 100644 --- a/AppCheckRecaptchaProvider/Sources/RecaptchaTokenGenerator.swift +++ b/AppCheckRecaptchaProvider/Sources/RecaptchaTokenGenerator.swift @@ -15,10 +15,8 @@ #if SWIFT_PACKAGE import AppCheckCore #endif -import FBLPromises import Foundation -import Promises -import RecaptchaInterop +@preconcurrency import RecaptchaInterop @available(iOS 15.0, visionOS 1.0, *) @available(macOS, unavailable) @@ -26,102 +24,75 @@ 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: AppCheckCoreBackoffWrapperProtocol init(siteKey: String, recaptchaAction: RCAActionProtocol, recaptchaClass: RCARecaptchaProtocol.Type, - backoffWrapper: _GACAppCheckBackoffWrapperProtocol) { + backoffWrapper: AppCheckCoreBackoffWrapperProtocol) { 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 withSafeCheckedThrowingContinuation { 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)) - } - } - } - return swiftPromise.asObjCPromise() - } + func getRecaptchaToken() async throws -> String { + let client = try await recaptchaClientTask.value - let errorHandler: GACAppCheckBackoffErrorHandler = { error in - let nsError = error as NSError - if nsError.domain == AppCheckCoreErrorDomain && nsError.code == AppCheckCoreErrorCode - .serverUnreachable.rawValue { - return .typeExponential + let operationProvider: () async throws -> String = { + try await withSafeCheckedThrowingContinuation { continuation in + let recaptchaAction = self.recaptchaAction + client.execute(withAction: recaptchaAction) { token, error in + if let token { + continuation.resume(returning: token) + } else { + continuation.resume(throwing: Self.mapRecaptchaError(error)) + } } - return .typeNone } + } - let fblPromise = self.backoffWrapper.applyBackoff( - toOperation: operationProvider, - errorHandler: errorHandler - ) - - return Promise(fblPromise).then { result in - guard let token = result as? String else { - throw _GACAppCheckErrorUtil - .error( - withFailureReason: "Unexpected result type from reCAPTCHA token exchange: \(type(of: result)). Expected String." - ) - } - return token + let errorHandler: (Error) -> AppCheckCoreBackoffType = { error in + let nsError = error as NSError + if nsError.domain == AppCheckCoreErrorDomain && nsError.code == AppCheckCoreErrorCode + .serverUnreachable.rawValue { + return .exponential } + return .none } + + return try await backoffWrapper.applyBackoffToOperation( + operationProvider, + errorHandler: errorHandler + ) } - 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 - } - return NSError( - domain: AppCheckCoreErrorDomain, - code: AppCheckCoreErrorCode.unknown.rawValue, - userInfo: userInfo - ) + return AppCheckCoreErrorUtil.unknownError(with: error) } } 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 26f972ed..19a61837 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,65 +102,60 @@ class MockAppCheckCoreAPIService: NSObject, _GACAppCheckAPIServiceProtocol { additionalHeaders: additionalHeaders ) - let promise = Promise<_GACURLSessionDataResponse>.pending() - if let expectedError { - promise.reject(expectedError) + throw expectedError } else { - let response = expectedResponse ?? _GACURLSessionDataResponse( - response: HTTPURLResponse(), + let response = expectedResponse ?? AppCheckCoreURLSessionDataResponse( + response: HTTPURLResponse( + url: URL(string: "https://test.com")!, + statusCode: 200, + httpVersion: nil, + headerFields: nil + )!, httpBody: Data(), - request: Date() + requestDate: Date() ) - promise.fulfill(response) + return response } - - return promise.asObjCPromise() } - func appCheckToken(withAPIResponse response: _GACURLSessionDataResponse) - -> FBLPromise { - let promise = Promise.pending() - + func appCheckToken(withAPIResponse response: AppCheckCoreURLSessionDataResponse) 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, AppCheckCoreBackoffWrapperProtocol { var applyBackoffCalled = false var shouldReturnError = false var mockError: NSError? var mockResult: Any? - var capturedErrorHandler: GACAppCheckBackoffErrorHandler? + var capturedErrorHandler: ((Error) -> AppCheckCoreBackoffType)? - func applyBackoff(toOperation operationProvider: @escaping GACAppCheckBackoffOperationProvider, - errorHandler: @escaping GACAppCheckBackoffErrorHandler) - -> FBLPromise { + func applyBackoffToOperation(_ operationProvider: @escaping () async throws -> T, + errorHandler: @escaping (Error) + -> AppCheckCoreBackoffType) async throws -> T { 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() + if let mockResult = mockResult as? T { + return mockResult } - return operationProvider() + return try await operationProvider() } - func defaultAppCheckProviderErrorHandler() -> GACAppCheckBackoffErrorHandler { - return { error in .typeExponential } + func defaultAppCheckProviderErrorHandler() -> (Error) -> AppCheckCoreBackoffType { + 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/CHANGELOG.md b/CHANGELOG.md index 66e8029d..56ae7a51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,29 @@ +# 12.0.0 +- [changed] Ported AppCheckCore from Objective-C to Swift. +- [changed] Raised Swift tools version to 6.0 for Swift Package Manager. +- [changed] **Breaking change**: AppCheckCore's minimum supported versions have + updated for the following platforms: + - | Platform | AppCheckCore 12 | + | ------------- | ------------- | + | iOS | **15.0** | + | macOS | **11.0** | + | tvOS | **15.0** | + | watchOS | **8.0** | + | Mac Catalyst | **15.0** | +- [changed] **Breaking change**: The `GACAppCheckErrorDomain` global constant is no longer visible to Objective-C consumers due to the Swift migration. Use `GACAppCheckErrors.errorDomain` instead. + - Before: `if ([error.domain isEqualToString:GACAppCheckErrorDomain]) { ... }` + - After: `if ([error.domain isEqualToString:GACAppCheckErrors.errorDomain]) { ... }` +- [changed] **Breaking change**: Objective-C classes can no longer be subclassed (Swift classes are `objc_subclassing_restricted`). Notably `GACAppCheckSettings` / `FIRAppCheckSettings`. +- [changed] **Breaking change**: `GACAppCheckTokenResult` is now `final`. +- [changed] **Breaking change**: `requestHooks:` is now typed `NSArray *` instead of `NSArray *`; blocks must match `void (^)(NSMutableURLRequest *)` exactly or they will crash when invoked. +- [changed] Forced token refreshes are no longer coalesced with an in-flight unforced refresh. This resolves TODO(#42) from v11's `GACAppCheck.m`. +- [fixed] `GACRecaptchaProvider`'s `requestHooks:` parameter now accepts `NSArray *` / `[Any]?` fixing an `@objc` argument bridging trap when passing request hooks, and establishing `requestHooks:` bridging parity with other providers. + # 11.3.2 -- [fixed] Fixed an issue where the time-to-live (TTL) for a cached token was calculated from the - moment the token response was processed locally, rather than when the request was initiated, - which could lead to artificially extended token lifetimes during app suspension. +- [fixed] Fixed an issue where the time-to-live (TTL) for a cached token was + calculated from the moment the token response was processed locally, rather + than when the request was initiated, which could lead to artificially extended + token lifetimes during app suspension. (https://github.com/firebase/firebase-ios-sdk/issues/16573) # 11.3.1 diff --git a/Package.swift b/Package.swift index 13691bc5..6e80cfc7 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.5 +// swift-tools-version:6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. // Copyright 2023 Google LLC @@ -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(.v15), .macCatalyst(.v15), .macOS(.v11), .tvOS(.v15), .watchOS(.v8)], 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"), ], @@ -61,14 +56,13 @@ let package = Package( linkerSettings: [ .linkedFramework( "DeviceCheck", - .when(platforms: [.iOS, .macCatalyst, .macOS, .tvOS, .appCheckVisionOS]) + .when(platforms: [.iOS, .macCatalyst, .macOS, .tvOS, .visionOS]) ), ]), .target(name: "AppCheckRecaptchaProvider", dependencies: [ "AppCheckCore", .product(name: "RecaptchaInterop", package: "interop-ios-for-google-sdks"), - .product(name: "Promises", package: "Promises"), ], path: "AppCheckRecaptchaProvider/Sources"), .testTarget( @@ -78,9 +72,10 @@ let package = Package( ], path: "AppCheckCore/Tests", exclude: [ - // Swift tests are in the target `AppCheckCoreUnitSwift` since mixed language targets are - // not supported (as of Xcode 14.3). + // Swift tests and ObjC tests are separated since mixed language targets are + // not supported. "Unit/Swift", + "Unit/ObjC", ], resources: [ .process("Fixture"), @@ -97,6 +92,18 @@ let package = Package( .headerSearchPath("../.."), ] ), + .testTarget( + name: "AppCheckCoreUnitObjC", + dependencies: [ + "AppCheckCore", + "AppCheckRecaptchaProvider", + ], + path: "AppCheckCore/Tests/Unit/ObjC", + cSettings: [ + .headerSearchPath("../.."), + .headerSearchPath("../../../Sources/Public"), + ] + ), .testTarget( name: "AppCheckRecaptchaProviderUnit", dependencies: [ @@ -104,21 +111,6 @@ let package = Package( ], path: "AppCheckRecaptchaProvider/Tests" ), - ] + ], + swiftLanguageModes: [.v5] ) - -extension Platform { - // Xcode dependent value for the visionOS platform. Namespaced with an "appCheck" prefix to - // prevent any API collisions (such issues should not arise as the manifest APIs should be - // confined to the `Package.swift`). - static var appCheckVisionOS: Self { - #if swift(>=5.9) - // For Xcode 15, return the available `visionOS` platform. - return .visionOS - #else - // For Xcode 14, return `iOS` as `visionOS` is unavailable. Since all targets support iOS, - // this acts as a no-op. - return .iOS - #endif // swift(>=5.9) - } -} diff --git a/agents.md b/agents.md index 476ec2cb..da5579e0 100644 --- a/agents.md +++ b/agents.md @@ -157,11 +157,40 @@ you may encounter the following blockers. Use these workarounds: external scripts fail, create a local wrapper script that forwards to `python3` and add it to the `PATH`: `mkdir -p tmp/bin && echo '#!/bin/sh\nexec python3 "$@"' > tmp/bin/python && chmod +x tmp/bin/python && export PATH="$PWD/tmp/bin:$PATH"` -- **Ruby Version Conflicts**: External scripts (like `pod_lib_lint.rb`) may - fail if `rbenv` tries to use the external repo's `.ruby-version`. Force the - local Ruby version by prefixing the command with `RBENV_VERSION=2.7.5`. +- **Ruby Version Conflicts**: Run external scripts from the repo root and let + `rbenv` pick up this repo's `.ruby-version` (currently **3.4.1**). Do **not** + force an older Ruby. + > ⚠️ Previous guidance here said to prefix with `RBENV_VERSION=2.7.5`. That + > is now **actively harmful**: this repo's `Gemfile.lock` pins + > `activesupport 7.2.3.1`, which requires Ruby >= 3.1, so forcing 2.7.5 fails + > with `Could not find activesupport-7.2.3.1 … (Bundler::GemNotFound)`. + > Running with no override succeeds. - **Quality Gates**: Do not skip `style.sh` and `pod_lib_lint.rb`. They are critical for verification. +- **`swift test` Does Not Cover `pod_lib_lint`**: A fully green `swift test` + can still fail `pod lib lint` with `Sendable` / `non-Sendable capture` + errors. The difference is **warning escalation, not warning visibility**. + - `swift build` / `swift test` *do* emit these diagnostics — verified: all + four `#SendableClosureCaptures` warnings appear on a plain + `swift build --disable-sandbox`, at the same lines the lint reported. + SwiftPM just exits **0**, so they scroll past in the build noise, and + `swift test` output buries them under the test summary. + - `pod lib lint` passes `--warnings-as-errors` semantics by default, so the + same warnings become a hard validation failure. + - Both toolchains are in Swift 5 language mode (`swiftLanguageModes: [.v5]` + in `Package.swift`; `s.swift_version = '5.5'` → `SWIFT_VERSION` → + `-swift-version 5` in the podspec), so this is **not** a language-mode + divergence. Do not "fix" it by changing either setting. + - Reproduce in seconds instead of a ~5 minute lint round-trip — just grep + your own build output, no extra flags needed: + `swift build --disable-sandbox 2>&1 | grep -E "warning:.*(Sendable|non-Sendable)"` + - Avoid `-Xswiftc -strict-concurrency=complete` as the repro: it is + *stricter* than the lint and adds `sending` / `#SendingRisksDataRace` + diagnostics the lint does not enforce, which sends you chasing phantoms. + - For a main-queue hop of caller-supplied objects (delegates, completion + handlers), prefer a `nonisolated(unsafe) let` rebinding over declaring the + types `Sendable` — the library cannot promise thread-safety on a caller's + behalf, and it keeps the public API unchanged. - **Fixture Loading in Tests**: When running tests via `swift test` on macOS, `GACFixtureLoader` may fail to find JSON fixtures due to bundle resolution issues, causing tests to fail with `nil URL argument` exceptions. This is diff --git a/tools/generate_storage_fixtures.sh b/tools/generate_storage_fixtures.sh new file mode 100755 index 00000000..a6de173a --- /dev/null +++ b/tools/generate_storage_fixtures.sh @@ -0,0 +1,134 @@ +#!/usr/bin/env bash + +# 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. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then + cat << 'EOF' +Usage: generate_storage_fixtures.sh [GIT_TAG_OR_REF] + +Purpose: + Generates frozen legacy binary property list (.bin) fixtures from + Objective-C model definitions in historical git revisions. These + fixtures validate backward compatibility in Swift unarchiving tests. + +Arguments: + GIT_TAG_OR_REF Git tag, branch, or commit SHA containing legacy + Objective-C models (default: 11.0.0). + +Examples: + ./tools/generate_storage_fixtures.sh + ./tools/generate_storage_fixtures.sh 11.0.0 + ./tools/generate_storage_fixtures.sh 10.18.0 +EOF + exit 0 +fi + +TAG="${1:-11.0.0}" + +if ! git -C "${REPO_ROOT}" rev-parse --verify --quiet "${TAG}^{commit}" >/dev/null; then + echo "Error: Git ref '${TAG}' does not exist." >&2 + exit 1 +fi + +if ! git -C "${REPO_ROOT}" cat-file -e "${TAG}:AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.h" 2>/dev/null; then + echo "Error: Git ref '${TAG}' lacks legacy Objective-C storage models (expected AppCheckCore <= 11.x)." >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "${TMP_DIR}"' EXIT + +echo "Extracting legacy source files from git ref ${TAG}..." +mkdir -p "${TMP_DIR}/AppCheckCore/Sources/Core/Storage" +mkdir -p "${TMP_DIR}/AppCheckCore/Sources/AppAttestProvider/Storage" + +git -C "${REPO_ROOT}" show "${TAG}:AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.h" > "${TMP_DIR}/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.h" +git -C "${REPO_ROOT}" show "${TAG}:AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.m" > "${TMP_DIR}/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.m" +git -C "${REPO_ROOT}" show "${TAG}:AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.h" > "${TMP_DIR}/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.h" +git -C "${REPO_ROOT}" show "${TAG}:AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.m" > "${TMP_DIR}/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.m" + +cat << 'EOF' > "${TMP_DIR}/main.m" +#import +#import "AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.h" +#import "AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.h" + +int main(int argc, const char * argv[]) { + @autoreleasepool { + if (argc < 3) { + NSLog(@"Usage: %s ", argv[0]); + return 1; + } + NSString *tokenPath = [NSString stringWithUTF8String:argv[1]]; + NSString *artifactPath = [NSString stringWithUTF8String:argv[2]]; + + // 1. Generate GACAppCheckStoredToken fixture + GACAppCheckStoredToken *token = [[GACAppCheckStoredToken alloc] init]; + token.token = @"test_legacy_app_check_token_value"; + token.expirationDate = [NSDate dateWithTimeIntervalSince1970:1800000000]; + token.receivedAtDate = [NSDate dateWithTimeIntervalSince1970:1700000000]; + + NSError *error = nil; + NSData *tokenData = [NSKeyedArchiver archivedDataWithRootObject:token requiringSecureCoding:YES error:&error]; + if (!tokenData || error) { + NSLog(@"Failed to archive GACAppCheckStoredToken: %@", error); + return 1; + } + if (![tokenData writeToFile:tokenPath atomically:YES]) { + NSLog(@"Failed to write token data to %@", tokenPath); + return 1; + } + NSLog(@"Wrote GACAppCheckStoredToken fixture to %@", tokenPath); + + // 2. Generate GACAppAttestStoredArtifact fixture + NSString *keyID = @"test_legacy_key_id_12345"; + NSData *artifactData = [@"test_legacy_artifact_data_bytes" dataUsingEncoding:NSUTF8StringEncoding]; + GACAppAttestStoredArtifact *artifact = [[GACAppAttestStoredArtifact alloc] initWithKeyID:keyID artifact:artifactData]; + + NSData *archivedArtifact = [NSKeyedArchiver archivedDataWithRootObject:artifact requiringSecureCoding:YES error:&error]; + if (!archivedArtifact || error) { + NSLog(@"Failed to archive GACAppAttestStoredArtifact: %@", error); + return 1; + } + if (![archivedArtifact writeToFile:artifactPath atomically:YES]) { + NSLog(@"Failed to write artifact data to %@", artifactPath); + return 1; + } + NSLog(@"Wrote GACAppAttestStoredArtifact fixture to %@", artifactPath); + } + return 0; +} +EOF + +echo "Compiling generator with clang..." +clang -fobjc-arc -framework Foundation \ + -isysroot "$(xcrun --sdk macosx --show-sdk-path)" \ + -I "${TMP_DIR}" \ + "${TMP_DIR}/main.m" \ + "${TMP_DIR}/AppCheckCore/Sources/Core/Storage/GACAppCheckStoredToken.m" \ + "${TMP_DIR}/AppCheckCore/Sources/AppAttestProvider/Storage/GACAppAttestStoredArtifact.m" \ + -o "${TMP_DIR}/generate_fixtures" + +echo "Running generator..." +FIXTURES_DIR="${REPO_ROOT}/AppCheckCore/Tests/Fixture" +mkdir -p "${FIXTURES_DIR}" +"${TMP_DIR}/generate_fixtures" \ + "${FIXTURES_DIR}/GACAppCheckStoredToken.bin" \ + "${FIXTURES_DIR}/GACAppAttestStoredArtifact.bin" + +echo "Fixture generation complete."