From c52a562a90a4c5fda475ee9d46da4f96c7121e99 Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:29:24 -0700 Subject: [PATCH 01/20] g-orchestrated: Serialize GIDGoogleUser token access under the object lock --- GoogleSignIn/Sources/GIDGoogleUser.m | 149 ++++++++++++++++++++------- 1 file changed, 112 insertions(+), 37 deletions(-) diff --git a/GoogleSignIn/Sources/GIDGoogleUser.m b/GoogleSignIn/Sources/GIDGoogleUser.m index f67bb4c8..56b00375 100644 --- a/GoogleSignIn/Sources/GIDGoogleUser.m +++ b/GoogleSignIn/Sources/GIDGoogleUser.m @@ -61,12 +61,80 @@ @interface GIDGoogleUser () @end #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST +@interface GIDGoogleUser (Internal) + +- (void)getAccessToken:(GIDToken *_Nullable *_Nullable)accessToken + refreshToken:(GIDToken *_Nullable *_Nullable)refreshToken + idToken:(GIDToken *_Nullable *_Nullable)idToken; + +@end + @implementation GIDGoogleUser { GIDConfiguration *_cachedConfiguration; // A queue for pending token refresh handlers so we don't fire multiple requests in parallel. // Access to this ivar should be synchronized. NSMutableArray *_tokenRefreshHandlerQueue; + + GIDToken *_accessToken; + GIDToken *_refreshToken; + GIDToken *_idToken; +} + +@synthesize accessToken = _accessToken; +@synthesize refreshToken = _refreshToken; +@synthesize idToken = _idToken; + +- (GIDToken *)accessToken { + @synchronized(self) { + return _accessToken; + } +} + +- (void)setAccessToken:(GIDToken *)accessToken { + @synchronized(self) { + _accessToken = accessToken; + } +} + +- (GIDToken *)refreshToken { + @synchronized(self) { + return _refreshToken; + } +} + +- (void)setRefreshToken:(GIDToken *)refreshToken { + @synchronized(self) { + _refreshToken = refreshToken; + } +} + +- (nullable GIDToken *)idToken { + @synchronized(self) { + return _idToken; + } +} + +- (void)setIdToken:(nullable GIDToken *)idToken { + @synchronized(self) { + _idToken = idToken; + } +} + +- (void)getAccessToken:(GIDToken *_Nullable *_Nullable)accessToken + refreshToken:(GIDToken *_Nullable *_Nullable)refreshToken + idToken:(GIDToken *_Nullable *_Nullable)idToken { + @synchronized(self) { + if (accessToken) { + *accessToken = _accessToken; + } + if (refreshToken) { + *refreshToken = _refreshToken; + } + if (idToken) { + *idToken = _idToken; + } + } } - (nullable NSString *)userID { @@ -118,14 +186,19 @@ - (GIDConfiguration *)configuration { } - (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion { - if (!([self.accessToken.expirationDate timeIntervalSinceNow] < kMinimalTimeToExpire || - (self.idToken && [self.idToken.expirationDate timeIntervalSinceNow] < kMinimalTimeToExpire))) { + GIDToken *accessToken; + GIDToken *refreshToken; + GIDToken *idToken; + [self getAccessToken:&accessToken refreshToken:&refreshToken idToken:&idToken]; + + if (!([accessToken.expirationDate timeIntervalSinceNow] < kMinimalTimeToExpire || + (idToken && [idToken.expirationDate timeIntervalSinceNow] < kMinimalTimeToExpire))) { dispatch_async(dispatch_get_main_queue(), ^{ completion(self, nil); }); return; } - if (self.refreshToken.expirationDate && [self.refreshToken.expirationDate timeIntervalSinceNow] <= 0) { + if (refreshToken.expirationDate && [refreshToken.expirationDate timeIntervalSinceNow] <= 0) { NSError *error = [NSError errorWithDomain:kGIDSignInErrorDomain code:kGIDSignInErrorCodeRefreshTokenExpired userInfo:nil]; @@ -277,40 +350,42 @@ - (void)updateWithTokenResponse:(OIDTokenResponse *)tokenResponse } - (void)updateTokensWithAuthState:(OIDAuthState *)authState { - GIDToken *accessToken = - [[GIDToken alloc] initWithTokenString:authState.lastTokenResponse.accessToken - expirationDate:authState.lastTokenResponse.accessTokenExpirationDate]; - if (![self.accessToken isEqualToToken:accessToken]) { - self.accessToken = accessToken; - } - - NSDictionary *additionalParameters = authState.lastTokenResponse.additionalParameters; - NSNumber *refreshTokenExpiresIn = nil; - NSDate *refreshTokenExpirationDate = nil; - id expiresInValue = additionalParameters[@"refresh_token_expires_in"]; - if ([expiresInValue isKindOfClass:[NSNumber class]]) { - refreshTokenExpiresIn = (NSNumber *)expiresInValue; - NSTimeInterval interval = [refreshTokenExpiresIn doubleValue]; - refreshTokenExpirationDate = [NSDate dateWithTimeIntervalSinceNow:interval]; - } - GIDToken *refreshToken = [[GIDToken alloc] initWithTokenString:authState.refreshToken - expirationDate:refreshTokenExpirationDate]; - if (![self.refreshToken isEqualToToken:refreshToken]) { - self.refreshToken = refreshToken; - } - - GIDToken *idToken; - NSString *idTokenString = authState.lastTokenResponse.idToken; - if (idTokenString) { - NSDate *idTokenExpirationDate = - [[[OIDIDToken alloc] initWithIDTokenString:idTokenString] expiresAt]; - idToken = [[GIDToken alloc] initWithTokenString:idTokenString - expirationDate:idTokenExpirationDate]; - } else { - idToken = nil; - } - if ((self.idToken || idToken) && ![self.idToken isEqualToToken:idToken]) { - self.idToken = idToken; + @synchronized(self) { + GIDToken *accessToken = + [[GIDToken alloc] initWithTokenString:authState.lastTokenResponse.accessToken + expirationDate:authState.lastTokenResponse.accessTokenExpirationDate]; + if (![self.accessToken isEqualToToken:accessToken]) { + self.accessToken = accessToken; + } + + NSDictionary *additionalParameters = authState.lastTokenResponse.additionalParameters; + NSNumber *refreshTokenExpiresIn = nil; + NSDate *refreshTokenExpirationDate = nil; + id expiresInValue = additionalParameters[@"refresh_token_expires_in"]; + if ([expiresInValue isKindOfClass:[NSNumber class]]) { + refreshTokenExpiresIn = (NSNumber *)expiresInValue; + NSTimeInterval interval = [refreshTokenExpiresIn doubleValue]; + refreshTokenExpirationDate = [NSDate dateWithTimeIntervalSinceNow:interval]; + } + GIDToken *refreshToken = [[GIDToken alloc] initWithTokenString:authState.refreshToken + expirationDate:refreshTokenExpirationDate]; + if (![self.refreshToken isEqualToToken:refreshToken]) { + self.refreshToken = refreshToken; + } + + GIDToken *idToken; + NSString *idTokenString = authState.lastTokenResponse.idToken; + if (idTokenString) { + NSDate *idTokenExpirationDate = + [[[OIDIDToken alloc] initWithIDTokenString:idTokenString] expiresAt]; + idToken = [[GIDToken alloc] initWithTokenString:idTokenString + expirationDate:idTokenExpirationDate]; + } else { + idToken = nil; + } + if ((self.idToken || idToken) && ![self.idToken isEqualToToken:idToken]) { + self.idToken = idToken; + } } } From 133d5a6a770f67a5868cd4ff17ece137a1fdecd4 Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:29:26 -0700 Subject: [PATCH 02/20] g-orchestrated: Changelog: serialize GIDGoogleUser token access --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 187dc61d..d8dad190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# Unreleased +- Fix a data race on `GIDGoogleUser`'s access, refresh and ID tokens. Concurrent token refreshes + could previously write the three properties from different queues at once, and readers could + observe a partially-updated set. + # 10.0.0 - **BREAKING**: Update to AppAuth 3.0.0 and GTMAppAuth 6.0.0, which raises the minimum deployment targets to iOS 15.0 and macOS 12.0, widens the `GTMSessionFetcher` dependency to allow 4.x and 5.x, and renames the version-specific Swift Package Manager manifest to `Package@swift-5.7.swift`. Projects that must keep supporting earlier OS versions should stay on GoogleSignIn 9.2.0. ([#628](https://github.com/google/GoogleSignIn-iOS/pull/628)) - Add `GIDSignIn.wrapperIdentifier` so SDKs that embed Google Sign-In can self-identify in Google's diagnostic logs via a new `gidwrapper` parameter. It is opt-in and pre-existing behavior is unchanged. ([#625](https://github.com/google/GoogleSignIn-iOS/pull/625)) From 1f3dcc76d73f6124d44468487f6f69c80297d0d3 Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:29:28 -0700 Subject: [PATCH 03/20] g-orchestrated: Test: GIDGoogleUser token reads stay consistent under concurrency --- GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m | 112 ++++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m b/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m index a6a5788c..7217ad55 100644 --- a/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m +++ b/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m @@ -53,6 +53,14 @@ #import #endif +@interface GIDGoogleUser () + +- (void)getAccessToken:(GIDToken *_Nullable *_Nullable)accessToken + refreshToken:(GIDToken *_Nullable *_Nullable)refreshToken + idToken:(GIDToken *_Nullable *_Nullable)idToken; + +@end + static NSString *const kNewAccessToken = @"new_access_token"; static NSString *const kNewRefreshToken = @"new_refresh_token"; @@ -222,6 +230,110 @@ - (void)testUpdateAuthState_tokensAreNotChanged { XCTAssertIdentical(user.refreshToken, refreshTokenBeforeUpdate); } +- (void)testUpdateTokens_concurrentUpdates_leaveConsistentTokenSet { + GIDGoogleUser *user = [self googleUserWithAccessTokenExpiresIn:kAccessTokenExpiresIn + idTokenExpiresIn:kIDTokenExpiresIn]; + + NSString *idTokenA = [self idTokenWithExpiresIn:kNewIDTokenExpiresIn]; + NSString *accessTokenA = @"access_token_A"; + OIDAuthState *authStateA = [OIDAuthState testInstanceWithIDToken:idTokenA + accessToken:accessTokenA + accessTokenExpiresIn:kAccessTokenExpiresIn + refreshToken:kNewRefreshToken]; + + NSString *idTokenB = [self idTokenWithExpiresIn:kNewIDTokenExpiresIn + 1]; + NSString *accessTokenB = @"access_token_B"; + OIDAuthState *authStateB = [OIDAuthState testInstanceWithIDToken:idTokenB + accessToken:accessTokenB + accessTokenExpiresIn:kAccessTokenExpiresIn + refreshToken:kNewRefreshToken]; + + XCTestExpectation *updateExpectation = [self expectationWithDescription:@"Updates finished"]; + XCTestExpectation *readExpectation = [self expectationWithDescription:@"Reads finished"]; + + dispatch_queue_t updateQueue = dispatch_queue_create("com.google.gidgoogleuser.testUpdateTokens.update", + DISPATCH_QUEUE_CONCURRENT); + dispatch_queue_t readQueue = dispatch_queue_create("com.google.gidgoogleuser.testUpdateTokens.read", + DISPATCH_QUEUE_CONCURRENT); + + NSInteger iterations = 200; + + // Concurrent updates + dispatch_async(updateQueue, ^{ + dispatch_apply(iterations, updateQueue, ^(size_t i) { + OIDAuthState *state = (i % 2 == 0) ? authStateA : authStateB; + [user updateWithTokenResponse:state.lastTokenResponse + authorizationResponse:state.lastAuthorizationResponse + profileData:nil]; + }); + [updateExpectation fulfill]; + }); + + // Concurrent reads + dispatch_async(readQueue, ^{ + dispatch_apply(iterations, readQueue, ^(size_t i) { + GIDToken *accessToken = nil; + GIDToken *refreshToken = nil; + GIDToken *idToken = nil; + [user getAccessToken:&accessToken refreshToken:&refreshToken idToken:&idToken]; + + // Consistency check: accessToken and idToken must both come from A or both from B. + // 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]) { + XCTAssertEqualObjects(idToken.tokenString, idTokenA); + XCTAssertEqualObjects(refreshToken.tokenString, kNewRefreshToken); + } else if ([accessToken.tokenString isEqualToString:accessTokenB]) { + XCTAssertEqualObjects(idToken.tokenString, idTokenB); + XCTAssertEqualObjects(refreshToken.tokenString, kNewRefreshToken); + } + }); + [readExpectation fulfill]; + }); + + [self waitForExpectationsWithTimeout:5 handler:nil]; + + // Final state should be either A or B (whichever ran last) + BOOL matchesA = [user.accessToken.tokenString isEqualToString:accessTokenA] && + [user.idToken.tokenString isEqualToString:idTokenA]; + BOOL matchesB = [user.accessToken.tokenString isEqualToString:accessTokenB] && + [user.idToken.tokenString isEqualToString:idTokenB]; + XCTAssertTrue(matchesA || matchesB); + + // This test is a reliable failure detector only under Thread Sanitizer. + // The consistency assertion above is what gives it meaning without TSan. +} + +- (void)testRefreshTokensIfNeeded_readsConsistentSnapshot { + // Access token expired 10 seconds ago. ID token will expire in 10 minutes. + // This matches the shape in testRefreshTokensIfNeededWithCompletion_refresh_givenAccessTokenExpired. + GIDGoogleUser *user = [self googleUserWithAccessTokenExpiresIn:-10 idTokenExpiresIn:10 * 60]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Callback is called"]; + + // Call -refreshTokensIfNeededWithCompletion: and assert the completion fires with the user and no + // error, i.e. the decision path is unchanged by the snapshot refactor. + [user refreshTokensIfNeededWithCompletion:^(GIDGoogleUser *_Nullable user, NSError *_Nullable error) { + [expectation fulfill]; + XCTAssertNotNil(user); + XCTAssertNil(error); + }]; + + // We need to provide a response because the access token is expired, so it WILL attempt a refresh. + NSString *newIdToken = [self idTokenWithExpiresIn:kNewIDTokenExpiresIn]; + OIDTokenResponse *fakeResponse = [OIDTokenResponse testInstanceWithIDToken:newIdToken + accessToken:kNewAccessToken + expiresIn:@(kAccessTokenExpiresIn) + refreshToken:kRefreshToken + tokenRequest:nil]; + _tokenFetchHandler(fakeResponse, nil); + + [self waitForExpectationsWithTimeout:1 handler:nil]; + + // This is a guard against the snapshot refactor changing the refresh decision, not a race test. +} + - (void)testFetcherAuthorizer { // This is really hard to test without assuming how GTMAppAuthFetcherAuthorization works // internally, so let's just take the shortcut here by asserting we get a From 5e23369f0e779f52d095698416a7816c3579bfad Mon Sep 17 00:00:00 2001 From: Brianna Morales <74382627+brnnmrls@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:08:42 -0700 Subject: [PATCH 04/20] Update push and pr notification to use Cards v2. (#636) --- .github/workflows/pr_notification.yml | 167 ++++++++++++------------ .github/workflows/push_notification.yml | 123 +++++++++-------- 2 files changed, 155 insertions(+), 135 deletions(-) diff --git a/.github/workflows/pr_notification.yml b/.github/workflows/pr_notification.yml index 794ad682..8846fb11 100644 --- a/.github/workflows/pr_notification.yml +++ b/.github/workflows/pr_notification.yml @@ -4,97 +4,100 @@ on: pull_request: types: [review_requested] +permissions: + contents: read + jobs: notify-pull-request: runs-on: ubuntu-latest steps: - - name: Pull Request Details - run: | - echo "Pull Request: ${{ github.event.pull_request.number }}" - echo "Author: ${GITHUB_EVENT_PULL_REQUEST_USER_LOGIN}" - env: - GITHUB_EVENT_PULL_REQUEST_USER_LOGIN: ${{ github.event.pull_request.user.login }} + - name: Pull Request Details + run: | + echo "Pull Request: ${PR_NUMBER}" + echo "Author: ${AUTHOR}" + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + AUTHOR: ${{ github.event.pull_request.user.login }} - - name: Google Chat Notification - shell: bash - env: - TITLE: ${{ github.event.pull_request.title }} - LABELS: ${{ join(github.event.pull_request.labels.*.name, ', ') }} - GITHUB_EVENT_PULL_REQUEST_HEAD_REPO_FULL_NAME: ${{ github.event.pull_request.head.repo.full_name }} - GITHUB_EVENT_PULL_REQUEST_USER_LOGIN: ${{ github.event.pull_request.user.login }} - GITHUB_EVENT_PULL_REQUEST_HTML_URL: ${{ github.event.pull_request.html_url }} - run: | - curl --location --request POST '${{ secrets.WEBHOOK_URL }}' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "cards": [ + - name: Google Chat Notification + continue-on-error: true + shell: bash + env: + WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }} + PR_NUMBER: ${{ github.event.pull_request.number }} + TITLE: ${{ github.event.pull_request.title }} + LABELS: ${{ join(github.event.pull_request.labels.*.name, ', ') }} + REPO: ${{ github.event.pull_request.head.repo.full_name }} + CREATOR: ${{ github.event.pull_request.user.login }} + STATE: ${{ github.event.pull_request.state }} + ASSIGNEES: ${{ join(github.event.pull_request.assignees.*.login, ', ') }} + REVIEWERS: ${{ join(github.event.pull_request.requested_reviewers.*.login, ', ') }} + URL: ${{ github.event.pull_request.html_url }} + run: | + if [ -z "$WEBHOOK_URL" ]; then + echo "WEBHOOK_URL secret is not set (e.g. fork PR). Skipping notification." + exit 0 + fi + + PAYLOAD=$(jq -n \ + --arg pr_num "$PR_NUMBER" \ + --arg title "$TITLE" \ + --arg labels "$LABELS" \ + --arg repo "$REPO" \ + --arg creator "$CREATOR" \ + --arg state "$STATE" \ + --arg assignees "$ASSIGNEES" \ + --arg reviewers "$REVIEWERS" \ + --arg url "$URL" \ + ' + def guard(val): if (val == null or val == "") then "None" else val end; + def guard_list(val): if (val == null or val == "") then "None" else "- " + val end; { - "header": { - "title": "Pull request notification", - "subtitle": "Pull request: #${{ github.event.pull_request.number }}" - }, - "sections": [ + cardsV2: [ { - "widgets": [ - { - "keyValue": { - "topLabel": "Repo", - "content": "${GITHUB_EVENT_PULL_REQUEST_HEAD_REPO_FULL_NAME}" - } + cardId: "prNotificationCard", + card: { + header: { + title: "Pull request notification", + subtitle: ("Pull request: #" + guard($pr_num)) }, - { - "keyValue": { - "topLabel": "Title", - "content": "'"$TITLE"'" - } - }, - { - "keyValue": { - "topLabel": "Creator", - "content": "${GITHUB_EVENT_PULL_REQUEST_USER_LOGIN}" - } - }, - { - "keyValue": { - "topLabel": "State", - "content": "${{ github.event.pull_request.state }}" - } - }, - { - "keyValue": { - "topLabel": "Assignees", - "content": "- ${{ join(github.event.pull_request.assignees.*.login, ', ') }}" - } - }, - { - "keyValue": { - "topLabel": "Reviewers", - "content": "- ${{ join(github.event.pull_request.requested_reviewers.*.login, ', ') }}" - } - }, - { - "keyValue": { - "topLabel": "Labels", - "content": "- '"$LABELS"'" - } - }, - { - "buttons": [ - { - "textButton": { - "text": "Open Pull Request", - "onClick": { - "openLink": { - "url": "${GITHUB_EVENT_PULL_REQUEST_HTML_URL}" - } + sections: [ + { + widgets: [ + { decoratedText: { topLabel: "Repo", text: guard($repo) } }, + { decoratedText: { topLabel: "Title", text: guard($title) } }, + { decoratedText: { topLabel: "Creator", text: guard($creator) } }, + { decoratedText: { topLabel: "State", text: guard($state) } }, + { decoratedText: { topLabel: "Assignees", text: guard_list($assignees) } }, + { decoratedText: { topLabel: "Reviewers", text: guard_list($reviewers) } }, + { decoratedText: { topLabel: "Labels", text: guard_list($labels) } }, + { + buttonList: { + buttons: [ + { + text: "Open Pull Request", + onClick: { + openLink: { + url: $url + } + } + } + ] } } - } - ] - } - ] + ] + } + ] + } } ] - } - ] - }' + }') + + RESPONSE=$(curl --fail-with-body --location --request POST "$WEBHOOK_URL" \ + --header 'Content-Type: application/json; charset=UTF-8' \ + --data-raw "$PAYLOAD" 2>&1) || { + echo "Failed to send Google Chat notification:" + echo "$RESPONSE" + exit 1 + } + diff --git a/.github/workflows/push_notification.yml b/.github/workflows/push_notification.yml index 33e43c6e..9a5e9164 100644 --- a/.github/workflows/push_notification.yml +++ b/.github/workflows/push_notification.yml @@ -5,69 +5,86 @@ on: branches: - main +permissions: + contents: read + jobs: notify-push-main: runs-on: ubuntu-latest - env: - COMMIT: ${{ github.event.head_commit.message }} steps: - - name: Main Branch Push - run: | - echo "Workflow initiated by event with name: ${{ github.event_name }}" - echo "Pushing commit to main: ${GITHUB_EVENT_HEAD_COMMIT_ID}" - echo "Pushed by: ${GITHUB_EVENT_PUSHER_NAME}" - env: - GITHUB_EVENT_HEAD_COMMIT_ID: ${{ github.event.head_commit.id }} - GITHUB_EVENT_PUSHER_NAME: ${{ github.event.pusher.name }} + - name: Main Branch Push + run: | + echo "Workflow initiated by event with name: ${EVENT_NAME}" + echo "Pushing commit to main: ${HEAD_COMMIT_ID}" + echo "Pushed by: ${PUSHER_NAME}" + env: + EVENT_NAME: ${{ github.event_name }} + HEAD_COMMIT_ID: ${{ github.event.head_commit.id }} + PUSHER_NAME: ${{ github.event.pusher.name }} - - name: Push Notification to Google Chat - run: | - curl --location --request POST '${{ secrets.WEBHOOK_URL }}' \ - --header 'Content-Type: application/json' \ - --data-raw '{ - "cards": [ + - name: Push Notification to Google Chat + continue-on-error: true + shell: bash + env: + WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }} + COMMIT_MSG: ${{ github.event.head_commit.message }} + AUTHOR: ${{ github.event.head_commit.author.username }} + REPO: ${{ github.event.repository.full_name }} + COMPARE_URL: ${{ github.event.compare }} + run: | + if [ -z "$WEBHOOK_URL" ]; then + echo "WEBHOOK_URL secret is not set. Skipping notification." + exit 0 + fi + + PAYLOAD=$(jq -n \ + --arg commit "$COMMIT_MSG" \ + --arg author "$AUTHOR" \ + --arg repo "$REPO" \ + --arg compare "$COMPARE_URL" \ + ' + def guard(val): if (val == null or val == "") then "None" else val end; { - "header": { - "title": "Push to main branch", - "subtitle": "'"$COMMIT"'" - }, - "sections": [ + cardsV2: [ { - "widgets": [ - { - "keyValue": { - "topLabel": "Repo", - "content": "${GITHUB_EVENT_REPOSITORY_FULL_NAME}" - } - }, - { - "keyValue": { - "topLabel": "Committed by", - "content": "${GITHUB_EVENT_HEAD_COMMIT_AUTHOR_USERNAME}" - } + cardId: "pushNotificationCard", + card: { + header: { + title: "Push to main branch", + subtitle: guard($commit) }, - { - "buttons": [ - { - "textButton": { - "text": "Ref comparison", - "onClick": { - "openLink": { - "url": "${GITHUB_EVENT_COMPARE}" - } + sections: [ + { + widgets: [ + { decoratedText: { topLabel: "Repo", text: guard($repo) } }, + { decoratedText: { topLabel: "Committed by", text: guard($author) } }, + { + buttonList: { + buttons: [ + { + text: "Ref comparison", + onClick: { + openLink: { + url: $compare + } + } + } + ] } } - } - ] - } - ] + ] + } + ] + } } ] - } - ] - }' - env: - GITHUB_EVENT_REPOSITORY_FULL_NAME: ${{ github.event.repository.full_name }} - GITHUB_EVENT_HEAD_COMMIT_AUTHOR_USERNAME: ${{ github.event.head_commit.author.username }} - GITHUB_EVENT_COMPARE: ${{ github.event.compare }} + }') + + RESPONSE=$(curl --fail-with-body --location --request POST "$WEBHOOK_URL" \ + --header 'Content-Type: application/json; charset=UTF-8' \ + --data-raw "$PAYLOAD" 2>&1) || { + echo "Failed to send Google Chat notification:" + echo "$RESPONSE" + exit 1 + } From 095bb84b0225b1f44e4feb514638e54157d65fb4 Mon Sep 17 00:00:00 2001 From: Worthing <115107835+w-goog@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:31:01 -0700 Subject: [PATCH 05/20] Carry `nonce` and `claimsAsJSON` through Device Policy flows (#637) --- CHANGELOG.md | 1 + .../Sources/GIDSignInInternalOptions.m | 2 + .../Tests/Unit/GIDSignInInternalOptionsTest.m | 147 ++++++++++++------ 3 files changed, 100 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8dad190..fab43bc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Fix a data race on `GIDGoogleUser`'s access, refresh and ID tokens. Concurrent token refreshes could previously write the three properties from different queues at once, and readers could observe a partially-updated set. +- Fix a custom `nonce` and requested token `claims` being dropped when a sign-in is continued after a Device Policy app restart. # 10.0.0 - **BREAKING**: Update to AppAuth 3.0.0 and GTMAppAuth 6.0.0, which raises the minimum deployment targets to iOS 15.0 and macOS 12.0, widens the `GTMSessionFetcher` dependency to allow 4.x and 5.x, and renames the version-specific Swift Package Manager manifest to `Package@swift-5.7.swift`. Projects that must keep supporting earlier OS versions should stay on GoogleSignIn 9.2.0. ([#628](https://github.com/google/GoogleSignIn-iOS/pull/628)) diff --git a/GoogleSignIn/Sources/GIDSignInInternalOptions.m b/GoogleSignIn/Sources/GIDSignInInternalOptions.m index 4a87bddf..4ea6a0da 100644 --- a/GoogleSignIn/Sources/GIDSignInInternalOptions.m +++ b/GoogleSignIn/Sources/GIDSignInInternalOptions.m @@ -124,7 +124,9 @@ - (instancetype)optionsWithExtraParameters:(NSDictionary *)extraParams options->_loginHint = _loginHint; options->_completion = _completion; options->_scopes = _scopes; + options->_nonce = _nonce; options->_claims = _claims; + options->_claimsAsJSON = _claimsAsJSON; options->_extraParams = [extraParams copy]; } return options; diff --git a/GoogleSignIn/Tests/Unit/GIDSignInInternalOptionsTest.m b/GoogleSignIn/Tests/Unit/GIDSignInInternalOptionsTest.m index 13fcac4d..4a555024 100644 --- a/GoogleSignIn/Tests/Unit/GIDSignInInternalOptionsTest.m +++ b/GoogleSignIn/Tests/Unit/GIDSignInInternalOptionsTest.m @@ -25,92 +25,139 @@ #import #endif -@interface GIDSignInInternalOptionsTest : XCTestCase -@end +static NSString *const kLoginHint = @"login_hint"; +static NSString *const kScope1 = @"scope1"; +static NSString *const kScope2 = @"scope2"; +static NSString *const kNonce = @"test_nonce"; +static NSString *const kClaimsAsJSON = @"{\"claim\":\"value\"}"; -@implementation GIDSignInInternalOptionsTest +@interface GIDSignInInternalOptionsTest : XCTestCase { + /// Mock for the configuration passed to the option factories. + id _configuration; -- (void)testDefaultOptions { - id configuration = OCMStrictClassMock([GIDConfiguration class]); #if TARGET_OS_IOS || TARGET_OS_MACCATALYST - id presentingViewController = OCMStrictClassMock([UIViewController class]); + /// Mock for the presenting view controller passed to the option factories. + id _presentingViewController; #elif TARGET_OS_OSX - id presentingWindow = OCMStrictClassMock([NSWindow class]); + /// Mock for the presenting window passed to the option factories. + id _presentingWindow; #endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST - NSString *loginHint = @"login_hint"; +} +@end - GIDSignInCompletion completion = ^(GIDSignInResult *_Nullable signInResult, - NSError * _Nullable error) {}; - GIDSignInInternalOptions *options = - [GIDSignInInternalOptions defaultOptionsWithConfiguration:configuration +@implementation GIDSignInInternalOptionsTest + +#pragma mark - Lifecycle + +- (void)setUp { + [super setUp]; + _configuration = OCMStrictClassMock([GIDConfiguration class]); #if TARGET_OS_IOS || TARGET_OS_MACCATALYST - presentingViewController:presentingViewController + _presentingViewController = OCMStrictClassMock([UIViewController class]); #elif TARGET_OS_OSX - presentingWindow:presentingWindow + _presentingWindow = OCMStrictClassMock([NSWindow class]); #endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST - loginHint:loginHint - addScopesFlow:NO - completion:completion]; - XCTAssertTrue(options.interactive); - XCTAssertFalse(options.continuation); - XCTAssertFalse(options.addScopesFlow); - XCTAssertNil(options.extraParams); +} + +#pragma mark - Helpers + +/// The claim set requested by `-optionsWithAllParameters`. `GIDClaim` implements +/// `-isEqual:` by name and essentiality, so a freshly built set compares equal. +- (NSSet *)expectedClaims { + return [NSSet setWithObject:[GIDClaim authTimeClaim]]; +} - OCMVerifyAll(configuration); +- (GIDSignInInternalOptions *)optionsWithAllParameters { + GIDSignInCompletion completion = ^(GIDSignInResult *_Nullable signInResult, + NSError *_Nullable error) {}; + return [GIDSignInInternalOptions defaultOptionsWithConfiguration:_configuration #if TARGET_OS_IOS || TARGET_OS_MACCATALYST - OCMVerifyAll(presentingViewController); + presentingViewController:_presentingViewController #elif TARGET_OS_OSX - OCMVerifyAll(presentingWindow); + presentingWindow:_presentingWindow #endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST + loginHint:kLoginHint + addScopesFlow:NO + scopes:@[kScope1, kScope2] + nonce:kNonce + claims:[self expectedClaims] + completion:completion]; } -- (void)testDefaultOptions_withAllParameters_initializesPropertiesCorrectly { - id configuration = OCMStrictClassMock([GIDConfiguration class]); +/// Verifies the mocks created in `-setUp` have no unfulfilled expectations. +- (void)verifyConfigurationAndPresentationMocks { + OCMVerifyAll(_configuration); #if TARGET_OS_IOS || TARGET_OS_MACCATALYST - id presentingViewController = OCMStrictClassMock([UIViewController class]); + OCMVerifyAll(_presentingViewController); #elif TARGET_OS_OSX - id presentingWindow = OCMStrictClassMock([NSWindow class]); + OCMVerifyAll(_presentingWindow); #endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST - NSString *loginHint = @"login_hint"; - NSArray *scopes = @[@"scope1", @"scope2"]; - NSString *nonce = @"test_nonce"; - NSSet *claims = [NSSet setWithObject:[GIDClaim authTimeClaim]]; - NSArray *expectedScopes = @[@"scope1", @"scope2", @"email", @"profile"]; +} +#pragma mark - Tests + +- (void)testDefaultOptions { GIDSignInCompletion completion = ^(GIDSignInResult *_Nullable signInResult, - NSError * _Nullable error) {}; + NSError *_Nullable error) {}; GIDSignInInternalOptions *options = - [GIDSignInInternalOptions defaultOptionsWithConfiguration:configuration + [GIDSignInInternalOptions defaultOptionsWithConfiguration:_configuration #if TARGET_OS_IOS || TARGET_OS_MACCATALYST - presentingViewController:presentingViewController + presentingViewController:_presentingViewController #elif TARGET_OS_OSX - presentingWindow:presentingWindow + presentingWindow:_presentingWindow #endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST - loginHint:loginHint + loginHint:kLoginHint addScopesFlow:NO - scopes:scopes - nonce:nonce - claims:claims completion:completion]; XCTAssertTrue(options.interactive); XCTAssertFalse(options.continuation); XCTAssertFalse(options.addScopesFlow); XCTAssertNil(options.extraParams); + [self verifyConfigurationAndPresentationMocks]; +} + +- (void)testDefaultOptions_withAllParameters_initializesPropertiesCorrectly { + NSArray *expectedScopes = @[kScope1, kScope2, @"email", @"profile"]; + + GIDSignInInternalOptions *options = [self optionsWithAllParameters]; + + XCTAssertTrue(options.interactive); + XCTAssertFalse(options.continuation); + XCTAssertFalse(options.addScopesFlow); + XCTAssertNil(options.extraParams); + // Convert arrays to sets for comparison to make the test order-independent. - XCTAssertEqualObjects([NSSet setWithArray:options.scopes], [NSSet setWithArray:expectedScopes]); - XCTAssertEqualObjects(options.nonce, nonce); - XCTAssertEqualObjects(options.claims, claims); + XCTAssertEqualObjects([NSSet setWithArray:options.scopes], + [NSSet setWithArray:expectedScopes]); + XCTAssertEqualObjects(options.nonce, kNonce); + XCTAssertEqualObjects(options.claims, [self expectedClaims]); XCTAssertNil(options.claimsAsJSON); - OCMVerifyAll(configuration); -#if TARGET_OS_IOS || TARGET_OS_MACCATALYST - OCMVerifyAll(presentingViewController); -#elif TARGET_OS_OSX - OCMVerifyAll(presentingWindow); -#endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST + [self verifyConfigurationAndPresentationMocks]; } +- (void)testOptionsWithExtraParameters_forContinuation_preservesAllPropertiesAndSetsContinuation { + GIDSignInInternalOptions *options = [self optionsWithAllParameters]; + options.claimsAsJSON = kClaimsAsJSON; + NSDictionary *extraParams = @{@"extra_key" : @"extra_value"}; + + GIDSignInInternalOptions *continuationOptions = + [options optionsWithExtraParameters:extraParams forContinuation:YES]; + + XCTAssertEqualObjects(continuationOptions.nonce, kNonce); + XCTAssertEqualObjects(continuationOptions.claims, [self expectedClaims]); + XCTAssertEqualObjects(continuationOptions.claimsAsJSON, kClaimsAsJSON); + XCTAssertTrue(continuationOptions.continuation); + XCTAssertEqualObjects(continuationOptions.extraParams, extraParams); + XCTAssertEqualObjects(continuationOptions.loginHint, kLoginHint); + XCTAssertEqualObjects([NSSet setWithArray:continuationOptions.scopes], + [NSSet setWithArray:options.scopes]); + XCTAssertFalse(continuationOptions.addScopesFlow); + XCTAssertTrue(continuationOptions.interactive); + + [self verifyConfigurationAndPresentationMocks]; +} - (void)testSilentOptions { GIDSignInCompletion completion = ^(GIDSignInResult *_Nullable signInResult, From 8d9ebcaac9fceb2dd2b0e7ede1daa5984931c52f Mon Sep 17 00:00:00 2001 From: Worthing <115107835+w-goog@users.noreply.github.com> Date: Mon, 21 Sep 2026 15:54:08 -0700 Subject: [PATCH 06/20] Fix crash when EMM emitted non-string errors (#638) --- CHANGELOG.md | 1 + GoogleSignIn/Sources/GIDEMMErrorHandler.m | 34 ++++++++++--------- .../Tests/Unit/GIDEMMErrorHandlerTest.m | 18 ++++++++++ 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fab43bc0..5346f1fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ - Fix a data race on `GIDGoogleUser`'s access, refresh and ID tokens. Concurrent token refreshes could previously write the three properties from different queues at once, and readers could observe a partially-updated set. +- Fix a crash when a server error response carries a non-string value under its `error` key. The EMM error handler sent `-hasPrefix:` to whatever value was present, raising an unrecognized selector exception on a number, array or object. - Fix a custom `nonce` and requested token `claims` being dropped when a sign-in is continued after a Device Policy app restart. # 10.0.0 diff --git a/GoogleSignIn/Sources/GIDEMMErrorHandler.m b/GoogleSignIn/Sources/GIDEMMErrorHandler.m index 1429a435..1e00d934 100644 --- a/GoogleSignIn/Sources/GIDEMMErrorHandler.m +++ b/GoogleSignIn/Sources/GIDEMMErrorHandler.m @@ -64,23 +64,25 @@ - (BOOL)handleErrorFromResponse:(NSDictionary *)response if (!_pendingDialog && [UIAlertController class] && [response isKindOfClass:[NSDictionary class]]) { id errorValue = response[kErrorKey]; - if ([errorValue isEqual:kScreenlockRequiredError]) { - errorCode = ErrorCodeScreenlockRequired; - } else if ([errorValue hasPrefix:kAppVerificationRequiredErrorPrefix]) { - errorCode = ErrorCodeAppVerificationRequired; - NSString *appVerificationString = - [errorValue substringFromIndex:kAppVerificationRequiredErrorPrefix.length]; - if ([appVerificationString hasPrefix:kErrorPayloadSeparator]) { - appVerificationString = - [appVerificationString substringFromIndex:kErrorPayloadSeparator.length]; + if ([errorValue isKindOfClass:[NSString class]]) { + if ([errorValue isEqual:kScreenlockRequiredError]) { + errorCode = ErrorCodeScreenlockRequired; + } else if ([errorValue hasPrefix:kAppVerificationRequiredErrorPrefix]) { + errorCode = ErrorCodeAppVerificationRequired; + NSString *appVerificationString = + [errorValue substringFromIndex:kAppVerificationRequiredErrorPrefix.length]; + if ([appVerificationString hasPrefix:kErrorPayloadSeparator]) { + appVerificationString = + [appVerificationString substringFromIndex:kErrorPayloadSeparator.length]; + } + appVerificationString = [appVerificationString + stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (appVerificationString.length) { + appVerificationURL = [NSURL URLWithString:appVerificationString]; + } + } else if ([errorValue hasPrefix:kGeneralErrorPrefix]) { + errorCode = ErrorCodeDeviceNotCompliant; } - appVerificationString = [appVerificationString - stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; - if (appVerificationString.length) { - appVerificationURL = [NSURL URLWithString:appVerificationString]; - } - } else if ([errorValue hasPrefix:kGeneralErrorPrefix]) { - errorCode = ErrorCodeDeviceNotCompliant; } if (errorCode) { _pendingDialog = YES; diff --git a/GoogleSignIn/Tests/Unit/GIDEMMErrorHandlerTest.m b/GoogleSignIn/Tests/Unit/GIDEMMErrorHandlerTest.m index b51519c6..c623d92d 100644 --- a/GoogleSignIn/Tests/Unit/GIDEMMErrorHandlerTest.m +++ b/GoogleSignIn/Tests/Unit/GIDEMMErrorHandlerTest.m @@ -120,6 +120,24 @@ - (void)testNoError { XCTAssertNil(_presentedViewController); } +// Verifies that a non-string value under the `error` key is ignored rather than crashing. +// The value comes straight from a server JSON response and is typed `id`, so it can be any +// plist type. `-hasPrefix:` is an `NSString` method, so before the type check was added it +// raised an unrecognized selector exception on a number, array or dictionary. +- (void)testNonStringErrorValue { + NSArray *nonStringValues = @[ @123, @[ @"emm_passcode_required" ], @{ @"a" : @"b" } ]; + for (id nonStringValue in nonStringValues) { + __block BOOL completionCalled = NO; + NSDictionary *response = @{ @"error" : nonStringValue }; + BOOL result = [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:response + completion:^() { + completionCalled = YES; + }]; + XCTAssertFalse(result); + XCTAssertTrue(completionCalled); + } +} + // Verifies that the handler doesn't handle non-EMM error. - (void)testNoEMMError { __block BOOL completionCalled = NO; From 8d8f0dd0fd450152aaa5c438602a5e8c801d6e71 Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:29:26 -0700 Subject: [PATCH 07/20] g-orchestrated: Changelog: serialize GIDGoogleUser token access --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5346f1fe..64e8f7b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ observe a partially-updated set. - Fix a crash when a server error response carries a non-string value under its `error` key. The EMM error handler sent `-hasPrefix:` to whatever value was present, raising an unrecognized selector exception on a number, array or object. - Fix a custom `nonce` and requested token `claims` being dropped when a sign-in is continued after a Device Policy app restart. +- Fix a data race on `GIDGoogleUser`'s access, refresh and ID tokens. Concurrent token refreshes could previously write the three properties from different queues at once, and readers could observe a partially-updated set. # 10.0.0 - **BREAKING**: Update to AppAuth 3.0.0 and GTMAppAuth 6.0.0, which raises the minimum deployment targets to iOS 15.0 and macOS 12.0, widens the `GTMSessionFetcher` dependency to allow 4.x and 5.x, and renames the version-specific Swift Package Manager manifest to `Package@swift-5.7.swift`. Projects that must keep supporting earlier OS versions should stay on GoogleSignIn 9.2.0. ([#628](https://github.com/google/GoogleSignIn-iOS/pull/628)) From 7f98cfe38578d042e02107dc84929e09a69ba372 Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:50:58 -0700 Subject: [PATCH 08/20] g-orchestrated: Test: race token updates against public property reads --- GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m | 56 +++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m b/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m index 7217ad55..95388308 100644 --- a/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m +++ b/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m @@ -334,6 +334,62 @@ - (void)testRefreshTokensIfNeeded_readsConsistentSnapshot { // This is a guard against the snapshot refactor changing the refresh decision, not a race test. } +// Races concurrent token updates against plain reads of the three public token properties, which +// is how apps read them. Without Thread Sanitizer this only checks that nothing crashes; under +// Thread Sanitizer (`-enableThreadSanitizer YES`) it fails if any of the three properties is read +// without the lock that `-updateTokensWithAuthState:` writes them under. +- (void)testTokenProperties_concurrentUpdatesAndReads { + GIDGoogleUser *user = [self googleUserWithAccessTokenExpiresIn:kAccessTokenExpiresIn + idTokenExpiresIn:kIDTokenExpiresIn]; + + NSString *idTokenA = [self idTokenWithExpiresIn:kNewIDTokenExpiresIn]; + OIDAuthState *authStateA = [OIDAuthState testInstanceWithIDToken:idTokenA + accessToken:@"access_token_A" + accessTokenExpiresIn:kAccessTokenExpiresIn + refreshToken:kNewRefreshToken]; + + NSString *idTokenB = [self idTokenWithExpiresIn:kNewIDTokenExpiresIn + 1]; + OIDAuthState *authStateB = [OIDAuthState testInstanceWithIDToken:idTokenB + accessToken:@"access_token_B" + accessTokenExpiresIn:kAccessTokenExpiresIn + refreshToken:kNewRefreshToken]; + + XCTestExpectation *updateExpectation = [self expectationWithDescription:@"Updates finished"]; + XCTestExpectation *readExpectation = [self expectationWithDescription:@"Reads finished"]; + + dispatch_queue_t updateQueue = + dispatch_queue_create("com.google.gidgoogleuser.testTokenProperties.update", + DISPATCH_QUEUE_CONCURRENT); + dispatch_queue_t readQueue = + dispatch_queue_create("com.google.gidgoogleuser.testTokenProperties.read", + DISPATCH_QUEUE_CONCURRENT); + + size_t iterations = 2000; + + dispatch_async(updateQueue, ^{ + dispatch_apply(iterations, updateQueue, ^(size_t i) { + OIDAuthState *state = (i % 2 == 0) ? authStateA : authStateB; + [user updateWithTokenResponse:state.lastTokenResponse + authorizationResponse:state.lastAuthorizationResponse + profileData:nil]; + }); + [updateExpectation fulfill]; + }); + + dispatch_async(readQueue, ^{ + dispatch_apply(iterations, readQueue, ^(size_t i) { + (void)user.accessToken.tokenString; + (void)user.refreshToken.tokenString; + (void)user.idToken.tokenString; + }); + [readExpectation fulfill]; + }); + + [self waitForExpectationsWithTimeout:30 handler:nil]; + + XCTAssertNotNil(user.accessToken); +} + - (void)testFetcherAuthorizer { // This is really hard to test without assuming how GTMAppAuthFetcherAuthorization works // internally, so let's just take the shortcut here by asserting we get a From d2a2ec554e3fb242451282f8d1093f48760161b8 Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:50:59 -0700 Subject: [PATCH 09/20] g-orchestrated: CI: run the iOS unit tests under Thread Sanitizer --- .github/workflows/unit_tests.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 67c9fd09..5567c9c2 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -68,6 +68,31 @@ jobs: -destination ${{ matrix.destination }} \ test-without-building + thread-sanitizer-test: + # Run the iOS unit tests with Thread Sanitizer so data races (for example on + # GIDGoogleUser's token properties) fail CI instead of shipping silently. + runs-on: macos-15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_16.4.app/Contents/Developer + - name: Build unit test target with Thread Sanitizer + run: | + xcodebuild \ + -scheme GoogleSignIn-Package \ + -sdk iphonesimulator \ + -destination "platform=iOS Simulator,name=iPhone 16,OS=18.6" \ + -enableThreadSanitizer YES \ + build-for-testing + - name: Run unit test target with Thread Sanitizer + run: | + xcodebuild \ + -scheme GoogleSignIn-Package \ + -sdk iphonesimulator \ + -destination "platform=iOS Simulator,name=iPhone 16,OS=18.6" \ + -enableThreadSanitizer YES \ + test-without-building + signin-sample-spm-build: runs-on: macos-15 steps: From 5b4a66b8e9a1bda1fb082913549cf27e365e8284 Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:10:35 -0700 Subject: [PATCH 10/20] g-orchestrated: Replace @synchronized(self) in GIDGoogleUser with private locks --- GoogleSignIn/Sources/GIDGoogleUser.m | 263 ++++++++++++++++++--------- 1 file changed, 174 insertions(+), 89 deletions(-) diff --git a/GoogleSignIn/Sources/GIDGoogleUser.m b/GoogleSignIn/Sources/GIDGoogleUser.m index 56b00375..117389f0 100644 --- a/GoogleSignIn/Sources/GIDGoogleUser.m +++ b/GoogleSignIn/Sources/GIDGoogleUser.m @@ -34,6 +34,8 @@ #import #endif +#import + NS_ASSUME_NONNULL_BEGIN // The ID Token claim key for the hosted domain value. @@ -79,6 +81,23 @@ @implementation GIDGoogleUser { GIDToken *_accessToken; GIDToken *_refreshToken; GIDToken *_idToken; + + // Guards `_accessToken`, `_refreshToken`, `_idToken` and `_cachedConfiguration`. It must never + // be held while calling methods on other objects, KVO notification methods, or any method on + // self that could take `_tokenLock` again, since it is not recursive. + os_unfair_lock _tokenLock; + + // Serializes -updateWithTokenResponse:authorizationResponse:profileData:. It is held while + // AppAuth runs and calls back synchronously into -didChangeState:. That is safe because + // -didChangeState: takes `_tokenUpdateLock` and `_tokenLock`, never `_authStateUpdateLock`. The + // lock order is therefore _authStateUpdateLock, then _tokenUpdateLock, then _tokenLock. + os_unfair_lock _authStateUpdateLock; + + // Serializes -updateTokensWithAuthState: so concurrent updates apply in the order they read + // authState. Only writers take it; readers take `_tokenLock` alone, so they are never blocked + // by token parsing or KVO observers. KVO observers run while it is held, so they must not + // synchronously update this user's tokens. + os_unfair_lock _tokenUpdateLock; } @synthesize accessToken = _accessToken; @@ -86,54 +105,62 @@ @implementation GIDGoogleUser { @synthesize idToken = _idToken; - (GIDToken *)accessToken { - @synchronized(self) { - return _accessToken; - } + os_unfair_lock_lock(&_tokenLock); + GIDToken *accessToken = _accessToken; + os_unfair_lock_unlock(&_tokenLock); + return accessToken; } - (void)setAccessToken:(GIDToken *)accessToken { - @synchronized(self) { - _accessToken = accessToken; - } + os_unfair_lock_lock(&_tokenLock); + _accessToken = accessToken; + os_unfair_lock_unlock(&_tokenLock); } - (GIDToken *)refreshToken { - @synchronized(self) { - return _refreshToken; - } + os_unfair_lock_lock(&_tokenLock); + GIDToken *refreshToken = _refreshToken; + os_unfair_lock_unlock(&_tokenLock); + return refreshToken; } - (void)setRefreshToken:(GIDToken *)refreshToken { - @synchronized(self) { - _refreshToken = refreshToken; - } + os_unfair_lock_lock(&_tokenLock); + _refreshToken = refreshToken; + os_unfair_lock_unlock(&_tokenLock); } - (nullable GIDToken *)idToken { - @synchronized(self) { - return _idToken; - } + os_unfair_lock_lock(&_tokenLock); + GIDToken *idToken = _idToken; + os_unfair_lock_unlock(&_tokenLock); + return idToken; } - (void)setIdToken:(nullable GIDToken *)idToken { - @synchronized(self) { - _idToken = idToken; - } + os_unfair_lock_lock(&_tokenLock); + _idToken = idToken; + os_unfair_lock_unlock(&_tokenLock); } - (void)getAccessToken:(GIDToken *_Nullable *_Nullable)accessToken refreshToken:(GIDToken *_Nullable *_Nullable)refreshToken idToken:(GIDToken *_Nullable *_Nullable)idToken { - @synchronized(self) { - if (accessToken) { - *accessToken = _accessToken; - } - if (refreshToken) { - *refreshToken = _refreshToken; - } - if (idToken) { - *idToken = _idToken; - } + // Snapshot the tokens under the lock, then write them out with the lock released. + os_unfair_lock_lock(&_tokenLock); + GIDToken *currentAccessToken = _accessToken; + GIDToken *currentRefreshToken = _refreshToken; + GIDToken *currentIdToken = _idToken; + os_unfair_lock_unlock(&_tokenLock); + + if (accessToken) { + *accessToken = currentAccessToken; + } + if (refreshToken) { + *refreshToken = currentRefreshToken; + } + if (idToken) { + *idToken = currentIdToken; } } @@ -167,22 +194,33 @@ - (nullable NSString *)userID { } - (GIDConfiguration *)configuration { - @synchronized(self) { - // Caches the configuration since it would not change for one GIDGoogleUser instance. - if (!_cachedConfiguration) { - NSString *clientID = self.authState.lastAuthorizationResponse.request.clientID; - NSString *serverClientID = - self.authState.lastTokenResponse.request.additionalParameters[kAudienceParameter]; - NSString *openIDRealm = - self.authState.lastTokenResponse.request.additionalParameters[kOpenIDRealmParameter]; - - _cachedConfiguration = [[GIDConfiguration alloc] initWithClientID:clientID - serverClientID:serverClientID - hostedDomain:[self hostedDomain] - openIDRealm:openIDRealm]; - }; + // Caches the configuration since it would not change for one GIDGoogleUser instance. + os_unfair_lock_lock(&_tokenLock); + GIDConfiguration *configuration = _cachedConfiguration; + os_unfair_lock_unlock(&_tokenLock); + if (configuration) { + return configuration; } - return _cachedConfiguration; + + NSString *clientID = self.authState.lastAuthorizationResponse.request.clientID; + NSString *serverClientID = + self.authState.lastTokenResponse.request.additionalParameters[kAudienceParameter]; + NSString *openIDRealm = + self.authState.lastTokenResponse.request.additionalParameters[kOpenIDRealmParameter]; + + configuration = [[GIDConfiguration alloc] initWithClientID:clientID + serverClientID:serverClientID + hostedDomain:[self hostedDomain] + openIDRealm:openIDRealm]; + + os_unfair_lock_lock(&_tokenLock); + if (!_cachedConfiguration) { + _cachedConfiguration = configuration; + } + configuration = _cachedConfiguration; + os_unfair_lock_unlock(&_tokenLock); + + return configuration; } - (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion { @@ -316,6 +354,12 @@ - (instancetype)initWithAuthState:(OIDAuthState *)authState profileData:(nullable GIDProfileData *)profileData { self = [super init]; if (self) { + // Initialize the locks first, -updateTokensWithAuthState: below takes `_tokenUpdateLock` and + // `_tokenLock`. + _tokenLock = OS_UNFAIR_LOCK_INIT; + _authStateUpdateLock = OS_UNFAIR_LOCK_INIT; + _tokenUpdateLock = OS_UNFAIR_LOCK_INIT; + _tokenRefreshHandlerQueue = [[NSMutableArray alloc] init]; _profile = profileData; @@ -335,58 +379,99 @@ - (instancetype)initWithAuthState:(OIDAuthState *)authState - (void)updateWithTokenResponse:(OIDTokenResponse *)tokenResponse authorizationResponse:(OIDAuthorizationResponse *)authorizationResponse profileData:(nullable GIDProfileData *)profileData { - @synchronized(self) { - _profile = profileData; - - // We don't want to trigger the delegate before we update authState completely. So we unset the - // delegate before the first update. Also the order of updates is important because - // `updateWithAuthorizationResponse` would clear the last token reponse and refresh token. - // TODO: Rewrite authState update logic when the issue is addressed.(openid/AppAuth-iOS#728) - self.authState.stateChangeDelegate = nil; - [self.authState updateWithAuthorizationResponse:authorizationResponse error:nil]; - self.authState.stateChangeDelegate = self; - [self.authState updateWithTokenResponse:tokenResponse error:nil]; - } + os_unfair_lock_lock(&_authStateUpdateLock); + _profile = profileData; + + // We don't want to trigger the delegate before we update authState completely. So we unset the + // delegate before the first update. Also the order of updates is important because + // `updateWithAuthorizationResponse` would clear the last token reponse and refresh token. + // TODO: Rewrite authState update logic when the issue is addressed.(openid/AppAuth-iOS#728) + self.authState.stateChangeDelegate = nil; + [self.authState updateWithAuthorizationResponse:authorizationResponse error:nil]; + self.authState.stateChangeDelegate = self; + [self.authState updateWithTokenResponse:tokenResponse error:nil]; + os_unfair_lock_unlock(&_authStateUpdateLock); } - (void)updateTokensWithAuthState:(OIDAuthState *)authState { - @synchronized(self) { - GIDToken *accessToken = - [[GIDToken alloc] initWithTokenString:authState.lastTokenResponse.accessToken - expirationDate:authState.lastTokenResponse.accessTokenExpirationDate]; - if (![self.accessToken isEqualToToken:accessToken]) { - self.accessToken = accessToken; - } + os_unfair_lock_lock(&_tokenUpdateLock); + + // Phase A: build the new tokens without holding `_tokenLock`, since parsing the ID token can be + // slow. + GIDToken *accessToken = + [[GIDToken alloc] initWithTokenString:authState.lastTokenResponse.accessToken + expirationDate:authState.lastTokenResponse.accessTokenExpirationDate]; + + NSDictionary *additionalParameters = authState.lastTokenResponse.additionalParameters; + NSNumber *refreshTokenExpiresIn = nil; + NSDate *refreshTokenExpirationDate = nil; + id expiresInValue = additionalParameters[@"refresh_token_expires_in"]; + if ([expiresInValue isKindOfClass:[NSNumber class]]) { + refreshTokenExpiresIn = (NSNumber *)expiresInValue; + NSTimeInterval interval = [refreshTokenExpiresIn doubleValue]; + refreshTokenExpirationDate = [NSDate dateWithTimeIntervalSinceNow:interval]; + } + GIDToken *refreshToken = [[GIDToken alloc] initWithTokenString:authState.refreshToken + expirationDate:refreshTokenExpirationDate]; - NSDictionary *additionalParameters = authState.lastTokenResponse.additionalParameters; - NSNumber *refreshTokenExpiresIn = nil; - NSDate *refreshTokenExpirationDate = nil; - id expiresInValue = additionalParameters[@"refresh_token_expires_in"]; - if ([expiresInValue isKindOfClass:[NSNumber class]]) { - refreshTokenExpiresIn = (NSNumber *)expiresInValue; - NSTimeInterval interval = [refreshTokenExpiresIn doubleValue]; - refreshTokenExpirationDate = [NSDate dateWithTimeIntervalSinceNow:interval]; - } - GIDToken *refreshToken = [[GIDToken alloc] initWithTokenString:authState.refreshToken - expirationDate:refreshTokenExpirationDate]; - if (![self.refreshToken isEqualToToken:refreshToken]) { - self.refreshToken = refreshToken; - } + GIDToken *idToken; + NSString *idTokenString = authState.lastTokenResponse.idToken; + if (idTokenString) { + NSDate *idTokenExpirationDate = + [[[OIDIDToken alloc] initWithIDTokenString:idTokenString] expiresAt]; + idToken = [[GIDToken alloc] initWithTokenString:idTokenString + expirationDate:idTokenExpirationDate]; + } else { + idToken = nil; + } - GIDToken *idToken; - NSString *idTokenString = authState.lastTokenResponse.idToken; - if (idTokenString) { - NSDate *idTokenExpirationDate = - [[[OIDIDToken alloc] initWithIDTokenString:idTokenString] expiresAt]; - idToken = [[GIDToken alloc] initWithTokenString:idTokenString - expirationDate:idTokenExpirationDate]; - } else { - idToken = nil; - } - if ((self.idToken || idToken) && ![self.idToken isEqualToToken:idToken]) { - self.idToken = idToken; - } + // Phase B: take the lock just long enough to see which tokens would actually change. + os_unfair_lock_lock(&_tokenLock); + BOOL accessTokenChanged = ![_accessToken isEqualToToken:accessToken]; + BOOL refreshTokenChanged = ![_refreshToken isEqualToToken:refreshToken]; + BOOL idTokenChanged = (_idToken || idToken) && ![_idToken isEqualToToken:idToken]; + os_unfair_lock_unlock(&_tokenLock); + + if (!accessTokenChanged && !refreshTokenChanged && !idTokenChanged) { + os_unfair_lock_unlock(&_tokenUpdateLock); + return; + } + + // Phase C: the changed tokens are swapped together under a single lock so that readers never + // see a mix of old and new tokens. The KVO notifications are sent with no lock held. + if (accessTokenChanged) { + [self willChangeValueForKey:NSStringFromSelector(@selector(accessToken))]; + } + if (refreshTokenChanged) { + [self willChangeValueForKey:NSStringFromSelector(@selector(refreshToken))]; } + if (idTokenChanged) { + [self willChangeValueForKey:NSStringFromSelector(@selector(idToken))]; + } + + os_unfair_lock_lock(&_tokenLock); + if (accessTokenChanged) { + _accessToken = accessToken; + } + if (refreshTokenChanged) { + _refreshToken = refreshToken; + } + if (idTokenChanged) { + _idToken = idToken; + } + os_unfair_lock_unlock(&_tokenLock); + + if (idTokenChanged) { + [self didChangeValueForKey:NSStringFromSelector(@selector(idToken))]; + } + if (refreshTokenChanged) { + [self didChangeValueForKey:NSStringFromSelector(@selector(refreshToken))]; + } + if (accessTokenChanged) { + [self didChangeValueForKey:NSStringFromSelector(@selector(accessToken))]; + } + + os_unfair_lock_unlock(&_tokenUpdateLock); } #pragma mark - Helpers From b10b2f934df8a31f6fb706c1ec5b35fe907584d7 Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Tue, 22 Sep 2026 13:33:48 -0700 Subject: [PATCH 11/20] g-orchestrated: Store GIDGoogleUser tokens as one immutable set --- GoogleSignIn/Sources/GIDGoogleUser.m | 170 +++++++++---------- GoogleSignIn/Sources/GIDGoogleUser_Private.h | 6 - 2 files changed, 84 insertions(+), 92 deletions(-) diff --git a/GoogleSignIn/Sources/GIDGoogleUser.m b/GoogleSignIn/Sources/GIDGoogleUser.m index 117389f0..4eb252d9 100644 --- a/GoogleSignIn/Sources/GIDGoogleUser.m +++ b/GoogleSignIn/Sources/GIDGoogleUser.m @@ -63,6 +63,44 @@ @interface GIDGoogleUser () @end #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST +// An immutable snapshot of a user's tokens. It is replaced as a whole, so readers never see a mix +// of old and new tokens. +@interface GIDGoogleUserTokens : NSObject + +@property(nonatomic, readonly) GIDToken *accessToken; +@property(nonatomic, readonly) GIDToken *refreshToken; +@property(nonatomic, readonly, nullable) GIDToken *idToken; + +- (instancetype)initWithAccessToken:(GIDToken *)accessToken + refreshToken:(GIDToken *)refreshToken + idToken:(nullable GIDToken *)idToken NS_DESIGNATED_INITIALIZER; +- (instancetype)init NS_UNAVAILABLE; + +@end + +@implementation GIDGoogleUserTokens + +- (instancetype)initWithAccessToken:(GIDToken *)accessToken + refreshToken:(GIDToken *)refreshToken + idToken:(nullable GIDToken *)idToken { + self = [super init]; + if (self) { + _accessToken = accessToken; + _refreshToken = refreshToken; + _idToken = idToken; + } + return self; +} + +@end + +@interface GIDGoogleUser () + +// The user's current tokens. The getter and setter take `_tokenLock`, so the property is atomic. +@property(atomic, strong, nullable) GIDGoogleUserTokens *tokens; + +@end + @interface GIDGoogleUser (Internal) - (void)getAccessToken:(GIDToken *_Nullable *_Nullable)accessToken @@ -78,13 +116,10 @@ @implementation GIDGoogleUser { // Access to this ivar should be synchronized. NSMutableArray *_tokenRefreshHandlerQueue; - GIDToken *_accessToken; - GIDToken *_refreshToken; - GIDToken *_idToken; + GIDGoogleUserTokens *_tokens; - // Guards `_accessToken`, `_refreshToken`, `_idToken` and `_cachedConfiguration`. It must never - // be held while calling methods on other objects, KVO notification methods, or any method on - // self that could take `_tokenLock` again, since it is not recursive. + // Guards `_tokens` and `_cachedConfiguration`. 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; // Serializes -updateWithTokenResponse:authorizationResponse:profileData:. It is held while @@ -94,73 +129,65 @@ @implementation GIDGoogleUser { os_unfair_lock _authStateUpdateLock; // Serializes -updateTokensWithAuthState: so concurrent updates apply in the order they read - // authState. Only writers take it; readers take `_tokenLock` alone, so they are never blocked - // by token parsing or KVO observers. KVO observers run while it is held, so they must not + // authState. Only writers take it; readers take only `_tokenLock`, so token parsing and KVO + // observers never block them. KVO observers run while it is held, so they must not // synchronously update this user's tokens. os_unfair_lock _tokenUpdateLock; } -@synthesize accessToken = _accessToken; -@synthesize refreshToken = _refreshToken; -@synthesize idToken = _idToken; - -- (GIDToken *)accessToken { +- (nullable GIDGoogleUserTokens *)tokens { os_unfair_lock_lock(&_tokenLock); - GIDToken *accessToken = _accessToken; + GIDGoogleUserTokens *tokens = _tokens; os_unfair_lock_unlock(&_tokenLock); - return accessToken; + return tokens; } -- (void)setAccessToken:(GIDToken *)accessToken { +- (void)setTokens:(nullable GIDGoogleUserTokens *)tokens { os_unfair_lock_lock(&_tokenLock); - _accessToken = accessToken; + _tokens = tokens; os_unfair_lock_unlock(&_tokenLock); } -- (GIDToken *)refreshToken { - os_unfair_lock_lock(&_tokenLock); - GIDToken *refreshToken = _refreshToken; - os_unfair_lock_unlock(&_tokenLock); - return refreshToken; +- (GIDToken *)accessToken { + return self.tokens.accessToken; } -- (void)setRefreshToken:(GIDToken *)refreshToken { - os_unfair_lock_lock(&_tokenLock); - _refreshToken = refreshToken; - os_unfair_lock_unlock(&_tokenLock); +- (GIDToken *)refreshToken { + return self.tokens.refreshToken; } - (nullable GIDToken *)idToken { - os_unfair_lock_lock(&_tokenLock); - GIDToken *idToken = _idToken; - os_unfair_lock_unlock(&_tokenLock); - return idToken; + return self.tokens.idToken; } -- (void)setIdToken:(nullable GIDToken *)idToken { - os_unfair_lock_lock(&_tokenLock); - _idToken = idToken; - os_unfair_lock_unlock(&_tokenLock); +// The token properties are derived from `tokens`, so KVO observers of each one are notified +// whenever `tokens` is replaced. ++ (NSSet *)keyPathsForValuesAffectingAccessToken { + return [NSSet setWithObject:NSStringFromSelector(@selector(tokens))]; +} + ++ (NSSet *)keyPathsForValuesAffectingRefreshToken { + return [NSSet setWithObject:NSStringFromSelector(@selector(tokens))]; +} + ++ (NSSet *)keyPathsForValuesAffectingIdToken { + return [NSSet setWithObject:NSStringFromSelector(@selector(tokens))]; } - (void)getAccessToken:(GIDToken *_Nullable *_Nullable)accessToken refreshToken:(GIDToken *_Nullable *_Nullable)refreshToken idToken:(GIDToken *_Nullable *_Nullable)idToken { - // Snapshot the tokens under the lock, then write them out with the lock released. - os_unfair_lock_lock(&_tokenLock); - GIDToken *currentAccessToken = _accessToken; - GIDToken *currentRefreshToken = _refreshToken; - GIDToken *currentIdToken = _idToken; - os_unfair_lock_unlock(&_tokenLock); + // A single read of `tokens` gives a consistent snapshot of all three. + GIDGoogleUserTokens *tokens = self.tokens; if (accessToken) { - *accessToken = currentAccessToken; + *accessToken = tokens.accessToken; } if (refreshToken) { - *refreshToken = currentRefreshToken; + *refreshToken = tokens.refreshToken; } if (idToken) { - *idToken = currentIdToken; + *idToken = tokens.idToken; } } @@ -395,9 +422,8 @@ - (void)updateWithTokenResponse:(OIDTokenResponse *)tokenResponse - (void)updateTokensWithAuthState:(OIDAuthState *)authState { os_unfair_lock_lock(&_tokenUpdateLock); + GIDGoogleUserTokens *current = self.tokens; - // Phase A: build the new tokens without holding `_tokenLock`, since parsing the ID token can be - // slow. GIDToken *accessToken = [[GIDToken alloc] initWithTokenString:authState.lastTokenResponse.accessToken expirationDate:authState.lastTokenResponse.accessTokenExpirationDate]; @@ -425,52 +451,24 @@ - (void)updateTokensWithAuthState:(OIDAuthState *)authState { idToken = nil; } - // Phase B: take the lock just long enough to see which tokens would actually change. - os_unfair_lock_lock(&_tokenLock); - BOOL accessTokenChanged = ![_accessToken isEqualToToken:accessToken]; - BOOL refreshTokenChanged = ![_refreshToken isEqualToToken:refreshToken]; - BOOL idTokenChanged = (_idToken || idToken) && ![_idToken isEqualToToken:idToken]; - os_unfair_lock_unlock(&_tokenLock); - - if (!accessTokenChanged && !refreshTokenChanged && !idTokenChanged) { - os_unfair_lock_unlock(&_tokenUpdateLock); - return; + // Keep the existing token objects when they are unchanged, so an update that changes nothing + // leaves `tokens` untouched and sends no KVO notifications. + if ([current.accessToken isEqualToToken:accessToken]) { + accessToken = current.accessToken; } - - // Phase C: the changed tokens are swapped together under a single lock so that readers never - // see a mix of old and new tokens. The KVO notifications are sent with no lock held. - if (accessTokenChanged) { - [self willChangeValueForKey:NSStringFromSelector(@selector(accessToken))]; - } - if (refreshTokenChanged) { - [self willChangeValueForKey:NSStringFromSelector(@selector(refreshToken))]; - } - if (idTokenChanged) { - [self willChangeValueForKey:NSStringFromSelector(@selector(idToken))]; - } - - os_unfair_lock_lock(&_tokenLock); - if (accessTokenChanged) { - _accessToken = accessToken; + if ([current.refreshToken isEqualToToken:refreshToken]) { + refreshToken = current.refreshToken; } - if (refreshTokenChanged) { - _refreshToken = refreshToken; - } - if (idTokenChanged) { - _idToken = idToken; + if ([current.idToken isEqualToToken:idToken]) { + idToken = current.idToken; } - os_unfair_lock_unlock(&_tokenLock); - if (idTokenChanged) { - [self didChangeValueForKey:NSStringFromSelector(@selector(idToken))]; + if (!current || accessToken != current.accessToken || + refreshToken != current.refreshToken || idToken != current.idToken) { + self.tokens = [[GIDGoogleUserTokens alloc] initWithAccessToken:accessToken + refreshToken:refreshToken + idToken:idToken]; } - if (refreshTokenChanged) { - [self didChangeValueForKey:NSStringFromSelector(@selector(refreshToken))]; - } - if (accessTokenChanged) { - [self didChangeValueForKey:NSStringFromSelector(@selector(accessToken))]; - } - os_unfair_lock_unlock(&_tokenUpdateLock); } diff --git a/GoogleSignIn/Sources/GIDGoogleUser_Private.h b/GoogleSignIn/Sources/GIDGoogleUser_Private.h index f07a1045..2331595a 100644 --- a/GoogleSignIn/Sources/GIDGoogleUser_Private.h +++ b/GoogleSignIn/Sources/GIDGoogleUser_Private.h @@ -32,12 +32,6 @@ typedef void (^GIDGoogleUserCompletion)(GIDGoogleUser *_Nullable user, NSError * /// Internal methods for the class that are not part of the public API. @interface GIDGoogleUser () -@property(nonatomic, readwrite) GIDToken *accessToken; - -@property(nonatomic, readwrite) GIDToken *refreshToken; - -@property(nonatomic, readwrite, nullable) GIDToken *idToken; - /// A representation of the state of the OAuth session for this instance. @property(nonatomic, readonly) OIDAuthState *authState; From 9db8c362433116b501d1b2ce8796e84ebb355f4d Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:23:10 -0700 Subject: [PATCH 12/20] g-orchestrated: Replace GIDGoogleUser's update locks with one recursive lock --- GoogleSignIn/Sources/GIDGoogleUser.m | 31 ++++++++++++---------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/GoogleSignIn/Sources/GIDGoogleUser.m b/GoogleSignIn/Sources/GIDGoogleUser.m index 4eb252d9..234bab4e 100644 --- a/GoogleSignIn/Sources/GIDGoogleUser.m +++ b/GoogleSignIn/Sources/GIDGoogleUser.m @@ -122,17 +122,13 @@ @implementation GIDGoogleUser { // of those ivars, never while calling out to other code. os_unfair_lock _tokenLock; - // Serializes -updateWithTokenResponse:authorizationResponse:profileData:. It is held while - // AppAuth runs and calls back synchronously into -didChangeState:. That is safe because - // -didChangeState: takes `_tokenUpdateLock` and `_tokenLock`, never `_authStateUpdateLock`. The - // lock order is therefore _authStateUpdateLock, then _tokenUpdateLock, then _tokenLock. - os_unfair_lock _authStateUpdateLock; - - // Serializes -updateTokensWithAuthState: so concurrent updates apply in the order they read - // authState. Only writers take it; readers take only `_tokenLock`, so token parsing and KVO - // observers never block them. KVO observers run while it is held, so they must not - // synchronously update this user's tokens. - os_unfair_lock _tokenUpdateLock; + // Serializes every change GoogleSignIn makes to `authState`, as well as the token snapshot + // updates in -updateTokensWithAuthState:. It is recursive because AppAuth calls + // -didChangeState: synchronously while an update holds it, and because KVO observers of the + // token properties run while it is held and may call back into this user on the same thread. + // The lock order is `_authStateLock`, then `_tokenLock`; `_tokenLock` is never held while + // taking `_authStateLock`. + NSRecursiveLock *_authStateLock; } - (nullable GIDGoogleUserTokens *)tokens { @@ -381,11 +377,10 @@ - (instancetype)initWithAuthState:(OIDAuthState *)authState profileData:(nullable GIDProfileData *)profileData { self = [super init]; if (self) { - // Initialize the locks first, -updateTokensWithAuthState: below takes `_tokenUpdateLock` and + // Initialize the locks first, -updateTokensWithAuthState: below takes `_authStateLock` and // `_tokenLock`. _tokenLock = OS_UNFAIR_LOCK_INIT; - _authStateUpdateLock = OS_UNFAIR_LOCK_INIT; - _tokenUpdateLock = OS_UNFAIR_LOCK_INIT; + _authStateLock = [[NSRecursiveLock alloc] init]; _tokenRefreshHandlerQueue = [[NSMutableArray alloc] init]; _profile = profileData; @@ -406,7 +401,7 @@ - (instancetype)initWithAuthState:(OIDAuthState *)authState - (void)updateWithTokenResponse:(OIDTokenResponse *)tokenResponse authorizationResponse:(OIDAuthorizationResponse *)authorizationResponse profileData:(nullable GIDProfileData *)profileData { - os_unfair_lock_lock(&_authStateUpdateLock); + [_authStateLock lock]; _profile = profileData; // We don't want to trigger the delegate before we update authState completely. So we unset the @@ -417,11 +412,11 @@ - (void)updateWithTokenResponse:(OIDTokenResponse *)tokenResponse [self.authState updateWithAuthorizationResponse:authorizationResponse error:nil]; self.authState.stateChangeDelegate = self; [self.authState updateWithTokenResponse:tokenResponse error:nil]; - os_unfair_lock_unlock(&_authStateUpdateLock); + [_authStateLock unlock]; } - (void)updateTokensWithAuthState:(OIDAuthState *)authState { - os_unfair_lock_lock(&_tokenUpdateLock); + [_authStateLock lock]; GIDGoogleUserTokens *current = self.tokens; GIDToken *accessToken = @@ -469,7 +464,7 @@ - (void)updateTokensWithAuthState:(OIDAuthState *)authState { refreshToken:refreshToken idToken:idToken]; } - os_unfair_lock_unlock(&_tokenUpdateLock); + [_authStateLock unlock]; } #pragma mark - Helpers From 4d197d4db4d72cbfd89180be2028a9ed4f86cf2d Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:24:37 -0700 Subject: [PATCH 13/20] g-orchestrated: Read GIDGoogleUser's auth state under the auth state lock --- GoogleSignIn/Sources/GIDGoogleUser.m | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/GoogleSignIn/Sources/GIDGoogleUser.m b/GoogleSignIn/Sources/GIDGoogleUser.m index 234bab4e..1997f37d 100644 --- a/GoogleSignIn/Sources/GIDGoogleUser.m +++ b/GoogleSignIn/Sources/GIDGoogleUser.m @@ -200,7 +200,9 @@ - (nullable NSString *)userID { - (nullable NSArray *)grantedScopes { NSArray *grantedScopes; + [_authStateLock lock]; NSString *grantedScopeString = self.authState.lastTokenResponse.scope; + [_authStateLock unlock]; if (grantedScopeString) { // If we have a 'scope' parameter from the backend, this is authoritative. // Remove leading and trailing whitespace. @@ -225,6 +227,19 @@ - (GIDConfiguration *)configuration { return configuration; } + // Reads the auth state under `_authStateLock` so the configuration is never computed from a + // half-updated auth state. + [_authStateLock lock]; + + os_unfair_lock_lock(&_tokenLock); + configuration = _cachedConfiguration; + os_unfair_lock_unlock(&_tokenLock); + if (configuration) { + // Another thread filled the cache while we waited for `_authStateLock`. + [_authStateLock unlock]; + return configuration; + } + NSString *clientID = self.authState.lastAuthorizationResponse.request.clientID; NSString *serverClientID = self.authState.lastTokenResponse.request.additionalParameters[kAudienceParameter]; @@ -237,12 +252,11 @@ - (GIDConfiguration *)configuration { openIDRealm:openIDRealm]; os_unfair_lock_lock(&_tokenLock); - if (!_cachedConfiguration) { - _cachedConfiguration = configuration; - } - configuration = _cachedConfiguration; + _cachedConfiguration = configuration; os_unfair_lock_unlock(&_tokenLock); + [_authStateLock unlock]; + return configuration; } @@ -368,8 +382,11 @@ - (void)addScopes:(NSArray *)scopes #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST - (nullable NSString *)emmSupport { - return self.authState.lastAuthorizationResponse + [_authStateLock lock]; + NSString *emmSupport = self.authState.lastAuthorizationResponse .request.additionalParameters[kEMMSupportParameterName]; + [_authStateLock unlock]; + return emmSupport; } #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST From 0ec02bcb7f11f19f57bdddd0c635610dd922bbdb Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:26:12 -0700 Subject: [PATCH 14/20] g-orchestrated: Update auth state under the lock after a token refresh --- GoogleSignIn/Sources/GIDGoogleUser.m | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/GoogleSignIn/Sources/GIDGoogleUser.m b/GoogleSignIn/Sources/GIDGoogleUser.m index 1997f37d..93e41b09 100644 --- a/GoogleSignIn/Sources/GIDGoogleUser.m +++ b/GoogleSignIn/Sources/GIDGoogleUser.m @@ -293,6 +293,9 @@ - (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion } // This is the first handler in the queue, a fetch is needed. NSMutableDictionary *additionalParameters = [@{} mutableCopy]; + // Read the auth state under `_authStateLock` so building the request cannot interleave with + // -updateWithTokenResponse:authorizationResponse:profileData:. + [_authStateLock lock]; #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST [additionalParameters addEntriesFromDictionary: [GIDEMMSupport updatedEMMParametersWithParameters: @@ -303,12 +306,18 @@ - (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST [additionalParameters addEntriesFromDictionary:[GIDSignInPreferences loggingParameters]]; + OIDAuthorizationResponse *authorizationResponse = self.authState.lastAuthorizationResponse; OIDTokenRequest *tokenRefreshRequest = [self.authState tokenRefreshRequestWithAdditionalParameters:additionalParameters]; + [_authStateLock unlock]; + [OIDAuthorizationService performTokenRequest:tokenRefreshRequest - originalAuthorizationResponse:self.authState.lastAuthorizationResponse + originalAuthorizationResponse:authorizationResponse callback:^(OIDTokenResponse *_Nullable tokenResponse, NSError *_Nullable error) { + // Update the auth state under `_authStateLock` so this refresh cannot interleave with + // -updateWithTokenResponse:authorizationResponse:profileData:. + [self->_authStateLock lock]; if (tokenResponse) { [self.authState updateWithTokenResponse:tokenResponse error:nil]; } else { @@ -316,6 +325,7 @@ - (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion [self.authState updateWithAuthorizationError:error]; } } + [self->_authStateLock unlock]; #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST [GIDEMMSupport handleTokenFetchEMMError:error completion:^(NSError *_Nullable error) { // Process the handler queue to call back. From b30e552c94c38fafcbc9be2c556f2aede1d24751 Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:28:24 -0700 Subject: [PATCH 15/20] g-orchestrated: Guard GIDGoogleUser's profile and encoding with its locks --- GoogleSignIn/Sources/GIDGoogleUser.m | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/GoogleSignIn/Sources/GIDGoogleUser.m b/GoogleSignIn/Sources/GIDGoogleUser.m index 93e41b09..82bfd86c 100644 --- a/GoogleSignIn/Sources/GIDGoogleUser.m +++ b/GoogleSignIn/Sources/GIDGoogleUser.m @@ -118,8 +118,8 @@ @implementation GIDGoogleUser { GIDGoogleUserTokens *_tokens; - // Guards `_tokens` and `_cachedConfiguration`. It is only ever held for a single read or write - // of those ivars, never while calling out to other code. + // Guards `_tokens`, `_cachedConfiguration` 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; // Serializes every change GoogleSignIn makes to `authState`, as well as the token snapshot @@ -131,6 +131,10 @@ @implementation GIDGoogleUser { NSRecursiveLock *_authStateLock; } +// `profile` is readonly and its getter below is hand-written, which turns off autosynthesis, so +// the backing ivar is synthesized explicitly. +@synthesize profile = _profile; + - (nullable GIDGoogleUserTokens *)tokens { os_unfair_lock_lock(&_tokenLock); GIDGoogleUserTokens *tokens = _tokens; @@ -156,6 +160,13 @@ - (nullable GIDToken *)idToken { return self.tokens.idToken; } +- (nullable GIDProfileData *)profile { + os_unfair_lock_lock(&_tokenLock); + GIDProfileData *profile = _profile; + os_unfair_lock_unlock(&_tokenLock); + return profile; +} + // The token properties are derived from `tokens`, so KVO observers of each one are notified // whenever `tokens` is replaced. + (NSSet *)keyPathsForValuesAffectingAccessToken { @@ -429,7 +440,9 @@ - (void)updateWithTokenResponse:(OIDTokenResponse *)tokenResponse authorizationResponse:(OIDAuthorizationResponse *)authorizationResponse profileData:(nullable GIDProfileData *)profileData { [_authStateLock lock]; + os_unfair_lock_lock(&_tokenLock); _profile = profileData; + os_unfair_lock_unlock(&_tokenLock); // We don't want to trigger the delegate before we update authState completely. So we unset the // delegate before the first update. Also the order of updates is important because @@ -540,8 +553,11 @@ - (nullable instancetype)initWithCoder:(NSCoder *)decoder { } - (void)encodeWithCoder:(NSCoder *)encoder { - [encoder encodeObject:_profile forKey:kProfileDataKey]; + // Holds `_authStateLock` so the encoded profile and auth state come from the same update. + [_authStateLock lock]; + [encoder encodeObject:self.profile forKey:kProfileDataKey]; [encoder encodeObject:self.authState forKey:kAuthStateKey]; + [_authStateLock unlock]; } @end From ac657be86a442f0c9c76a43167041490a067c5fd Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:35:17 -0700 Subject: [PATCH 16/20] g-orchestrated: Test: KVO observers can re-enter GIDGoogleUser during an update --- GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m | 76 +++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m b/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m index 95388308..5ab7b6f7 100644 --- a/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m +++ b/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m @@ -61,6 +61,26 @@ - (void)getAccessToken:(GIDToken *_Nullable *_Nullable)accessToken @end +// Observer that runs `onChange` whenever an observed key path changes. +@interface GIDGoogleUserTestKVOObserver : NSObject + +@property(nonatomic, copy) void (^onChange)(void); + +@end + +@implementation GIDGoogleUserTestKVOObserver + +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(NSDictionary *)change + context:(void *)context { + if (self.onChange) { + self.onChange(); + } +} + +@end + static NSString *const kNewAccessToken = @"new_access_token"; static NSString *const kNewRefreshToken = @"new_refresh_token"; @@ -390,6 +410,62 @@ - (void)testTokenProperties_concurrentUpdatesAndReads { XCTAssertNotNil(user.accessToken); } +// 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 { + GIDGoogleUser *user = [self googleUserWithAccessTokenExpiresIn:kAccessTokenExpiresIn + idTokenExpiresIn:kIDTokenExpiresIn]; + + NSString *idTokenA = [self idTokenWithExpiresIn:kNewIDTokenExpiresIn]; + OIDAuthState *authStateA = [OIDAuthState testInstanceWithIDToken:idTokenA + accessToken:@"access_token_A" + accessTokenExpiresIn:kAccessTokenExpiresIn + refreshToken:kNewRefreshToken]; + + NSString *idTokenB = [self idTokenWithExpiresIn:kNewIDTokenExpiresIn + 1]; + OIDAuthState *authStateB = [OIDAuthState testInstanceWithIDToken:idTokenB + accessToken:@"access_token_B" + accessTokenExpiresIn:kAccessTokenExpiresIn + refreshToken:kNewRefreshToken]; + + NSString *accessTokenKeyPath = NSStringFromSelector(@selector(accessToken)); + GIDGoogleUserTestKVOObserver *observer = [[GIDGoogleUserTestKVOObserver alloc] init]; + __block NSInteger notificationCount = 0; + __weak GIDGoogleUser *weakUser = user; + + observer.onChange = ^{ + notificationCount += 1; + // Only the first notification re-enters, so the re-entrant update cannot recurse forever. + if (notificationCount > 1) { + return; + } + GIDGoogleUser *strongUser = weakUser; + + // These reads take the user's auth state lock again on this thread. + (void)strongUser.grantedScopes; + (void)strongUser.profile; + XCTAssertNotNil(strongUser.configuration); + + // A re-entrant token update from inside the notification. + [strongUser updateWithTokenResponse:authStateB.lastTokenResponse + authorizationResponse:authStateB.lastAuthorizationResponse + profileData:nil]; + }; + + [user addObserver:observer forKeyPath:accessTokenKeyPath options:0 context:NULL]; + + [user updateWithTokenResponse:authStateA.lastTokenResponse + authorizationResponse:authStateA.lastAuthorizationResponse + profileData:nil]; + + [user removeObserver:observer forKeyPath:accessTokenKeyPath context:NULL]; + + // Both the outer update and the re-entrant one notify, and the re-entrant one is applied last. + XCTAssertGreaterThanOrEqual(notificationCount, 2); + XCTAssertEqualObjects(user.accessToken.tokenString, @"access_token_B"); +} + - (void)testFetcherAuthorizer { // This is really hard to test without assuming how GTMAppAuthFetcherAuthorization works // internally, so let's just take the shortcut here by asserting we get a From 27947145c14a008e5c94735e4edfdc22de3e702e Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:35:48 -0700 Subject: [PATCH 17/20] g-orchestrated: Assert GIDGoogleUser's lock order and trim its lock comments --- GoogleSignIn/Sources/GIDGoogleUser.m | 56 +++++++++++++++------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/GoogleSignIn/Sources/GIDGoogleUser.m b/GoogleSignIn/Sources/GIDGoogleUser.m index 82bfd86c..bdc5d9e8 100644 --- a/GoogleSignIn/Sources/GIDGoogleUser.m +++ b/GoogleSignIn/Sources/GIDGoogleUser.m @@ -122,12 +122,9 @@ @implementation GIDGoogleUser { // read or write of those ivars, never while calling out to other code. os_unfair_lock _tokenLock; - // Serializes every change GoogleSignIn makes to `authState`, as well as the token snapshot - // updates in -updateTokensWithAuthState:. It is recursive because AppAuth calls - // -didChangeState: synchronously while an update holds it, and because KVO observers of the - // token properties run while it is held and may call back into this user on the same thread. - // The lock order is `_authStateLock`, then `_tokenLock`; `_tokenLock` is never held while - // taking `_authStateLock`. + // Guards `authState` updates and reads that span several of its fields. Recursive because + // AppAuth calls -didChangeState: synchronously while it is held, and token KVO observers may + // re-enter (see -testTokenObserver_reentersUserDuringUpdate). Take it via -lockAuthState. NSRecursiveLock *_authStateLock; } @@ -135,6 +132,16 @@ @implementation GIDGoogleUser { // the backing ivar is synthesized explicitly. @synthesize profile = _profile; +// Lock order: `_authStateLock`, then `_tokenLock`. +- (void)lockAuthState { + os_unfair_lock_assert_not_owner(&_tokenLock); + [_authStateLock lock]; +} + +- (void)unlockAuthState { + [_authStateLock unlock]; +} + - (nullable GIDGoogleUserTokens *)tokens { os_unfair_lock_lock(&_tokenLock); GIDGoogleUserTokens *tokens = _tokens; @@ -211,9 +218,9 @@ - (nullable NSString *)userID { - (nullable NSArray *)grantedScopes { NSArray *grantedScopes; - [_authStateLock lock]; + [self lockAuthState]; NSString *grantedScopeString = self.authState.lastTokenResponse.scope; - [_authStateLock unlock]; + [self unlockAuthState]; if (grantedScopeString) { // If we have a 'scope' parameter from the backend, this is authoritative. // Remove leading and trailing whitespace. @@ -240,14 +247,14 @@ - (GIDConfiguration *)configuration { // Reads the auth state under `_authStateLock` so the configuration is never computed from a // half-updated auth state. - [_authStateLock lock]; + [self lockAuthState]; os_unfair_lock_lock(&_tokenLock); configuration = _cachedConfiguration; os_unfair_lock_unlock(&_tokenLock); if (configuration) { // Another thread filled the cache while we waited for `_authStateLock`. - [_authStateLock unlock]; + [self unlockAuthState]; return configuration; } @@ -266,7 +273,7 @@ - (GIDConfiguration *)configuration { _cachedConfiguration = configuration; os_unfair_lock_unlock(&_tokenLock); - [_authStateLock unlock]; + [self unlockAuthState]; return configuration; } @@ -306,7 +313,7 @@ - (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion NSMutableDictionary *additionalParameters = [@{} mutableCopy]; // Read the auth state under `_authStateLock` so building the request cannot interleave with // -updateWithTokenResponse:authorizationResponse:profileData:. - [_authStateLock lock]; + [self lockAuthState]; #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST [additionalParameters addEntriesFromDictionary: [GIDEMMSupport updatedEMMParametersWithParameters: @@ -320,7 +327,7 @@ - (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion OIDAuthorizationResponse *authorizationResponse = self.authState.lastAuthorizationResponse; OIDTokenRequest *tokenRefreshRequest = [self.authState tokenRefreshRequestWithAdditionalParameters:additionalParameters]; - [_authStateLock unlock]; + [self unlockAuthState]; [OIDAuthorizationService performTokenRequest:tokenRefreshRequest originalAuthorizationResponse:authorizationResponse @@ -328,7 +335,7 @@ - (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion NSError *_Nullable error) { // Update the auth state under `_authStateLock` so this refresh cannot interleave with // -updateWithTokenResponse:authorizationResponse:profileData:. - [self->_authStateLock lock]; + [self lockAuthState]; if (tokenResponse) { [self.authState updateWithTokenResponse:tokenResponse error:nil]; } else { @@ -336,7 +343,7 @@ - (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion [self.authState updateWithAuthorizationError:error]; } } - [self->_authStateLock unlock]; + [self unlockAuthState]; #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST [GIDEMMSupport handleTokenFetchEMMError:error completion:^(NSError *_Nullable error) { // Process the handler queue to call back. @@ -403,10 +410,10 @@ - (void)addScopes:(NSArray *)scopes #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST - (nullable NSString *)emmSupport { - [_authStateLock lock]; + [self lockAuthState]; NSString *emmSupport = self.authState.lastAuthorizationResponse .request.additionalParameters[kEMMSupportParameterName]; - [_authStateLock unlock]; + [self unlockAuthState]; return emmSupport; } #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST @@ -415,8 +422,7 @@ - (instancetype)initWithAuthState:(OIDAuthState *)authState profileData:(nullable GIDProfileData *)profileData { self = [super init]; if (self) { - // Initialize the locks first, -updateTokensWithAuthState: below takes `_authStateLock` and - // `_tokenLock`. + // Initialize the locks before -updateTokensWithAuthState: below uses them. _tokenLock = OS_UNFAIR_LOCK_INIT; _authStateLock = [[NSRecursiveLock alloc] init]; @@ -439,7 +445,7 @@ - (instancetype)initWithAuthState:(OIDAuthState *)authState - (void)updateWithTokenResponse:(OIDTokenResponse *)tokenResponse authorizationResponse:(OIDAuthorizationResponse *)authorizationResponse profileData:(nullable GIDProfileData *)profileData { - [_authStateLock lock]; + [self lockAuthState]; os_unfair_lock_lock(&_tokenLock); _profile = profileData; os_unfair_lock_unlock(&_tokenLock); @@ -452,11 +458,11 @@ - (void)updateWithTokenResponse:(OIDTokenResponse *)tokenResponse [self.authState updateWithAuthorizationResponse:authorizationResponse error:nil]; self.authState.stateChangeDelegate = self; [self.authState updateWithTokenResponse:tokenResponse error:nil]; - [_authStateLock unlock]; + [self unlockAuthState]; } - (void)updateTokensWithAuthState:(OIDAuthState *)authState { - [_authStateLock lock]; + [self lockAuthState]; GIDGoogleUserTokens *current = self.tokens; GIDToken *accessToken = @@ -504,7 +510,7 @@ - (void)updateTokensWithAuthState:(OIDAuthState *)authState { refreshToken:refreshToken idToken:idToken]; } - [_authStateLock unlock]; + [self unlockAuthState]; } #pragma mark - Helpers @@ -554,10 +560,10 @@ - (nullable instancetype)initWithCoder:(NSCoder *)decoder { - (void)encodeWithCoder:(NSCoder *)encoder { // Holds `_authStateLock` so the encoded profile and auth state come from the same update. - [_authStateLock lock]; + [self lockAuthState]; [encoder encodeObject:self.profile forKey:kProfileDataKey]; [encoder encodeObject:self.authState forKey:kAuthStateKey]; - [_authStateLock unlock]; + [self unlockAuthState]; } @end From 6964fab1f8172344b764389d7932d860e2ca0d43 Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:11:49 -0700 Subject: [PATCH 18/20] g-orchestrated: Serve configuration and granted scopes from the token snapshot --- GoogleSignIn/Sources/GIDGoogleUser.m | 152 +++++++++++++-------------- 1 file changed, 73 insertions(+), 79 deletions(-) diff --git a/GoogleSignIn/Sources/GIDGoogleUser.m b/GoogleSignIn/Sources/GIDGoogleUser.m index bdc5d9e8..3c2b7847 100644 --- a/GoogleSignIn/Sources/GIDGoogleUser.m +++ b/GoogleSignIn/Sources/GIDGoogleUser.m @@ -63,17 +63,21 @@ @interface GIDGoogleUser () @end #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST -// An immutable snapshot of a user's tokens. It is replaced as a whole, so readers never see a mix -// of old and new tokens. +// An immutable snapshot of a user's tokens and the values derived from the same auth state. It is +// replaced as a whole, so readers never see a mix of old and new values. @interface GIDGoogleUserTokens : NSObject @property(nonatomic, readonly) GIDToken *accessToken; @property(nonatomic, readonly) GIDToken *refreshToken; @property(nonatomic, readonly, nullable) GIDToken *idToken; +@property(nonatomic, readonly, nullable) NSArray *grantedScopes; +@property(nonatomic, readonly) GIDConfiguration *configuration; - (instancetype)initWithAccessToken:(GIDToken *)accessToken refreshToken:(GIDToken *)refreshToken - idToken:(nullable GIDToken *)idToken NS_DESIGNATED_INITIALIZER; + idToken:(nullable GIDToken *)idToken + grantedScopes:(nullable NSArray *)grantedScopes + configuration:(GIDConfiguration *)configuration NS_DESIGNATED_INITIALIZER; - (instancetype)init NS_UNAVAILABLE; @end @@ -82,12 +86,16 @@ @implementation GIDGoogleUserTokens - (instancetype)initWithAccessToken:(GIDToken *)accessToken refreshToken:(GIDToken *)refreshToken - idToken:(nullable GIDToken *)idToken { + idToken:(nullable GIDToken *)idToken + grantedScopes:(nullable NSArray *)grantedScopes + configuration:(GIDConfiguration *)configuration { self = [super init]; if (self) { _accessToken = accessToken; _refreshToken = refreshToken; _idToken = idToken; + _grantedScopes = [grantedScopes copy]; + _configuration = configuration; } return self; } @@ -109,17 +117,43 @@ - (void)getAccessToken:(GIDToken *_Nullable *_Nullable)accessToken @end +// Parses the `scope` parameter returned by the backend, which is authoritative when present. +static NSArray *_Nullable GIDGrantedScopesFromScopeString( + NSString *_Nullable scopeString) { + if (!scopeString) { + return nil; + } + // Remove leading and trailing whitespace. + scopeString = + [scopeString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + // Tokenize with space as a delimiter. + NSMutableArray *parsedScopes = + [[scopeString componentsSeparatedByString:@" "] mutableCopy]; + // Remove empty strings. + [parsedScopes removeObject:@""]; + return [parsedScopes copy]; +} + +// Returns the hosted domain claim of the given ID token, if it has one. +static NSString *_Nullable GIDHostedDomainFromIDTokenString(NSString *_Nullable idTokenString) { + if (idTokenString) { + OIDIDToken *idTokenDecoded = [[OIDIDToken alloc] initWithIDTokenString:idTokenString]; + if (idTokenDecoded && idTokenDecoded.claims[kHostedDomainIDTokenClaimKey]) { + return idTokenDecoded.claims[kHostedDomainIDTokenClaimKey]; + } + } + return nil; +} + @implementation GIDGoogleUser { - GIDConfiguration *_cachedConfiguration; - // A queue for pending token refresh handlers so we don't fire multiple requests in parallel. // Access to this ivar should be synchronized. NSMutableArray *_tokenRefreshHandlerQueue; GIDGoogleUserTokens *_tokens; - // Guards `_tokens`, `_cachedConfiguration` and `_profile`. It is only ever held for a single - // read or write of those ivars, never while calling out to other code. + // 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; // Guards `authState` updates and reads that span several of its fields. Recursive because @@ -217,65 +251,11 @@ - (nullable NSString *)userID { } - (nullable NSArray *)grantedScopes { - NSArray *grantedScopes; - [self lockAuthState]; - NSString *grantedScopeString = self.authState.lastTokenResponse.scope; - [self unlockAuthState]; - if (grantedScopeString) { - // If we have a 'scope' parameter from the backend, this is authoritative. - // Remove leading and trailing whitespace. - grantedScopeString = [grantedScopeString stringByTrimmingCharactersInSet: - [NSCharacterSet whitespaceCharacterSet]]; - // Tokenize with space as a delimiter. - NSMutableArray *parsedScopes = - [[grantedScopeString componentsSeparatedByString:@" "] mutableCopy]; - // Remove empty strings. - [parsedScopes removeObject:@""]; - grantedScopes = [parsedScopes copy]; - } - return grantedScopes; + return self.tokens.grantedScopes; } - (GIDConfiguration *)configuration { - // Caches the configuration since it would not change for one GIDGoogleUser instance. - os_unfair_lock_lock(&_tokenLock); - GIDConfiguration *configuration = _cachedConfiguration; - os_unfair_lock_unlock(&_tokenLock); - if (configuration) { - return configuration; - } - - // Reads the auth state under `_authStateLock` so the configuration is never computed from a - // half-updated auth state. - [self lockAuthState]; - - os_unfair_lock_lock(&_tokenLock); - configuration = _cachedConfiguration; - os_unfair_lock_unlock(&_tokenLock); - if (configuration) { - // Another thread filled the cache while we waited for `_authStateLock`. - [self unlockAuthState]; - return configuration; - } - - NSString *clientID = self.authState.lastAuthorizationResponse.request.clientID; - NSString *serverClientID = - self.authState.lastTokenResponse.request.additionalParameters[kAudienceParameter]; - NSString *openIDRealm = - self.authState.lastTokenResponse.request.additionalParameters[kOpenIDRealmParameter]; - - configuration = [[GIDConfiguration alloc] initWithClientID:clientID - serverClientID:serverClientID - hostedDomain:[self hostedDomain] - openIDRealm:openIDRealm]; - - os_unfair_lock_lock(&_tokenLock); - _cachedConfiguration = configuration; - os_unfair_lock_unlock(&_tokenLock); - - [self unlockAuthState]; - - return configuration; + return self.tokens.configuration; } - (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion { @@ -492,6 +472,27 @@ - (void)updateTokensWithAuthState:(OIDAuthState *)authState { idToken = nil; } + NSArray *grantedScopes = + GIDGrantedScopesFromScopeString(authState.lastTokenResponse.scope); + + GIDConfiguration *configuration; + if (current.configuration) { + // The configuration is fixed for the lifetime of a user, so it is computed once. + configuration = current.configuration; + } else { + NSString *clientID = authState.lastAuthorizationResponse.request.clientID; + NSString *serverClientID = + authState.lastTokenResponse.request.additionalParameters[kAudienceParameter]; + NSString *openIDRealm = + authState.lastTokenResponse.request.additionalParameters[kOpenIDRealmParameter]; + + configuration = [[GIDConfiguration alloc] + initWithClientID:clientID + serverClientID:serverClientID + hostedDomain:GIDHostedDomainFromIDTokenString(idToken.tokenString) + openIDRealm:openIDRealm]; + } + // Keep the existing token objects when they are unchanged, so an update that changes nothing // leaves `tokens` untouched and sends no KVO notifications. if ([current.accessToken isEqualToToken:accessToken]) { @@ -504,28 +505,21 @@ - (void)updateTokensWithAuthState:(OIDAuthState *)authState { idToken = current.idToken; } + BOOL grantedScopesChanged = !((!grantedScopes && !current.grantedScopes) || + [grantedScopes isEqualToArray:current.grantedScopes]); + if (!current || accessToken != current.accessToken || - refreshToken != current.refreshToken || idToken != current.idToken) { + refreshToken != current.refreshToken || idToken != current.idToken || + grantedScopesChanged) { self.tokens = [[GIDGoogleUserTokens alloc] initWithAccessToken:accessToken refreshToken:refreshToken - idToken:idToken]; + idToken:idToken + grantedScopes:grantedScopes + configuration:configuration]; } [self unlockAuthState]; } -#pragma mark - Helpers - -- (nullable NSString *)hostedDomain { - NSString *idTokenString = self.idToken.tokenString; - if (idTokenString) { - OIDIDToken *idTokenDecoded = [[OIDIDToken alloc] initWithIDTokenString:idTokenString]; - if (idTokenDecoded && idTokenDecoded.claims[kHostedDomainIDTokenClaimKey]) { - return idTokenDecoded.claims[kHostedDomainIDTokenClaimKey]; - } - } - return nil; -} - #pragma mark - OIDAuthStateChangeDelegate - (void)didChangeState:(OIDAuthState *)state { From aac6854dc6a3270201f98a347cf6b91dd409a4c0 Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:12:28 -0700 Subject: [PATCH 19/20] g-orchestrated: Note that direct authState reads bypass GIDGoogleUser's lock --- GoogleSignIn/Sources/GIDGoogleUser_Private.h | 3 +++ 1 file changed, 3 insertions(+) diff --git a/GoogleSignIn/Sources/GIDGoogleUser_Private.h b/GoogleSignIn/Sources/GIDGoogleUser_Private.h index 2331595a..b08a2069 100644 --- a/GoogleSignIn/Sources/GIDGoogleUser_Private.h +++ b/GoogleSignIn/Sources/GIDGoogleUser_Private.h @@ -33,6 +33,9 @@ typedef void (^GIDGoogleUserCompletion)(GIDGoogleUser *_Nullable user, NSError * @interface GIDGoogleUser () /// 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 +// state updates, and apps can reach the same object through `fetcherAuthorizer`. Fixing this +// may need a public API change. @property(nonatomic, readonly) OIDAuthState *authState; #pragma clang diagnostic push From 7c6023c7158255672c18c43ad8333ce41da10f2f Mon Sep 17 00:00:00 2001 From: Worthing ~ <115107835+w-goog@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:15:11 -0700 Subject: [PATCH 20/20] g-orchestrated: Test: stub the token response scope read when restoring a user --- GoogleSignIn/Tests/Unit/GIDSignInTest.m | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/GoogleSignIn/Tests/Unit/GIDSignInTest.m b/GoogleSignIn/Tests/Unit/GIDSignInTest.m index ed959f12..574ea36c 100644 --- a/GoogleSignIn/Tests/Unit/GIDSignInTest.m +++ b/GoogleSignIn/Tests/Unit/GIDSignInTest.m @@ -532,7 +532,8 @@ - (void)testRestorePreviousSignInNoRefresh_hasPreviousUser { OCMStub([idTokenDecoded initWithIDTokenString:OCMOCK_ANY]).andReturn(idTokenDecoded); OCMStub([idTokenDecoded subject]).andReturn(kFakeGaiaID); - // Mock generating a GIDConfiguration when initializing GIDGoogleUser. + // Mock generating the token snapshot (tokens, granted scopes and GIDConfiguration) when + // initializing GIDGoogleUser. OIDAuthorizationResponse *authResponse = [OIDAuthorizationResponse testInstance]; @@ -542,6 +543,7 @@ - (void)testRestorePreviousSignInNoRefresh_hasPreviousUser { OCMStub([_tokenRequest additionalParameters]).andReturn(nil); OCMStub([_tokenResponse accessToken]).andReturn(kAccessToken); OCMStub([_tokenResponse accessTokenExpirationDate]).andReturn(nil); + OCMStub([_tokenResponse scope]).andReturn(nil); [_signIn restorePreviousSignInNoRefresh];