Skip to content

Publish GIDGoogleUser's tokens as immutable snapshots - #645

Open
w-goog wants to merge 4 commits into
mainfrom
fix/googleuser-token-snapshot
Open

w-goog wants to merge 4 commits into
mainfrom
fix/googleuser-token-snapshot

Conversation

@w-goog

@w-goog w-goog commented Oct 1, 2026

Copy link
Copy Markdown
Contributor

GIDGoogleUser wrote its access, refresh and ID tokens one property at a time - as a result, readers could see a mix of old & new tokens.

Behaviour change (in the CHANGELOG):

  1. Observers of any of accessToken, refreshToken, idToken are now notified when any of the three changes. KVO observers run while the user's lock is held.
  2. Calling back into the same user is fine, but an observer must not synchronously wait on another thread that is updating the same user.

The second behaviour change is an intentional tradeoff. The suggestion here was to send the KVO outside of the lock. That would mean out-of-order delivery could occur between any concurrent updates, and notifications be sent out-of-order. Sending outside of the lock would also mean that observers who ask KVO for the old value would get the new one. The only way for us to guarantee that there is no chance of deadlock; KVO freshness is correct; and notifications are delivered in-order would be to implement a manual notification queue.

Given that a deadlock would only occur when an observer is synchronously waiting on another thread that is also updating that same user; and that this behaviour has been present on the login path for some time; I judged that amount of manual machinery as not worth the tradeoff. Very willing to revisit in the future.

@w-goog
w-goog requested a review from mdmathias October 1, 2026 21:10
/// The user's current tokens. Read once - accessing individual properties in sequence is not
/// recommended. Reading once ensures that each property is from the same update. Writes are
/// serialized by `@synchronized(self)`.
@property(atomic, strong, nullable) GIDGoogleUserTokens *tokens;

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.

I think atomic is the default. Happy to leave as-is if you think explicitness is useful.

Comment thread GoogleSignIn/Sources/GIDGoogleUser.m Outdated
- (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion {
if (!([self.accessToken.expirationDate timeIntervalSinceNow] < kMinimalTimeToExpire ||
(self.idToken && [self.idToken.expirationDate timeIntervalSinceNow] < kMinimalTimeToExpire))) {
// A single read here avoids accidnetally reading tokens from multiple snapshots.

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.

s/accidnetally/accidentally

}
if ((self.idToken || idToken) && ![self.idToken isEqualToToken:idToken]) {
self.idToken = idToken;
@synchronized(self) {

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.

I think this lock fully covers the snapshot write, but I want to check something about the refresh path.

-updateWithTokenResponse:authorizationResponse:profileData: was already under @synchronized(self) before this change, so (as I understand it) the writer that actually had no lock is the refresh callback in -refreshTokensIfNeededWithCompletion:. It calls [self.authState updateWithTokenResponse:error:] (and updateWithAuthorizationError:) without holding the lock, and only gets here through -didChangeState:.

So the tokens write is now protected, but OIDAuthState itself can still change on that unlocked path while this method is building tokens from it (e.g. a sign-in or addScopes update running at the same time as a refresh). That problem existed before this PR, so I don't think it needs to block this one.

A couple of thoughts:

Could we mention this in the PR description, or file a follow-up issue, so it's clear the PR makes the token snapshot safe but doesn't make OIDAuthState thread-safe?

Would it be cheap to wrap the authState updates in the refresh callback in @synchronized(self) as well? -didChangeState: would then re-enter this lock, which is fine since it's recursive. Other code still reads authState without the lock (grantedScopes, encodeWithCoder:, and the fetcher authorizer), so fully fixing that is probably a follow-up either way.

Related to tests: both stress tests drive -updateWithTokenResponse:authorizationResponse:profileData:, which was already locked. A variant that races -didChangeState: (or [user.authState updateWithTokenResponse:error:]) against reads would cover the path this PR actually changes. Ideally use a different refresh token for each state so a mixed refresh token would be caught too.

Happy to take this as a follow-up if you'd prefer to keep this PR focused.

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.

2 participants