Skip to content

Fix data race on GIDGoogleUser tokens - #640

Open
w-goog wants to merge 21 commits into
mainfrom
fix/googleuser-token-sync
Open

w-goog wants to merge 21 commits into
mainfrom
fix/googleuser-token-sync

Conversation

@w-goog

@w-goog w-goog commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Fixes a data race on GIDGoogleUser tokens. This addresses a number of potential issues. (Yes, I used Markdown to write this, an LLM didn't write it :P)

What changed

  • access, refresh, and ID tokens (plus granted scopes and configuration) are now stored on the same object.
  • updates to that object by GSI are protected by a recursive lock.
  • a use-case specific lock was added for the token snapshot and profile changes.
  • KVO on the same thread still works (see the removed @synchronized(self))
  • Behaviour change: configuration is now computed once when the user is created (it was previously computed on first access). This will differ from GTMAppAuth's prior behavior on automated refreshes. Tbh, that was probably a bug which was caching nil values.
  • there's a new CI job w/Thread Sanitizer

What could have gone wrong
Previously, accessing these properties on different threads could have resulted in a crash. AppAuth & GSI mostly deliver everything on the main queue, but it seems likely that user.accessToken.tokenString would be read by networking code on the background queue. A refresh on the main queue at the same time could result in EXC_BAD_ACCESS . When you multiply that (small) likelihood by the number of times this code runs...it's probably happening.

Additionally:

  • updateTokensWithAuthState was updating each token individually, and the synchronized self only applied in updateWithTokenResponse; calls to the token update from elsewhere had no lock.
  • A client call of synchronized(googleUser) would share the library's lock, potentially leading to deadlocks.
  • Readers could receive a mixed set of tokens, since they were each updated separately.
  • Overlapping token updates could apply out of order.

What might still go wrong

  • authState can still be read directly, by GIDSignIn and others via fetcherAuthorizer. There's a TODO on the property, but the fix would be a public API change.
  • performActionWithFreshTokens in AppAuth still reads OIDAuthState without a lock if GTMAppAuth allows that off of the main thread. This is existing behaviour, and would require a fix in AppAuth.

@w-goog
w-goog requested a review from mdmathias September 23, 2026 22:27
@w-goog w-goog changed the title Fix/googleuser token sync Fix data race on GIDGoogleUser tokens Sep 24, 2026
@w-goog
w-goog marked this pull request as ready for review September 24, 2026 00:48

@mdmathias mdmathias left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a lot of commits here, including a merge from main and two separate "Changelog: serialize…" commits. The changelog commits' wording ("serialize under the object lock") no longer matches the final design. Please squash, or otherwise clean up to make sure things are easy to follow and match.


// Guards `_tokens` and `_profile`. It is only ever held for a single read or write of those
// ivars, never while calling out to other code.
os_unfair_lock _tokenLock;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The os_unfair_lock use looks correct, but I don't think we need it (since it seems like it rebuilds what atomic properties do under the hood for free). Every critical section is a single pointer read or write, which is exactly what a synthesized atomic property provides. The runtime does the same retain-and-return under its own lock, so a concurrent setter still can't free the snapshot. Could we:

  1. Drop the custom tokens accessors and let @property(atomic, strong) GIDGoogleUserTokens *tokens synthesize?
  2. Move profile into GIDGoogleUserTokens, or a private atomic backing property, so -profile doesn't need a lock either?

That removes _tokenLock, the lock-ordering rule, and os_unfair_lock_assert_not_owner, which crashes in release builds if violated. It also makes profile and the tokens change together: right now updateWithTokenResponse:… updates them in separate critical sections, so readers can see a new profile with old tokens even though they share a lock. It also fixes the old snapshot being released while the lock is held (L188). I don't think speed is a concern either way at this call frequency.


@interface GIDGoogleUser ()

- (void)getAccessToken:(GIDToken *_Nullable *_Nullable)accessToken

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method is declared in a GIDGoogleUser (Internal) category in the .m, then redeclared here in the test. Could it move to GIDGoogleUser_Private.h? Alternatively, expose the GIDGoogleUserTokens snapshot internally and have callers read one object instead of three out-params. That's simpler, and scopes and configuration come along for free.


// The token properties are derived from `tokens`, so KVO observers of each one are notified
// whenever `tokens` is replaced.
+ (NSSet<NSString *> *)keyPathsForValuesAffectingAccessToken {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every token property now depends on tokens. So when only the refresh token or the granted scopes change, observers of accessToken (and idToken) are still notified. Before, KVO fired only for the property that actually changed. This is visible to apps (for example, anyone who re-authorizes whenever accessToken fires). Could we call it out in the CHANGELOG? Or override automaticallyNotifiesObserversForKey: and notify each key only when it actually changes?

NSMutableDictionary *additionalParameters = [@{} mutableCopy];
// Read the auth state under `_authStateLock` so building the request cannot interleave with
// -updateWithTokenResponse:authorizationResponse:profileData:.
[self lockAuthState];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The lock also covers +[GIDEMMSupport updatedEMMParametersWithParameters:] and +[GIDSignInPreferences loggingParameters]. Consider reading only the authState fields under the lock and building additionalParameters after unlocking.

originalAuthorizationResponse:authorizationResponse
callback:^(OIDTokenResponse *_Nullable tokenResponse,
NSError *_Nullable error) {
// Update the auth state under `_authStateLock` so this refresh cannot interleave with

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The description says this fixes "Overlapping token updates could apply out of order", but the lock only makes each update atomic; it doesn't order them. The refresh request is built under the lock, but the lock is released while the request is in flight. If addScopes calls updateWithTokenResponse:authorizationResponse:… in the meantime, this callback then applies a response based on the pre-addScopes grant on top of it. As far as I can tell, AppAuth doesn't check which authorization a token response belongs to. The result is that grantedScopes and accessToken can revert. This is existing behavior, not a regression. Could we either drop the response when self.authState.lastAuthorizationResponse != authorizationResponse (the value you already capture at L307), or remove that line from the description?

@property(nonatomic, readwrite, nullable) GIDToken *idToken;

/// A representation of the state of the OAuth session for this instance.
// TODO: Reads through this property bypass the lock GIDGoogleUser takes around its own auth

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the TODO. Since GIDSignIn and fetcherAuthorizer read authState without the lock, locking encodeWithCoder: and emmSupport protects against GSI's own updates but not AppAuth/GTMAppAuth refreshes that go through fetcherAuthorizer. Could we file an issue for that and link it from the TODO?

Also, it would be nice to connect TODOs with an issue on the repo for tracking purposes.

// The consistency guarantee comes from the snapshot accessor's single lock acquisition.
// Reading the three properties individually would NOT be atomic even with the per-accessor
// locking, by design.
if ([accessToken.tokenString isEqualToString:accessTokenA]) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If accessToken is neither A nor B, the read checks nothing. Consider asserting that idToken matches the initial token in that case too, so a mixed initial/A snapshot is also caught.

// The consistency assertion above is what gives it meaning without TSan.
}

- (void)testRefreshTokensIfNeeded_readsConsistentSnapshot {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks similar to testRefreshTokensIfNeededWithCompletion_refresh_givenAccessTokenExpired. Suggest deleting it, or renaming it and asserting something new.

// KVO observers of the token properties run while the user holds its auth state lock. This checks
// that an observer can call back into the user on the same thread, including starting another
// token update, without deadlocking or aborting.
- (void)testTokenObserver_reentersUserDuringUpdate {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice test. Consider adding a second observer to check that nested updates don't deliver notifications out of order (it would see B's notification before A's finishes).

xcodebuild \
-scheme GoogleSignIn-Package \
-sdk iphonesimulator \
-destination "platform=iOS Simulator,name=iPhone 16,OS=18.6" \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Xcode 16.4, iPhone 16 and iOS 18.6 are hardcoded, while the other jobs use a matrix. Could we reuse the matrix values so it doesn't drift from the others? Consider a macOS destination as well.

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.

3 participants