From c9f9a846d96e5fd63c8ad8d7a47f8869f1f326de Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Wed, 9 Sep 2026 18:19:20 -0400 Subject: [PATCH 1/3] fix(core): make keychain token caching non-fatal When GACAppCheck refreshes a token, write failures to Keychain storage (e.g., in swift test or un-entitled environments) rejected the promise, discarding the fetched token and returning GACAppCheckErrorCodeKeychain. Treat storage write errors as non-fatal by logging a warning with code GACLoggerAppCheckMessageCodeTokenStorageFailed and returning the valid token. Maintain an in-memory token cache to serve unexpired tokens without repeated Keychain IPC, falling back to memory when Keychain is unavailable. Invalidate the in-memory cache on forced refresh, and expand unit test coverage. --- AppCheckCore/Sources/Core/GACAppCheck.m | 25 ++- .../Public/AppCheckCore/GACAppCheckErrors.h | 2 + .../Tests/Unit/Core/GACAppCheckTests.m | 211 ++++++++++++++++-- 3 files changed, 215 insertions(+), 23 deletions(-) diff --git a/AppCheckCore/Sources/Core/GACAppCheck.m b/AppCheckCore/Sources/Core/GACAppCheck.m index 4492e32..b111df5 100644 --- a/AppCheckCore/Sources/Core/GACAppCheck.m +++ b/AppCheckCore/Sources/Core/GACAppCheck.m @@ -53,6 +53,8 @@ @interface GACAppCheck () @property(nonatomic, nullable) FBLPromise *ongoingRetrieveOrRefreshTokenPromise; +@property(nonatomic, strong, nullable) GACAppCheckToken *inMemoryToken; + @end @implementation GACAppCheck @@ -163,24 +165,33 @@ - (void)limitedUseTokenWithCompletion:(GACAppCheckTokenHandler)handler { }); } +- (BOOL)isTokenExpiredOrExpiresSoon:(GACAppCheckToken *)token { + return [token.expirationDate timeIntervalSinceNow] < kTokenExpirationThreshold; +} + - (FBLPromise *)getCachedValidTokenForcingRefresh:(BOOL)forcingRefresh { if (forcingRefresh) { + self.inMemoryToken = nil; FBLPromise *rejectedPromise = [FBLPromise pendingPromise]; [rejectedPromise reject:[_GACAppCheckErrorUtil cachedTokenNotFound]]; return rejectedPromise; } + GACAppCheckToken *inMemoryToken = self.inMemoryToken; + if (inMemoryToken != nil && ![self isTokenExpiredOrExpiresSoon:inMemoryToken]) { + return [FBLPromise resolvedWith:inMemoryToken]; + } + return [self.storage getToken].then(^id(GACAppCheckToken *_Nullable token) { if (token == nil) { return [_GACAppCheckErrorUtil cachedTokenNotFound]; } - BOOL isTokenExpiredOrExpiresSoon = - [token.expirationDate timeIntervalSinceNow] < kTokenExpirationThreshold; - if (isTokenExpiredOrExpiresSoon) { + if ([self isTokenExpiredOrExpiresSoon:token]) { return [_GACAppCheckErrorUtil cachedTokenExpired]; } + self.inMemoryToken = token; return token; }); } @@ -191,7 +202,13 @@ - (void)limitedUseTokenWithCompletion:(GACAppCheckTokenHandler)handler { [self.appCheckProvider getTokenWithCompletion:handler]; }] .then(^id _Nullable(GACAppCheckToken *_Nullable token) { - return [self.storage setToken:token]; + self.inMemoryToken = token; + return [self.storage setToken:token].recover(^id _Nullable(NSError *_Nonnull error) { + NSString *logMessage = + [NSString stringWithFormat:@"Failed to cache App Check token: %@", error]; + GACAppCheckLogWarning(GACLoggerAppCheckMessageCodeTokenStorageFailed, logMessage); + return token; + }); }) .then(^id _Nullable(GACAppCheckToken *_Nullable token) { // TODO: Make sure the self.tokenRefresher is updated only once. Currently the timer will be diff --git a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h index 6a2e9e9..b8f00db 100644 --- a/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h +++ b/AppCheckCore/Sources/Public/AppCheckCore/GACAppCheckErrors.h @@ -46,6 +46,8 @@ typedef NS_ENUM(NSInteger, GACAppCheckMessageCode) { // App Check GACLoggerAppCheckMessageCodeProviderIsMissing = 2002, GACLoggerAppCheckMessageCodeStagingModeEnabled = 2003, + /// Failed to cache the App Check token in persistent storage. + GACLoggerAppCheckMessageCodeTokenStorageFailed = 2004, GACLoggerAppCheckMessageCodeUnexpectedHTTPCode = 3001, // Debug Provider diff --git a/AppCheckCore/Tests/Unit/Core/GACAppCheckTests.m b/AppCheckCore/Tests/Unit/Core/GACAppCheckTests.m index e6a9297..2badd85 100644 --- a/AppCheckCore/Tests/Unit/Core/GACAppCheckTests.m +++ b/AppCheckCore/Tests/Unit/Core/GACAppCheckTests.m @@ -356,57 +356,230 @@ - (void)testGetToken_WhenCalledSeveralTimesSuccess_ThenThereIsOnlyOneOperation { XCTAssertEqual(self.fakeTokenRefresher.updateWithRefreshResultCallCount, 1); XCTAssertEqual(self.fakeTokenDelegate.tokenDidUpdateCallCount, 1); - // 5. Check a get token call after. - [self assertGetToken_WhenCachedTokenIsValid_Success]; + // 5. Check a get token call after returns the cached token without re-fetching. + XCTestExpectation *afterExpectation = [self expectationWithDescription:@"getTokenAfter"]; + [self.appCheck tokenForcingRefresh:NO + completion:^(GACAppCheckTokenResult *result) { + [afterExpectation fulfill]; + XCTAssertEqualObjects(result.token, expectedToken); + XCTAssertNil(result.error); + }]; + [self waitForExpectations:@[ afterExpectation ] timeout:0.5]; + XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); } - (void)testGetToken_WhenCalledSeveralTimesError_ThenThereIsOnlyOneOperation { - // 1. Expect a token to be requested and stored. - NSArray * /*[expectedToken, storeTokenPromise]*/ expectedTokenAndPromise = - [self expectTokenRequestFromAppCheckProvider]; - FBLPromise *storeTokenPromise = expectedTokenAndPromise.lastObject; + // 1. Expect a token to be requested from storage, kept pending to merge multiple calls. + FBLPromise *storageGetPromise = [FBLPromise pendingPromise]; + self.fakeStorage.getTokenPromise = storageGetPromise; - // 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]; + // 1.1. Create an expected error to reject the provider request with later. + NSError *providerError = [self internalError]; + self.fakeAppCheckProvider.errorToReturn = providerError; - // 3. Request token several times. + // 2. 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. + // 2.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. + // 2.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); + XCTAssertEqualObjects(result.error, providerError); }]; } - // 3.3. Reject the pending promise to finish the get token operation. - [storeTokenPromise reject:storageError]; + // 2.3. Finish storage get with nil so it proceeds to refresh with provider. + [storageGetPromise fulfill:nil]; - // 4. Wait for expectations and validate mocks. + // 3. 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 + // After the first token generation fails, 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); + XCTAssertNil(self.fakeStorage.lastSetToken); XCTAssertEqual(self.fakeTokenRefresher.updateWithRefreshResultCallCount, 0); - // 5. Check a get token call after. + // 4. Check a get token call after. [self assertGetToken_WhenCachedTokenIsValid_Success]; } +- (void)testGetToken_WhenStorageFails_ThenTokenReturnedAndCachedInMemory { + // 1. Expect token to be requested from storage (cache miss) and provider to return a valid token. + self.fakeStorage.getTokenPromise = [FBLPromise resolvedWith:nil]; + + GACAppCheckToken *expectedToken = [self validToken]; + self.fakeAppCheckProvider.tokenToReturn = expectedToken; + + // 2. Make storage setToken fail with a keychain error. + NSError *storageError = [_GACAppCheckErrorUtil keychainErrorWithError:[self internalError]]; + FBLPromise *rejectedStoragePromise = [FBLPromise pendingPromise]; + [rejectedStoragePromise reject:storageError]; + self.fakeStorage.setTokenPromise = rejectedStoragePromise; + + // 3. Request token and verify it succeeds with the valid token despite storage failure. + XCTestExpectation *getTokenExpectation = [self expectationWithDescription:@"getToken"]; + [self.appCheck tokenForcingRefresh:NO + completion:^(GACAppCheckTokenResult *result) { + [getTokenExpectation fulfill]; + XCTAssertEqualObjects(result.token, expectedToken); + XCTAssertNil(result.error); + }]; + + [self waitForExpectations:@[ getTokenExpectation ] timeout:0.5]; + + XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); + XCTAssertEqualObjects(self.fakeStorage.lastSetToken, expectedToken); + XCTAssertEqual(self.fakeTokenRefresher.updateWithRefreshResultCallCount, 1); + XCTAssertEqual(self.fakeTokenDelegate.tokenDidUpdateCallCount, 1); + + // 4. Request token again: should be retrieved from in-memory cache without hitting provider or + // storage. + self.fakeStorage.getTokenPromise = [FBLPromise resolvedWith:nil]; + XCTestExpectation *cachedExpectation = [self expectationWithDescription:@"getCachedToken"]; + [self.appCheck tokenForcingRefresh:NO + completion:^(GACAppCheckTokenResult *result) { + [cachedExpectation fulfill]; + XCTAssertEqualObjects(result.token, expectedToken); + XCTAssertNil(result.error); + }]; + + [self waitForExpectations:@[ cachedExpectation ] timeout:0.5]; + + // Provider call count should remain 1. + XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); +} + +- (void)testGetToken_WhenForcingRefresh_ThenInMemoryCacheIsBypassed { + // 1. Prime the in-memory cache with an initial token. + self.fakeStorage.getTokenPromise = [FBLPromise resolvedWith:nil]; + GACAppCheckToken *token1 = [self validToken]; + self.fakeAppCheckProvider.tokenToReturn = token1; + self.fakeStorage.setTokenPromise = [FBLPromise resolvedWith:token1]; + + XCTestExpectation *expectation1 = [self expectationWithDescription:@"getToken1"]; + [self.appCheck tokenForcingRefresh:NO + completion:^(GACAppCheckTokenResult *result) { + [expectation1 fulfill]; + XCTAssertEqualObjects(result.token, token1); + XCTAssertNil(result.error); + }]; + [self waitForExpectations:@[ expectation1 ] timeout:0.5]; + XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); + + // 2. Request token with forcingRefresh:YES; should bypass in-memory cache and fetch new token. + GACAppCheckToken *token2 = [self validToken]; + self.fakeAppCheckProvider.tokenToReturn = token2; + self.fakeStorage.setTokenPromise = [FBLPromise resolvedWith:token2]; + + XCTestExpectation *expectation2 = [self expectationWithDescription:@"getToken2"]; + [self.appCheck tokenForcingRefresh:YES + completion:^(GACAppCheckTokenResult *result) { + [expectation2 fulfill]; + XCTAssertEqualObjects(result.token, token2); + XCTAssertNil(result.error); + }]; + [self waitForExpectations:@[ expectation2 ] timeout:0.5]; + + // Provider call count should now be 2. + XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 2); + + // 3. Subsequent call with forcingRefresh:NO should return token2 from in-memory cache without + // calling provider again. + XCTestExpectation *expectation3 = [self expectationWithDescription:@"getToken3"]; + [self.appCheck tokenForcingRefresh:NO + completion:^(GACAppCheckTokenResult *result) { + [expectation3 fulfill]; + XCTAssertEqualObjects(result.token, token2); + XCTAssertNil(result.error); + }]; + [self waitForExpectations:@[ expectation3 ] timeout:0.5]; + XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 2); +} + +- (void)testGetToken_WhenForcingRefresh_ThenInMemoryTokenIsInvalidated { + // 1. Prime the in-memory cache with an initial token. + self.fakeStorage.getTokenPromise = [FBLPromise resolvedWith:nil]; + GACAppCheckToken *token1 = [self validToken]; + self.fakeAppCheckProvider.tokenToReturn = token1; + self.fakeStorage.setTokenPromise = [FBLPromise resolvedWith:token1]; + + XCTestExpectation *expectation1 = [self expectationWithDescription:@"getToken1"]; + [self.appCheck tokenForcingRefresh:NO + completion:^(GACAppCheckTokenResult *result) { + [expectation1 fulfill]; + XCTAssertEqualObjects(result.token, token1); + }]; + [self waitForExpectations:@[ expectation1 ] timeout:0.5]; + XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); + + // 2. Force refresh with provider failure. + NSError *providerError = [self internalError]; + self.fakeAppCheckProvider.errorToReturn = providerError; + self.fakeAppCheckProvider.tokenToReturn = nil; + + XCTestExpectation *expectation2 = [self expectationWithDescription:@"getToken2"]; + [self.appCheck tokenForcingRefresh:YES + completion:^(GACAppCheckTokenResult *result) { + [expectation2 fulfill]; + XCTAssertEqualObjects(result.error, providerError); + }]; + [self waitForExpectations:@[ expectation2 ] timeout:0.5]; + XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 2); + + // 3. Now request token with forcingRefresh:NO; since token1 was invalidated, it should NOT return + // token1. Provider will be called again (or fail). + XCTestExpectation *expectation3 = [self expectationWithDescription:@"getToken3"]; + [self.appCheck tokenForcingRefresh:NO + completion:^(GACAppCheckTokenResult *result) { + [expectation3 fulfill]; + XCTAssertEqualObjects(result.error, providerError); + }]; + [self waitForExpectations:@[ expectation3 ] timeout:0.5]; + XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 3); +} + +- (void)testGetToken_WhenInMemoryTokenExpires_ThenRefreshesWithProvider { + // 1. Prime the in-memory cache with a soon-expiring token. + self.fakeStorage.getTokenPromise = [FBLPromise resolvedWith:nil]; + GACAppCheckToken *expiringToken = [self soonExpiringToken]; + self.fakeAppCheckProvider.tokenToReturn = expiringToken; + self.fakeStorage.setTokenPromise = [FBLPromise resolvedWith:expiringToken]; + + XCTestExpectation *expectation1 = [self expectationWithDescription:@"getToken1"]; + [self.appCheck tokenForcingRefresh:NO + completion:^(GACAppCheckTokenResult *result) { + [expectation1 fulfill]; + XCTAssertEqualObjects(result.token, expiringToken); + }]; + [self waitForExpectations:@[ expectation1 ] timeout:0.5]; + XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 1); + + // 2. Next call with forcingRefresh:NO detects in-memory token expires soon and refreshes. + GACAppCheckToken *newToken = [self validToken]; + self.fakeAppCheckProvider.tokenToReturn = newToken; + self.fakeStorage.setTokenPromise = [FBLPromise resolvedWith:newToken]; + + XCTestExpectation *expectation2 = [self expectationWithDescription:@"getToken2"]; + [self.appCheck tokenForcingRefresh:NO + completion:^(GACAppCheckTokenResult *result) { + [expectation2 fulfill]; + XCTAssertEqualObjects(result.token, newToken); + }]; + [self waitForExpectations:@[ expectation2 ] timeout:0.5]; + XCTAssertEqual(self.fakeAppCheckProvider.getTokenCallCount, 2); +} + #pragma mark - Helpers - (NSError *)internalError { From dda76bee233598ff85668797757fdffa7463a108 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 10 Sep 2026 11:33:49 -0400 Subject: [PATCH 2/3] Add CHANGELOG entry --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29c0f5e..64ea213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# Unreleased +- [changed] Made Keychain token caching errors non-fatal by logging a warning + and falling back to an in-memory cache, enabling token retrieval in + environments without Keychain access (such as `swift test` and CI). + # 11.3.1 - [fixed] Added recovery logic to reset and retry attestation when App Attest returns `DCErrorUnknownSystemFailure` during assertion From 729fa5b9081dc3e9035d66a210252b52eb5ecfd4 Mon Sep 17 00:00:00 2001 From: Andrew Heard Date: Thu, 10 Sep 2026 12:14:05 -0400 Subject: [PATCH 3/3] fix: add token storage code to exhaustive switch Add the missing `.loggerAppCheckMessageCodeTokenStorageFailed` case to the `AppCheckCoreMessageCode` switch statement in `AppCheckAPITests` to resolve an exhaustive switch warning on CI. --- AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift b/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift index a36fab5..30b8637 100644 --- a/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift +++ b/AppCheckCore/Tests/Unit/Swift/AppCheckAPITests.swift @@ -230,6 +230,7 @@ final class AppCheckAPITests { case .loggerAppCheckMessageCodeUnknown: break case .loggerAppCheckMessageCodeProviderIsMissing: break case .loggerAppCheckMessageCodeStagingModeEnabled: break + case .loggerAppCheckMessageCodeTokenStorageFailed: break case .loggerAppCheckMessageCodeUnexpectedHTTPCode: break case .loggerAppCheckMessageLocalDebugToken: break case .loggerAppCheckMessageEnvironmentVariableDebugToken: break