Skip to content

feat: add in-memory token caching - #114

Draft
andrewheard wants to merge 3 commits into
mainfrom
ah/keychain-non-fatal
Draft

feat: add in-memory token caching#114
andrewheard wants to merge 3 commits into
mainfrom
ah/keychain-non-fatal

Conversation

@andrewheard

@andrewheard andrewheard commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Added an in-memory token cache and made Keychain token caching failures non-fatal in GACAppCheck.

Previously, GACAppCheck.refreshToken failed the entire token request with GACAppCheckErrorCodeKeychain if writing to Keychain storage failed, discarding the valid fetched token. This meant that workflows in environments without Keychain entitlements, such as swift test on macOS and CI, were unsupported. Now, tokens are added to an in-memory cache that functions even if writes to Keychain can't be performed and offers faster read times in environments where the Keychain is available.

  • Non-Fatal Storage: Caught Keychain write errors during refreshToken, logged a warning (GACLoggerAppCheckMessageCodeTokenStorageFailed), and returned the valid token. Keychain writes are still attempted on every refresh to preserve persistence across cold launches.
  • In-Memory Cache: Added inMemoryToken to GACAppCheck. Unexpired tokens are now served directly from memory, eliminating redundant Keychain IPC queries.
  • Forced Refresh Invalidation: Cleared inMemoryToken when forcingRefresh: YES was requested so revoked tokens are not retained if a refresh fails.
  • Tests: Updated and expanded GACAppCheckTests.m to cover Keychain write error resilience, in-memory cache hits, forced refresh invalidation, and expiration fallback.
Walkthrough - In-Memory Fallback & Non-Fatal Keychain Storage

We investigated the token caching and Keychain error handling behavior in
AppCheckCore and resolved the issue where Keychain write failures (such as
those encountered in swift test or un-entitled environments) discarded
valid tokens and failed operations with GACAppCheckErrorCodeKeychain.

Summary of Changes

GACAppCheckErrors.h

  • Added documented message code:
    /// Failed to cache the App Check token in persistent storage.
    GACLoggerAppCheckMessageCodeTokenStorageFailed = 2004,

GACAppCheck.m

  • In-Memory Cache & Concurrency:
    • Added @property(nonatomic, strong, nullable) GACAppCheckToken *inMemoryToken;.
    • Removed anti-pattern @synchronized(self), relying on queue confinement
      consistent with ongoingRetrieveOrRefreshTokenPromise on
      FBLPromise.defaultDispatchQueue.
  • Forced Refresh Invalidation:
    • getCachedValidTokenForcingRefresh: explicitly sets self.inMemoryToken = nil;
      when forcingRefresh: YES is passed. If a token was revoked and a refresh
      fails, the revoked token is no longer retained in memory.
  • Cache Hierarchy (Memory -> Keychain -> Network):
    • Checks inMemoryToken first via extracted helper - (BOOL)isTokenExpiredOrExpiresSoon:(GACAppCheckToken *)token.
    • If absent or expiring within kTokenExpirationThreshold (5 min), checks
      Keychain storage.
    • On cache miss, expiration, or Keychain read error, refreshes from the
      provider.
  • Resilient Token Refresh:
    • In refreshToken, [self.storage setToken:token] catches Keychain write
      failures with .recover(^id _Nullable(NSError *_Nonnull error) { ... }).
    • On failure, logs a warning with message code 2004 and returns the valid
      token, allowing the caller to receive the fetched token without failing.
  • Cold Launch Persistence:
    • Every token refresh continues to attempt persisting to the Keychain,
      preserving persistence across app cold launches in typical environments.
  • Performance Improvement:
    • Bypassing redundant Keychain IPC (securityd) when a valid token is
      already held in memory significantly reduces token retrieval overhead.

GACAppCheckTests.m

  • Operation Merging Tests:
    • Updated testGetToken_WhenCalledSeveralTimesError_ThenThereIsOnlyOneOperation
      to test operation merging on provider failures now that storage failures
      are non-fatal.
    • Updated testGetToken_WhenCalledSeveralTimesSuccess_ThenThereIsOnlyOneOperation
      to verify subsequent calls reuse the in-memory cached token.
  • Storage Failure Resilience:
    • Added testGetToken_WhenStorageFails_ThenTokenReturnedAndCachedInMemory to
      verify that Keychain write errors log a warning, succeed with the valid
      token, and cache it in memory.
  • Cache Invalidation & Expiration:
    • Enhanced testGetToken_WhenForcingRefresh_ThenInMemoryCacheIsBypassed to
      verify the refreshed token is served on subsequent calls.
    • Added testGetToken_WhenForcingRefresh_ThenInMemoryTokenIsInvalidated to
      verify that forcingRefresh: YES invalidates the in-memory token even if
      the refresh fails.
    • Added testGetToken_WhenInMemoryTokenExpires_ThenRefreshesWithProvider to
      verify that an expiring in-memory token triggers a refresh.

Verification Results

Automated Tests

Ran the full test suite using swift test and formatted with xcsift:

swift test 2>&1 | tee /tmp/swift-test.log 2>&1 | /opt/homebrew/bin/xcsift -w -f toon

Output:

status: success
summary:
  errors: 0
  warnings: 0
  failed_tests: 0
  linker_errors: 0
  passed_tests: 136
  build_time: 3.73 secs.
  test_time: 134.286s

All 136 unit tests passed with zero errors and zero warnings.

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.
Add the missing `.loggerAppCheckMessageCodeTokenStorageFailed` case to
the `AppCheckCoreMessageCode` switch statement in `AppCheckAPITests` to
resolve an exhaustive switch warning on CI.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant