From ec73fc576cfdf29bf2153bc5eca9656013e87d3d Mon Sep 17 00:00:00 2001 From: ademirev Date: Mon, 1 Jun 2026 21:55:33 -0700 Subject: [PATCH] Fix interrupted auth session completion --- GoogleSignIn/Sources/GIDSignIn.m | 68 ++++++++++++++++ GoogleSignIn/Tests/Unit/GIDSignInTest.m | 103 +++++++++++++++++++++++- 2 files changed, 170 insertions(+), 1 deletion(-) diff --git a/GoogleSignIn/Sources/GIDSignIn.m b/GoogleSignIn/Sources/GIDSignIn.m index 63b19c7e..257b4448 100644 --- a/GoogleSignIn/Sources/GIDSignIn.m +++ b/GoogleSignIn/Sources/GIDSignIn.m @@ -128,6 +128,11 @@ // The delay before the new sign-in flow can be presented after the existing one is cancelled. static const NSTimeInterval kPresentationDelayAfterCancel = 1.0; +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST +// The delay before checking whether a backgrounded auth flow was dismissed without a callback. +static const NSTimeInterval kInterruptedAuthFlowCancellationDelay = 0.5; +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST + // Parameters for the auth and token exchange endpoints. static NSString *const kAudienceParameter = @"audience"; // See b/11669751 . @@ -189,6 +194,8 @@ @implementation GIDSignIn { GIDTimedLoader *_timedLoader; // Flag indicating developer's intent to use App Check. BOOL _configureAppCheckCalled; + // Whether the current auth flow entered the background while still pending. + BOOL _authorizationFlowEnteredBackground; #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST } @@ -202,6 +209,9 @@ - (BOOL)handleURL:(NSURL *)url { // Check if the callback path matches the expected one for a URL from Safari/Chrome/SafariVC. if ([url.path isEqual:kBrowserCallbackPath]) { if ([_currentAuthorizationFlow resumeExternalUserAgentFlowWithURL:url]) { +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + _authorizationFlowEnteredBackground = NO; +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST _currentAuthorizationFlow = nil; return YES; } @@ -601,6 +611,12 @@ - (void)disconnectWithCompletion:(nullable GIDDisconnectCompletion)completion { #pragma mark - Custom getters and setters +- (void)dealloc { +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + [[NSNotificationCenter defaultCenter] removeObserver:self]; +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST +} + + (GIDSignIn *)sharedInstance { static dispatch_once_t once; static GIDSignIn *sharedInstance; @@ -688,6 +704,18 @@ - (instancetype)initWithKeychainStore:(GTMKeychainStore *)keychainStore [authStateMigrationService migrateIfNeededWithTokenURL:_appAuthConfiguration.tokenEndpoint callbackPath:kBrowserCallbackPath isFreshInstall:isFreshInstall]; + +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter]; + [notificationCenter addObserver:self + selector:@selector(applicationDidEnterBackground:) + name:UIApplicationDidEnterBackgroundNotification + object:nil]; + [notificationCenter addObserver:self + selector:@selector(applicationDidBecomeActive:) + name:UIApplicationDidBecomeActiveNotification + object:nil]; +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST } return self; } @@ -713,6 +741,9 @@ - (void)signInWithOptions:(GIDSignInInternalOptions *)options { // derive suitable options for the continuation! if (!options.continuation) { _currentOptions = options; +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + _authorizationFlowEnteredBackground = NO; +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST } if (options.interactive) { @@ -781,6 +812,39 @@ - (void)signInWithOptions:(GIDSignInInternalOptions *)options { } } +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST +- (void)applicationDidEnterBackground:(NSNotification *)notification { + if (_currentAuthorizationFlow && _currentOptions.interactive) { + _authorizationFlowEnteredBackground = YES; + } +} + +- (void)applicationDidBecomeActive:(NSNotification *)notification { + if (!_authorizationFlowEnteredBackground) { + return; + } + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, + (int64_t)(kInterruptedAuthFlowCancellationDelay * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [self cancelInterruptedAuthorizationFlowIfNeeded]; + }); +} + +- (void)cancelInterruptedAuthorizationFlowIfNeeded { + if (!_authorizationFlowEnteredBackground || + !_currentAuthorizationFlow || + !_currentOptions.interactive || + _currentOptions.presentingViewController.presentedViewController) { + return; + } + + _authorizationFlowEnteredBackground = NO; + [_currentAuthorizationFlow cancel]; + _currentAuthorizationFlow = nil; +} +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST + #pragma mark - Authentication flow - (void)authenticateInteractivelyWithOptions:(GIDSignInInternalOptions *)options { @@ -804,6 +868,10 @@ - (void)authenticateInteractivelyWithOptions:(GIDSignInInternalOptions *)options callback: ^(OIDAuthorizationResponse *_Nullable authorizationResponse, NSError *_Nullable error) { +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + self->_authorizationFlowEnteredBackground = NO; +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST + self->_currentAuthorizationFlow = nil; [self processAuthorizationResponse:authorizationResponse error:error emmSupport:emmSupport]; diff --git a/GoogleSignIn/Tests/Unit/GIDSignInTest.m b/GoogleSignIn/Tests/Unit/GIDSignInTest.m index ab1c4003..7e9ed8f2 100644 --- a/GoogleSignIn/Tests/Unit/GIDSignInTest.m +++ b/GoogleSignIn/Tests/Unit/GIDSignInTest.m @@ -228,6 +228,9 @@ @interface GIDSignInTest : XCTestCase { // Mock for |OIDAuthorizationService| id _oidAuthorizationService; + // Mock for |OIDExternalUserAgentSession|. + id _authorizationFlow; + // Parameter saved from delegate call. NSError *_authError; @@ -332,6 +335,7 @@ - (void)setUp { }); _user = OCMStrictClassMock([GIDGoogleUser class]); _oidAuthorizationService = OCMStrictClassMock([OIDAuthorizationService class]); + _authorizationFlow = OCMProtocolMock(@protocol(OIDExternalUserAgentSession)); OCMStub([_oidAuthorizationService presentAuthorizationRequest:SAVE_TO_ARG_BLOCK(self->_savedAuthorizationRequest) #if TARGET_OS_IOS || TARGET_OS_MACCATALYST @@ -339,7 +343,8 @@ - (void)setUp { #elif TARGET_OS_OSX presentingWindow:SAVE_TO_ARG_BLOCK(self->_savedPresentingWindow) #endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST - callback:COPY_TO_ARG_BLOCK(self->_savedAuthorizationCallback)]); + callback:COPY_TO_ARG_BLOCK(self->_savedAuthorizationCallback)]) + .andReturn(_authorizationFlow); OCMStub([self->_oidAuthorizationService performTokenRequest:SAVE_TO_ARG_BLOCK(self->_savedTokenRequest) originalAuthorizationResponse:[OCMArg any] @@ -379,6 +384,7 @@ - (void)tearDown { OCMVerifyAll(_authorization); OCMVerifyAll(_user); OCMVerifyAll(_oidAuthorizationService); + OCMVerifyAll(_authorizationFlow); #if TARGET_OS_IOS || TARGET_OS_MACCATALYST OCMVerifyAll(_presentingViewController); @@ -1198,6 +1204,74 @@ - (void)testOAuthLogin_ModalCanceled { XCTAssertEqual(_authError.code, kGIDSignInErrorCodeCanceled); } +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + +- (void)testOAuthLogin_BackgroundedAuthFlowCanceledWhenAuthUIClears { + OCMStub([_presentingViewController presentedViewController]).andReturn(nil); + + XCTestExpectation *completionExpectation = + [self expectationWithDescription:@"Completion should be called"]; + GIDSignInCompletion completion = ^(GIDSignInResult *_Nullable signInResult, + NSError *_Nullable error) { + [completionExpectation fulfill]; + self->_completion(signInResult, error); + }; + [self beginInteractiveSignInWithCompletion:completion]; + + [[[_authorizationFlow expect] andDo:^(NSInvocation *invocation) { + self->_savedAuthorizationCallback(nil, [self authorizationFlowCancellationError]); + }] cancel]; + + [[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidEnterBackgroundNotification object:nil]; + [[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidBecomeActiveNotification object:nil]; + + [self waitForExpectationsWithTimeout:1 handler:nil]; + XCTAssertTrue(_completionCalled, @"should call delegate"); + XCTAssertEqual(_authError.code, kGIDSignInErrorCodeCanceled); +} + +- (void)testOAuthLogin_BackgroundedAuthFlowIgnoresCompletedFlow { + OCMStub([_presentingViewController presentedViewController]).andReturn(nil); + [[_authorizationFlow reject] cancel]; + + XCTestExpectation *completionExpectation = + [self expectationWithDescription:@"Completion should be called"]; + GIDSignInCompletion completion = ^(GIDSignInResult *_Nullable signInResult, + NSError *_Nullable error) { + [completionExpectation fulfill]; + self->_completion(signInResult, error); + }; + [self beginInteractiveSignInWithCompletion:completion]; + + [[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidEnterBackgroundNotification object:nil]; + [[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidBecomeActiveNotification object:nil]; + _savedAuthorizationCallback(nil, [self authorizationFlowCancellationError]); + + [self waitForExpectationsWithTimeout:1 handler:nil]; + [self waitForInterruptedAuthFlowDelay]; + XCTAssertTrue(_completionCalled, @"should call delegate"); + XCTAssertEqual(_authError.code, kGIDSignInErrorCodeCanceled); +} + +- (void)testOAuthLogin_DidBecomeActiveWithoutBackgroundDoesNotCancel { + OCMStub([_presentingViewController presentedViewController]).andReturn(nil); + [[_authorizationFlow reject] cancel]; + + [self beginInteractiveSignInWithCompletion:_completion]; + + [[NSNotificationCenter defaultCenter] + postNotificationName:UIApplicationDidBecomeActiveNotification object:nil]; + + [self waitForInterruptedAuthFlowDelay]; + XCTAssertFalse(_completionCalled, @"should not call delegate"); +} + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST + - (void)testOAuthLogin_KeychainError { // This error is going be overidden by `-[GIDSignIn errorWithString:code:]` // We just need to fill in the error so that happens. @@ -2050,6 +2124,33 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow #pragma mark - Private Helpers +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + +- (void)beginInteractiveSignInWithCompletion:(nullable GIDSignInCompletion)completion { + [_signIn signInWithPresentingViewController:_presentingViewController completion:completion]; + XCTAssertNotNil(_savedAuthorizationRequest); + XCTAssertNotNil(_savedAuthorizationCallback); + XCTAssertEqual(_savedPresentingViewController, _presentingViewController); +} + +- (NSError *)authorizationFlowCancellationError { + return [NSError errorWithDomain:OIDGeneralErrorDomain + code:OIDErrorCodeUserCanceledAuthorizationFlow + userInfo:nil]; +} + +- (void)waitForInterruptedAuthFlowDelay { + XCTestExpectation *expectation = + [self expectationWithDescription:@"Interrupted auth flow delay"]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [expectation fulfill]; + }); + [self waitForExpectationsWithTimeout:2 handler:nil]; +} + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST + - (NSDictionary *) additionalParametersWithEMMPasscodeInfoRequired:(BOOL)emmPasscodeInfoRequired claimsAsJSONRequired:(BOOL)claimsAsJSONRequired {