Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/builds.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,15 @@ jobs:
xcodebuild \
-scheme GoogleSignIn-Package \
-sdk iphonesimulator \
-destination "platform=iOS Simulator,name=iPhone 16" \
-destination "platform=iOS Simulator,name=iPhone 16,OS=18.6" \
-onlyUsePackageVersionsFromResolvedFile \
build-for-testing
- name: Run unit test target
run: |
xcodebuild \
-scheme GoogleSignIn-Package \
-sdk iphonesimulator \
-destination "platform=iOS Simulator,name=iPhone 16,OS=18.5" \
-destination "platform=iOS Simulator,name=iPhone 16,OS=18.6" \
-onlyUsePackageVersionsFromResolvedFile \
test-without-building

Expand Down
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
- Add Swift Package Manager support for AppCheckCore 12.x, enabling compatibility with Firebase 13. CocoaPods installations remain restricted to AppCheckCore 11.x. ([#626](https://github.com/google/GoogleSignIn-iOS/pull/626))
- Fix a failed App Check preparation being recorded as successful, which made later calls to `-[GIDSignIn configureWithCompletion:]` report success without retrying. ([#626](https://github.com/google/GoogleSignIn-iOS/pull/626))
- With AppCheckCore 11.x (including all CocoaPods installations), a custom App Check provider that returns neither a token nor an error is now treated as success instead of failing with `kGIDAppCheckUnexpectedError`. ([#626](https://github.com/google/GoogleSignIn-iOS/pull/626))
- Fix a data race on `GIDGoogleUser`'s tokens. Concurrent token updates could write the access, refresh and ID tokens from different threads at once, and readers could observe a partially updated set. The three tokens are now published together as one snapshot.
- `GIDGoogleUser`'s `accessToken`, `refreshToken` and `idToken` are now all derived from that snapshot, so key-value observers of any one of them are notified whenever any of them changes, not only when that property's own value changes.
- Fix a data race on `GIDGoogleUser`'s tokens. Concurrent token updates could write the access, refresh and ID tokens from different threads at once, and readers could observe a partially updated set. The tokens, granted scopes, configuration and profile are now published together as one snapshot.
- `GIDGoogleUser`'s `accessToken`, `refreshToken`, `idToken` and `profile` are now all derived from that snapshot, so key-value observers of any one of them are notified whenever any of them changes, not only when that property's own value changes. `profile` now sends key-value notifications; previously it sent none.
- Key-value observers of those properties are notified while `GIDGoogleUser` holds its internal lock. Calling back into the same user from the observer is fine, but an observer must not synchronously wait (for example with `dispatch_sync`) on another thread that is updating the same user.
- `GIDGoogleUser`'s `configuration` is now computed once, when the user is created, and stays fixed for the user's lifetime. Previously it was computed on first access, so a first read after a token update could reflect that update's client ID, server client ID or hosted domain.
- Fix EMM remediation dialogs being suppressed for the rest of the app's lifetime after a single error arrived while no key window was available. The pending-dialog flag is now cleared on every exit path, and its clearing write is synchronized to match the read.
- Fix a token request that failed with an EMM error sometimes being reported with the underlying OAuth error instead of `kGIDSignInErrorCodeEMM` when no remediation dialog could be presented.
- Fix an EMM error that arrived while a remediation dialog was already showing being reported as `kGIDSignInErrorCodeUnknown` (or as the underlying OAuth error, for a token request) instead of `kGIDSignInErrorCodeEMM`. No second dialog is shown.
Expand Down
171 changes: 113 additions & 58 deletions GoogleSignIn/Sources/GIDGoogleUser.m
Original file line number Diff line number Diff line change
Expand Up @@ -65,24 +65,68 @@ @implementation GIDGoogleUserTokens

- (instancetype)initWithAccessToken:(GIDToken *)accessToken
refreshToken:(GIDToken *)refreshToken
idToken:(nullable GIDToken *)idToken {
idToken:(nullable GIDToken *)idToken
grantedScopes:(nullable NSArray<NSString *> *)grantedScopes
configuration:(GIDConfiguration *)configuration
profile:(nullable GIDProfileData *)profile {
self = [super init];
if (self) {
_accessToken = accessToken;
_refreshToken = refreshToken;
_idToken = idToken;
_grantedScopes = [grantedScopes copy];
_configuration = configuration;
_profile = profile;
}
return self;
}

@end

// Parses the `scope` parameter of a token response, which is authoritative when present
// (https://datatracker.ietf.org/doc/html/rfc6749#section-5.1). Scopes are space-delimited
// (https://datatracker.ietf.org/doc/html/rfc6749#section-3.3); surrounding whitespace and runs of
// spaces are tolerated. Returns nil when the response carries no `scope` parameter.
static NSArray<NSString *> *_Nullable GIDGrantedScopesFromScopeString(
Comment thread
w-goog marked this conversation as resolved.
NSString *_Nullable scopeString) {
if (!scopeString) {
return nil;
}
// Remove leading and trailing whitespace.
scopeString =
[scopeString stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
// Tokenize with space as a delimiter.
NSMutableArray<NSString *> *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) {
return nil;
}
OIDIDToken *idTokenDecoded = [[OIDIDToken alloc] initWithIDTokenString:idTokenString];
if (!idTokenDecoded) {
return nil;
}
return idTokenDecoded.claims[kHostedDomainIDTokenClaimKey];
}

@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<GIDGoogleUserCompletion> *_tokenRefreshHandlerQueue;

// The profile is not part of the auth state, so a profile supplied to
// -initWithAuthState:profileData: or -updateWithTokenResponse:authorizationResponse:profileData:
// is parked here and folded into the next snapshot that -updateTokensWithAuthState: builds.
// `_hasPendingProfile` distinguishes a supplied nil profile, which replaces the current one,
// from no profile having been supplied, which keeps the current one.
GIDProfileData *_pendingProfile;
BOOL _hasPendingProfile;
}

- (GIDToken *)accessToken {
Expand All @@ -97,8 +141,13 @@ - (nullable GIDToken *)idToken {
return self.tokens.idToken;
}

// The three token properties are derived from `tokens`, so KVO observers of each one are notified
// whenever `tokens` is replaced (a change to any of them notifies observers of all three).
- (nullable GIDProfileData *)profile {
return self.tokens.profile;
}

// The token and profile properties are derived from `tokens`, so KVO observers of each one are
// notified whenever `tokens` is replaced (a change to any of them notifies observers of all of
// them).
+ (NSSet<NSString *> *)keyPathsForValuesAffectingAccessToken {
return [NSSet setWithObject:NSStringFromSelector(@selector(tokens))];
}
Expand All @@ -111,6 +160,10 @@ - (nullable GIDToken *)idToken {
return [NSSet setWithObject:NSStringFromSelector(@selector(tokens))];
}

+ (NSSet<NSString *> *)keyPathsForValuesAffectingProfile {
return [NSSet setWithObject:NSStringFromSelector(@selector(tokens))];
}

- (nullable NSString *)userID {
NSString *idTokenString = self.idToken.tokenString;
if (idTokenString) {
Expand All @@ -123,40 +176,11 @@ - (nullable NSString *)userID {
}

- (nullable NSArray<NSString *> *)grantedScopes {
NSArray<NSString *> *grantedScopes;
NSString *grantedScopeString = self.authState.lastTokenResponse.scope;
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<NSString *> *parsedScopes =
[[grantedScopeString componentsSeparatedByString:@" "] mutableCopy];
// Remove empty strings.
[parsedScopes removeObject:@""];
grantedScopes = [parsedScopes copy];
}
return grantedScopes;
return self.tokens.grantedScopes;
}

- (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];
};
}
return _cachedConfiguration;
return self.tokens.configuration;
}

- (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion {
Expand Down Expand Up @@ -291,7 +315,10 @@ - (instancetype)initWithAuthState:(OIDAuthState *)authState
self = [super init];
if (self) {
_tokenRefreshHandlerQueue = [[NSMutableArray alloc] init];
_profile = profileData;

// Park the profile for the first snapshot, built by -updateTokensWithAuthState: below.
_pendingProfile = profileData;
_hasPendingProfile = YES;
Comment thread
w-goog marked this conversation as resolved.

GTMAuthSession *authSession = [[GTMAuthSession alloc] initWithAuthState:authState];
#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST
Expand All @@ -310,8 +337,11 @@ - (void)updateWithTokenResponse:(OIDTokenResponse *)tokenResponse
authorizationResponse:(OIDAuthorizationResponse *)authorizationResponse
profileData:(nullable GIDProfileData *)profileData {
@synchronized(self) {
_profile = profileData;

// Park the profile for the snapshot built when the token response update below calls back
// through -didChangeState:.
_pendingProfile = profileData;
_hasPendingProfile = YES;

// 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.
Expand All @@ -320,15 +350,20 @@ - (void)updateWithTokenResponse:(OIDTokenResponse *)tokenResponse
[self.authState updateWithAuthorizationResponse:authorizationResponse error:nil];
self.authState.stateChangeDelegate = self;
[self.authState updateWithTokenResponse:tokenResponse error:nil];
// AppAuth does not call -didChangeState: for a nil token response, so if the profile was not
// folded into a snapshot above, build one here.
if (_hasPendingProfile) {
[self updateTokensWithAuthState:self.authState];
}
}
}

// KVO observers of the token properties run while `@synchronized(self)` is held; same-thread
// re-entry is fine because it is recursive, but an observer must not synchronously wait on another
// thread that is updating the same user.
- (void)updateTokensWithAuthState:(OIDAuthState *)authState {
@synchronized(self) {
GIDGoogleUserTokens *current = self.tokens;
GIDProfileData *profile = _hasPendingProfile ? _pendingProfile : current.profile;
_pendingProfile = nil;
_hasPendingProfile = NO;

// Build the access token
GIDToken *accessToken =
Expand Down Expand Up @@ -360,6 +395,27 @@ - (void)updateTokensWithAuthState:(OIDAuthState *)authState {
idToken = nil;
}

NSArray<NSString *> *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];
}

// If the computed values are equal to the existing ones, keep the existing ones. In that case,
// an update leaves `tokens` untouched and sends no KVO notifications.
if ([current.accessToken isEqualToToken:accessToken]) {
Expand All @@ -371,29 +427,28 @@ - (void)updateTokensWithAuthState:(OIDAuthState *)authState {
if ([current.idToken isEqualToToken:idToken]) {
idToken = current.idToken;
}
// Both nil must be checked explicitly: isEqualToArray: returns NO when either side is nil.
if ((!grantedScopes && !current.grantedScopes) ||
[grantedScopes isEqualToArray:current.grantedScopes]) {
Comment thread
w-goog marked this conversation as resolved.
grantedScopes = current.grantedScopes;
}

if (!current ||
accessToken != current.accessToken ||
refreshToken != current.refreshToken ||
idToken != current.idToken) {
idToken != current.idToken ||
grantedScopes != current.grantedScopes ||
profile != current.profile) {
// Replacing `tokens` notifies KVO observers of the derived properties while the lock is
// held; see the key-value observing note on GIDGoogleUser in GIDGoogleUser.h.
self.tokens = [[GIDGoogleUserTokens alloc] initWithAccessToken:accessToken
refreshToken:refreshToken
idToken:idToken];
}
}
}

#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];
idToken:idToken
grantedScopes:grantedScopes
configuration:configuration
profile:profile];
}
}
return nil;
}

#pragma mark - OIDAuthStateChangeDelegate
Expand Down Expand Up @@ -425,7 +480,7 @@ - (nullable instancetype)initWithCoder:(NSCoder *)decoder {
}

- (void)encodeWithCoder:(NSCoder *)encoder {
[encoder encodeObject:_profile forKey:kProfileDataKey];
[encoder encodeObject:self.profile forKey:kProfileDataKey];
[encoder encodeObject:self.authState forKey:kAuthStateKey];
}

Expand Down
21 changes: 15 additions & 6 deletions GoogleSignIn/Sources/GIDGoogleUser_Private.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
#import <AppAuth/AppAuth.h>
#endif

@class GIDConfiguration;
@class GIDProfileData;
@class GIDToken;
@class OIDAuthState;

Expand All @@ -30,17 +32,24 @@ NS_ASSUME_NONNULL_BEGIN
/// A completion block that takes a `GIDGoogleUser` or an error if the attempt to refresh tokens was unsuccessful.
typedef void (^GIDGoogleUserCompletion)(GIDGoogleUser *_Nullable user, NSError *_Nullable error);

/// An immutable snapshot of a user's access, refresh and ID tokens.
/// This value is replaced as a whole so that readers never see a mix of old and new tokens.
/// An immutable snapshot of a user's tokens, profile, and the values derived from the same
/// auth state.
/// This value is replaced as a whole so that 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<NSString *> *grantedScopes;
@property(nonatomic, readonly) GIDConfiguration *configuration;
@property(nonatomic, readonly, nullable) GIDProfileData *profile;

- (instancetype)initWithAccessToken:(GIDToken *)accessToken
refreshToken:(GIDToken *)refreshToken
idToken:(nullable GIDToken *)idToken NS_DESIGNATED_INITIALIZER;
idToken:(nullable GIDToken *)idToken
grantedScopes:(nullable NSArray<NSString *> *)grantedScopes
configuration:(GIDConfiguration *)configuration
profile:(nullable GIDProfileData *)profile NS_DESIGNATED_INITIALIZER;
- (instancetype)init NS_UNAVAILABLE;

@end
Expand All @@ -51,9 +60,9 @@ typedef void (^GIDGoogleUserCompletion)(GIDGoogleUser *_Nullable user, NSError *
/// A representation of the state of the OAuth session for this instance.
@property(nonatomic, readonly) OIDAuthState *authState;

/// The user's current tokens. Read once - accessing individual properties in sequence is not
/// recommended. Reading once ensures that each property is from the same update. Writes are
/// serialized by `@synchronized(self)`.
/// The user's current tokens, granted scopes, configuration and profile. Read once - accessing
/// individual properties in sequence is not recommended. Reading once ensures that each property is
/// from the same update. Writes are serialized by `@synchronized(self)`.
@property(atomic, strong, nullable) GIDGoogleUserTokens *tokens;

#pragma clang diagnostic push
Expand Down
6 changes: 6 additions & 0 deletions GoogleSignIn/Sources/Public/GoogleSignIn/GIDGoogleUser.h
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@
NS_ASSUME_NONNULL_BEGIN

/// This class represents a signed-in user.
///
/// The `accessToken`, `refreshToken`, `idToken` and `profile` properties are published together,
/// so key-value observers of any one of them are notified whenever any of them changes. Observers
/// are notified while the user holds an internal lock: an observer may call back into the same
/// user on the notifying thread, but must not synchronously wait (for example with
/// `dispatch_sync`) on another thread that is updating the same user.
@interface GIDGoogleUser : NSObject <NSSecureCoding>

/// The Google user ID.
Expand Down
Loading
Loading