diff --git a/GoogleSignIn/Sources/GIDGoogleUser.m b/GoogleSignIn/Sources/GIDGoogleUser.m index 0c18a49e..ca35edfa 100644 --- a/GoogleSignIn/Sources/GIDGoogleUser.m +++ b/GoogleSignIn/Sources/GIDGoogleUser.m @@ -17,11 +17,13 @@ #import "GoogleSignIn/Sources/GIDGoogleUser_Private.h" #import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDConfiguration.h" +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h" #import "GoogleSignIn/Sources/GIDAppAuthFetcherAuthorizationWithEMMSupport.h" #import "GoogleSignIn/Sources/GIDAuthentication.h" #import "GoogleSignIn/Sources/GIDEMMSupport.h" #import "GoogleSignIn/Sources/GIDProfileData_Private.h" +#import "GoogleSignIn/Sources/GIDSignIn_Private.h" #import "GoogleSignIn/Sources/GIDSignInPreferences.h" #import "GoogleSignIn/Sources/GIDToken_Private.h" @@ -181,6 +183,35 @@ - (OIDAuthState *) authState{ return ((GTMAppAuthFetcherAuthorization *)self.fetcherAuthorizer).authState; } +- (void)addScopes:(NSArray *)scopes +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + presentingViewController:(UIViewController *)presentingViewController +#elif TARGET_OS_OSX || TARGET_OS_MACCATALYST + presentingWindow:(NSWindow *)presentingWindow +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST + completion:(nullable void (^)(GIDUserAuth *_Nullable userAuth, + NSError *_Nullable error))completion { + if (self != GIDSignIn.sharedInstance.currentUser) { + NSError *error = [NSError errorWithDomain:kGIDSignInErrorDomain + code:kGIDSignInErrorCodeMismatchWithCurrentUser + userInfo:nil]; + if (completion) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(nil, error); + }); + } + return; + } + + [GIDSignIn.sharedInstance addScopes:scopes +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + presentingViewController:presentingViewController +#elif TARGET_OS_OSX || TARGET_OS_MACCATALYST + presentingWindow:presentingWindow +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST + completion:completion]; +} + #pragma mark - Private Methods #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDSignIn.m b/GoogleSignIn/Sources/GIDSignIn.m index cde8e205..56490155 100644 --- a/GoogleSignIn/Sources/GIDSignIn.m +++ b/GoogleSignIn/Sources/GIDSignIn.m @@ -265,20 +265,6 @@ - (void)signInWithPresentingViewController:(UIViewController *)presentingViewCon - (void)addScopes:(NSArray *)scopes presentingViewController:(UIViewController *)presentingViewController completion:(nullable GIDUserAuthCompletion)completion { - // A currentUser must be available in order to complete this flow. - if (!self.currentUser) { - // No currentUser is set, notify callback of failure. - NSError *error = [NSError errorWithDomain:kGIDSignInErrorDomain - code:kGIDSignInErrorCodeNoCurrentUser - userInfo:nil]; - if (completion) { - dispatch_async(dispatch_get_main_queue(), ^{ - completion(nil, error); - }); - } - return; - } - GIDConfiguration *configuration = self.currentUser.configuration; GIDSignInInternalOptions *options = [GIDSignInInternalOptions defaultOptionsWithConfiguration:configuration @@ -350,20 +336,6 @@ - (void)signInWithPresentingWindow:(NSWindow *)presentingWindow - (void)addScopes:(NSArray *)scopes presentingWindow:(NSWindow *)presentingWindow completion:(nullable GIDUserAuthCompletion)completion { - // A currentUser must be available in order to complete this flow. - if (!self.currentUser) { - // No currentUser is set, notify callback of failure. - NSError *error = [NSError errorWithDomain:kGIDSignInErrorDomain - code:kGIDSignInErrorCodeNoCurrentUser - userInfo:nil]; - if (completion) { - dispatch_async(dispatch_get_main_queue(), ^{ - completion(nil, error); - }); - } - return; - } - GIDConfiguration *configuration = self.currentUser.configuration; GIDSignInInternalOptions *options = [GIDSignInInternalOptions defaultOptionsWithConfiguration:configuration diff --git a/GoogleSignIn/Sources/GIDSignIn_Private.h b/GoogleSignIn/Sources/GIDSignIn_Private.h index 190a629c..39df848f 100644 --- a/GoogleSignIn/Sources/GIDSignIn_Private.h +++ b/GoogleSignIn/Sources/GIDSignIn_Private.h @@ -14,36 +14,83 @@ * limitations under the License. */ +#import + #import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h" +#if __has_include() +#import +#elif __has_include() +#import +#endif + NS_ASSUME_NONNULL_BEGIN @class GIDGoogleUser; @class GIDSignInInternalOptions; -// Represents a completion block that takes a `GIDUserAuth` on success or an error if the operation -// was unsuccessful. +/// Represents a completion block that takes a `GIDUserAuth` on success or an error if the operation +/// was unsuccessful. typedef void (^GIDUserAuthCompletion)(GIDUserAuth *_Nullable userAuth, NSError *_Nullable error); // Private |GIDSignIn| methods that are used internally in this SDK and other Google SDKs. @interface GIDSignIn () -// Redeclare |currentUser| as readwrite for internal use. +/// Redeclare |currentUser| as readwrite for internal use. @property(nonatomic, readwrite, nullable) GIDGoogleUser *currentUser; -// Private initializer for |GIDSignIn|. +/// Private initializer for |GIDSignIn|. - (instancetype)initPrivate; -// Authenticates with extra options. +/// Authenticates with extra options. - (void)signInWithOptions:(GIDSignInInternalOptions *)options; -// Restores a previously authenticated user from the keychain synchronously without refreshing -// the access token or making a userinfo request. The currentUser.profile will be nil unless -// the profile data can be extracted from the ID token. -// -// @return NO if there is no user restored from the keychain. +/// Restores a previously authenticated user from the keychain synchronously without refreshing +/// the access token or making a userinfo request. +/// +/// The currentUser.profile will be nil unless the profile data can be extracted from the ID token. +/// +/// @return NO if there is no user restored from the keychain. - (BOOL)restorePreviousSignInNoRefresh; +#if TARGET_OS_IOS || TARGET_OS_MACCATALYST + +/// Starts an interactive consent flow on iOS to add scopes to the current user's grants. +/// +/// The completion will be called at the end of this process. If successful, a `GIDUserAuth` +/// instance will be returned reflecting the new scopes and saved sign-in state will be updated. +/// +/// @param scopes The scopes to ask the user to consent to. +/// @param presentingViewController The view controller used to present `SFSafariViewContoller` on +/// iOS 9 and 10 and to supply `presentationContextProvider` for `ASWebAuthenticationSession` on +/// iOS 13+. +/// @param completion The block that is called on completion. This block will be called asynchronously +/// on the main queue. +- (void)addScopes:(NSArray *)scopes + presentingViewController:(UIViewController *)presentingViewController + completion:(nullable void (^)(GIDUserAuth *_Nullable userAuth, + NSError *_Nullable error))completion + NS_EXTENSION_UNAVAILABLE("The add scopes flow is not supported in App Extensions."); + +#elif TARGET_OS_OSX + +/// Starts an interactive consent flow on macOS to add scopes to the current user's grants. +/// +/// The completion will be called at the end of this process. If successful, a `GIDUserAuth` +/// instance will be returned reflecting the new scopes and saved sign-in state will be updated. +/// +/// @param scopes An array of scopes to ask the user to consent to. +/// @param presentingWindow The window used to supply `presentationContextProvider` for +/// `ASWebAuthenticationSession`. +/// @param completion The block that is called on completion. This block will be called asynchronously +/// on the main queue. +- (void)addScopes:(NSArray *)scopes + presentingWindow:(NSWindow *)presentingWindow + completion:(nullable void (^)(GIDUserAuth *_Nullable userAuth, + NSError *_Nullable error))completion; + +#endif + @end NS_ASSUME_NONNULL_END diff --git a/GoogleSignIn/Sources/Public/GoogleSignIn/GIDGoogleUser.h b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDGoogleUser.h index 558d9a9f..ee080a27 100644 --- a/GoogleSignIn/Sources/Public/GoogleSignIn/GIDGoogleUser.h +++ b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDGoogleUser.h @@ -15,6 +15,13 @@ */ #import +#import + +#if __has_include() +#import +#elif __has_include() +#import +#endif // We have to import GTMAppAuth because forward declaring the protocol does // not generate the `fetcherAuthorizer` property below for Swift. @@ -25,6 +32,7 @@ #endif @class GIDConfiguration; +@class GIDUserAuth; @class GIDToken; @class GIDProfileData; @@ -71,6 +79,43 @@ NS_ASSUME_NONNULL_BEGIN - (void)doWithFreshTokens:(void (^)(GIDGoogleUser *_Nullable user, NSError *_Nullable error))completion; +#if TARGET_OS_IOS || TARGET_OS_MACCATALYST + +/// Starts an interactive consent flow on iOS to add scopes to the user's grants. +/// +/// The completion will be called at the end of this process. If successful, a `GIDUserAuth` +/// instance will be returned reflecting the new scopes and saved sign-in state will be updated. +/// +/// @param scopes The scopes to ask the user to consent to. +/// @param presentingViewController The view controller used to present `SFSafariViewContoller` on +/// iOS 9 and 10 and to supply `presentationContextProvider` for `ASWebAuthenticationSession` on +/// iOS 13+. +/// @param completion The block that is called on completion. This block will be called asynchronously +/// on the main queue. +- (void)addScopes:(NSArray *)scopes + presentingViewController:(UIViewController *)presentingViewController + completion:(nullable void (^)(GIDUserAuth *_Nullable userAuth, + NSError *_Nullable error))completion + NS_EXTENSION_UNAVAILABLE("The add scopes flow is not supported in App Extensions."); + +#elif TARGET_OS_OSX + +/// Starts an interactive consent flow on macOS to add scopes to the user's grants. +/// +/// The completion will be called at the end of this process. If successful, a `GIDUserAuth` +/// instance will be returned reflecting the new scopes and saved sign-in state will be updated. +/// +/// @param scopes An array of scopes to ask the user to consent to. +/// @param presentingWindow The window used to supply `presentationContextProvider` for `ASWebAuthenticationSession`. +/// @param completion The block that is called on completion. This block will be called asynchronously +/// on the main queue. +- (void)addScopes:(NSArray *)scopes + presentingWindow:(NSWindow *)presentingWindow + completion:(nullable void (^)(GIDUserAuth *_Nullable userAuth, + NSError *_Nullable error))completion; + +#endif + @end NS_ASSUME_NONNULL_END diff --git a/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h index 8fad0549..5abf4f6f 100644 --- a/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h +++ b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h @@ -45,10 +45,10 @@ typedef NS_ERROR_ENUM(kGIDSignInErrorDomain, GIDSignInErrorCode) { kGIDSignInErrorCodeCanceled = -5, /// Indicates an Enterprise Mobility Management related error has occurred. kGIDSignInErrorCodeEMM = -6, - /// Indicates there is no `currentUser`. - kGIDSignInErrorCodeNoCurrentUser = -7, /// Indicates the requested scopes have already been granted to the `currentUser`. kGIDSignInErrorCodeScopesAlreadyGranted = -8, + /// Indicates there is an operation on a previous user. + kGIDSignInErrorCodeMismatchWithCurrentUser = -9, }; /// Represents a completion block that takes an error if the operation was unsuccessful. @@ -165,23 +165,6 @@ typedef void (^GIDDisconnectCompletion)(NSError *_Nullable error); completion:(nullable void (^)(GIDUserAuth *_Nullable userAuth, NSError *_Nullable error))completion; -/// Starts an interactive consent flow on iOS to add scopes to the current user's grants. -/// -/// The completion will be called at the end of this process. If successful, a new `GIDGoogleUser` -/// instance will be returned reflecting the new scopes and saved sign-in state will be updated. -/// -/// @param scopes The scopes to ask the user to consent to. -/// @param presentingViewController The view controller used to present `SFSafariViewContoller` on -/// iOS 9 and 10 and to supply `presentationContextProvider` for `ASWebAuthenticationSession` on -/// iOS 13+. -/// @param completion The block that is called on completion. This block will be called asynchronously -/// on the main queue. -- (void)addScopes:(NSArray *)scopes - presentingViewController:(UIViewController *)presentingViewController - completion:(nullable void (^)(GIDUserAuth *_Nullable userAuth, - NSError *_Nullable error))completion - NS_EXTENSION_UNAVAILABLE("The add scopes flow is not supported in App Extensions."); - #elif TARGET_OS_OSX /// Starts an interactive sign-in flow on macOS. /// @@ -233,20 +216,6 @@ typedef void (^GIDDisconnectCompletion)(NSError *_Nullable error); completion:(nullable void (^)(GIDUserAuth *_Nullable userAuth, NSError *_Nullable error))completion; -/// Starts an interactive consent flow on macOS to add scopes to the current user's grants. -/// -/// The completion will be called at the end of this process. If successful, a new `GIDGoogleUser` -/// instance will be returned reflecting the new scopes and saved sign-in state will be updated. -/// -/// @param scopes An array of scopes to ask the user to consent to. -/// @param presentingWindow The window used to supply `presentationContextProvider` for `ASWebAuthenticationSession`. -/// @param completion The block that is called on completion. This block will be called asynchronously -/// on the main queue. -- (void)addScopes:(NSArray *)scopes - presentingWindow:(NSWindow *)presentingWindow - completion:(nullable void (^)(GIDUserAuth *_Nullable userAuth, - NSError *_Nullable error))completion; - #endif @end diff --git a/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m b/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m index 488d3f63..5261872a 100644 --- a/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m +++ b/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m @@ -15,9 +15,11 @@ #import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDGoogleUser.h" #import +#import #import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDConfiguration.h" #import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDProfileData.h" +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h" #import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDToken.h" #import "GoogleSignIn/Sources/GIDGoogleUser_Private.h" @@ -33,6 +35,7 @@ @import GoogleUtilities_MethodSwizzler; @import GoogleUtilities_SwizzlerTestHelpers; @import GTMAppAuth; +@import OCMock; #else #import #import @@ -45,6 +48,7 @@ #import #import #import +#import #endif static NSString *const kNewAccessToken = @"new_access_token"; @@ -54,6 +58,8 @@ static NSTimeInterval const kIDTokenExpiresIn = 100; static NSTimeInterval const kNewIDTokenExpiresIn = 200; +static NSString *const kNewScope = @"newScope"; + @interface GIDGoogleUserTest : XCTestCase @end @@ -437,6 +443,91 @@ - (void)testDoWithFreshTokens_handleConcurrentRefresh { [self waitForExpectationsWithTimeout:1 handler:nil]; } +- (void)testAddScopes_success { + id signIn = OCMClassMock([GIDSignIn class]); + OCMStub([signIn sharedInstance]).andReturn(signIn); + [[signIn expect] addScopes:OCMOCK_ANY +#if TARGET_OS_IOS || TARGET_OS_MACCATALYST + presentingViewController:OCMOCK_ANY +#elif TARGET_OS_OSX + presentingWindow:OCMOCK_ANY +#endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST + completion:OCMOCK_ANY]; + + GIDGoogleUser *currentUser = [self googleUserWithAccessTokenExpiresIn:kAccessTokenExpiresIn + idTokenExpiresIn:kIDTokenExpiresIn]; + + OCMStub([signIn currentUser]).andReturn(currentUser); + + [currentUser addScopes:@[kNewScope] +#if TARGET_OS_IOS || TARGET_OS_MACCATALYST + presentingViewController:[[UIViewController alloc] init] +#elif TARGET_OS_OSX + presentingWindow:[[NSWindow alloc] init] +#endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST + completion:nil]; + + [signIn verify]; +} + +- (void)testAddScopes_failure_addScopesToPreviousUser { + id signIn = OCMClassMock([GIDSignIn class]); + OCMStub([signIn sharedInstance]).andReturn(signIn); + + GIDGoogleUser *currentUser = [self googleUserWithAccessTokenExpiresIn:kAccessTokenExpiresIn + idTokenExpiresIn:kIDTokenExpiresIn]; + + OCMStub([signIn currentUser]).andReturn(currentUser); + + GIDGoogleUser *previousUser = [self googleUserWithAccessTokenExpiresIn:kAccessTokenExpiresIn + idTokenExpiresIn:kNewIDTokenExpiresIn]; + + XCTestExpectation *expectation = + [self expectationWithDescription:@"Completion is called."]; + + [previousUser addScopes:@[kNewScope] +#if TARGET_OS_IOS || TARGET_OS_MACCATALYST + presentingViewController:[[UIViewController alloc] init] +#elif TARGET_OS_OSX + presentingWindow:[[NSWindow alloc] init] +#endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST + completion:^(GIDUserAuth *userAuth, NSError *error) { + [expectation fulfill]; + XCTAssertNil(userAuth); + XCTAssertEqual(error.code, kGIDSignInErrorCodeMismatchWithCurrentUser); + }]; + + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + +- (void)testAddScopes_failure_addScopesToPreviousUser_currentUserIsNull { + id signIn = OCMClassMock([GIDSignIn class]); + OCMStub([signIn sharedInstance]).andReturn(signIn); + + GIDGoogleUser *currentUser = nil; + OCMStub([signIn currentUser]).andReturn(currentUser); + + GIDGoogleUser *previousUser = [self googleUserWithAccessTokenExpiresIn:kAccessTokenExpiresIn + idTokenExpiresIn:kNewIDTokenExpiresIn]; + + XCTestExpectation *expectation = + [self expectationWithDescription:@"Completion is called."]; + + [previousUser addScopes:@[kNewScope] +#if TARGET_OS_IOS || TARGET_OS_MACCATALYST + presentingViewController:[[UIViewController alloc] init] +#elif TARGET_OS_OSX + presentingWindow:[[NSWindow alloc] init] +#endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST + completion:^(GIDUserAuth *userAuth, NSError *error) { + [expectation fulfill]; + XCTAssertNil(userAuth); + XCTAssertEqual(error.code, kGIDSignInErrorCodeMismatchWithCurrentUser); + }]; + + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + #pragma mark - Helpers // Returns a GIDGoogleUser with different tokens expiresIn time. The token strings are constants. diff --git a/Samples/ObjC/SignInSample/Source/SignInViewController.m b/Samples/ObjC/SignInSample/Source/SignInViewController.m index c44a0945..8524bb26 100644 --- a/Samples/ObjC/SignInSample/Source/SignInViewController.m +++ b/Samples/ObjC/SignInSample/Source/SignInViewController.m @@ -280,10 +280,11 @@ - (IBAction)disconnect:(id)sender { } - (IBAction)addScopes:(id)sender { - [GIDSignIn.sharedInstance addScopes:@[ @"https://www.googleapis.com/auth/user.birthday.read" ] - presentingViewController:self - completion:^(GIDUserAuth *_Nullable userAuth, - NSError *_Nullable error) { + GIDGoogleUser *currentUser = GIDSignIn.sharedInstance.currentUser; + [currentUser addScopes:@[ @"https://www.googleapis.com/auth/user.birthday.read" ] + presentingViewController:self + completion:^(GIDUserAuth *_Nullable userAuth, + NSError *_Nullable error) { if (error) { self->_signInAuthStatus.text = [NSString stringWithFormat:@"Status: Failed to add scopes: %@", error]; diff --git a/Samples/Swift/DaysUntilBirthday/Shared/Services/GoogleSignInAuthenticator.swift b/Samples/Swift/DaysUntilBirthday/Shared/Services/GoogleSignInAuthenticator.swift index ce20106f..93c29a6f 100644 --- a/Samples/Swift/DaysUntilBirthday/Shared/Services/GoogleSignInAuthenticator.swift +++ b/Samples/Swift/DaysUntilBirthday/Shared/Services/GoogleSignInAuthenticator.swift @@ -83,13 +83,17 @@ final class GoogleSignInAuthenticator: ObservableObject { /// - note: Successful requests will update the `authViewModel.state` with a new current user that /// has the granted scope. func addBirthdayReadScope(completion: @escaping () -> Void) { + guard let currentUser = GIDSignIn.sharedInstance.currentUser else { + fatalError("No user signed in!") + } + #if os(iOS) guard let rootViewController = UIApplication.shared.windows.first?.rootViewController else { fatalError("No root view controller!") } - GIDSignIn.sharedInstance.addScopes([BirthdayLoader.birthdayReadScope], - presenting: rootViewController) { userAuth, error in + currentUser.addScopes([BirthdayLoader.birthdayReadScope], + presenting: rootViewController) { userAuth, error in if let error = error { print("Found error while adding birthday read scope: \(error).") return @@ -105,8 +109,8 @@ final class GoogleSignInAuthenticator: ObservableObject { fatalError("No presenting window!") } - GIDSignIn.sharedInstance.addScopes([BirthdayLoader.birthdayReadScope], - presenting: presentingWindow) { userAuth, error in + currentUser.addScopes([BirthdayLoader.birthdayReadScope], + presenting: presentingWindow) { userAuth, error in if let error = error { print("Found error while adding birthday read scope: \(error).") return