diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/FetchAuthSessionOperationHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/FetchAuthSessionOperationHelper.swift index c713b55e76..0d32c0a8ed 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/FetchAuthSessionOperationHelper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/FetchAuthSessionOperationHelper.swift @@ -105,7 +105,7 @@ class FetchAuthSessionOperationHelper { case .sessionEstablished(let credentials): return credentials.cognitoSession case .error(let authorizationError): - return try await sessionResultWithError( + return await sessionResultWithError( authorizationError, authenticationState: authenticationState ) @@ -121,7 +121,7 @@ class FetchAuthSessionOperationHelper { func sessionResultWithError( _ error: AuthorizationError, authenticationState: AuthenticationState - ) async throws -> AuthSession { + ) async -> AuthSession { log.verbose("Received fetch auth session error - \(error)") var isSignedIn = false diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthCredentialStoreIssueTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthCredentialStoreIssueTests.swift new file mode 100644 index 0000000000..bdcac83a9c --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthCredentialStoreIssueTests.swift @@ -0,0 +1,448 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +import AWSCognitoIdentity +import AWSCognitoIdentityProvider +import AWSPluginsCore +import ClientRuntime +import XCTest +@testable import Amplify +@testable import AWSCognitoAuthPlugin + +@testable import AWSPluginsTestCommon + +// MARK: - Credential Store Issue Tests +// These tests investigate potential causes of random user logouts +// related to credential store operations, decoding failures, and edge cases. + +class AWSAuthCredentialStoreIssueTests: BaseAuthorizationTests { + + // MARK: - Test: Multiple concurrent session fetches should not cause race condition + + /// Test that multiple concurrent fetchAuthSession calls don't cause race conditions + /// + /// - Given: A signed-in user with expired tokens + /// - When: Multiple fetchAuthSession calls are made concurrently + /// - Then: All calls should return consistent results without causing logout + /// + func testConcurrentSessionFetches_ShouldNotCauseRaceCondition() async throws { + let tokenRefreshExpectation = expectation(description: "Token refresh should be called") + tokenRefreshExpectation.assertForOverFulfill = false // May be called multiple times + + let initialState = AuthState.configured( + AuthenticationState.signedIn(.testData), + AuthorizationState.sessionEstablished( + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted + ) + + let getTokensFromRefreshToken: MockIdentityProvider.MockGetTokensFromRefreshTokenResponse = { _ in + tokenRefreshExpectation.fulfill() + return GetTokensFromRefreshTokenOutput(authenticationResult: .init( + accessToken: "newAccessToken", + expiresIn: 3_600, + idToken: "newIdToken", + refreshToken: "newRefreshToken" + )) + } + + let awsCredentials: MockIdentity.MockGetCredentialsResponse = { _ in + let credentials = CognitoIdentityClientTypes.Credentials( + accessKeyId: "accessKey", + expiration: Date().addingTimeInterval(3_600), + secretKey: "secret", + sessionToken: "session" + ) + return .init(credentials: credentials, identityId: "responseIdentityID") + } + + let plugin = configurePluginWith( + userPool: { MockIdentityProvider(mockGetTokensFromRefreshTokenResponse: getTokensFromRefreshToken) }, + identityPool: { MockIdentity(mockGetCredentialsResponse: awsCredentials) }, + initialState: initialState + ) + + // Make multiple concurrent session fetch calls + async let session1 = plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + async let session2 = plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + async let session3 = plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + + let results = try await [session1, session2, session3] + + await fulfillment(of: [tokenRefreshExpectation], timeout: apiTimeout) + + // All sessions should report signed in + for (index, session) in results.enumerated() { + XCTAssertTrue( + session.isSignedIn, + "Session \(index + 1) should report signed in after concurrent fetch" + ) + } + } + + // MARK: - Test: Session fetch during token refresh should not cause inconsistent state + + /// Test that fetching session while token refresh is in progress returns consistent state + /// + /// - Given: A signed-in user with a token refresh in progress + /// - When: Another fetchAuthSession is called + /// - Then: The result should be consistent and not cause logout + /// + func testSessionFetchDuringRefresh_ShouldReturnConsistentState() async throws { + let tokenRefreshExpectation = expectation(description: "Token refresh should be called") + + let initialState = AuthState.configured( + AuthenticationState.signedIn(.testData), + AuthorizationState.sessionEstablished( + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted + ) + + let getTokensFromRefreshToken: MockIdentityProvider.MockGetTokensFromRefreshTokenResponse = { _ in + tokenRefreshExpectation.fulfill() + // Simulate some delay in token refresh + try? await Task.sleep(nanoseconds: 100_000_000) // 0.1 seconds + return GetTokensFromRefreshTokenOutput(authenticationResult: .init( + accessToken: "newAccessToken", + expiresIn: 3_600, + idToken: "newIdToken", + refreshToken: "newRefreshToken" + )) + } + + let awsCredentials: MockIdentity.MockGetCredentialsResponse = { _ in + let credentials = CognitoIdentityClientTypes.Credentials( + accessKeyId: "accessKey", + expiration: Date().addingTimeInterval(3_600), + secretKey: "secret", + sessionToken: "session" + ) + return .init(credentials: credentials, identityId: "responseIdentityID") + } + + let plugin = configurePluginWith( + userPool: { MockIdentityProvider(mockGetTokensFromRefreshTokenResponse: getTokensFromRefreshToken) }, + identityPool: { MockIdentity(mockGetCredentialsResponse: awsCredentials) }, + initialState: initialState + ) + + let session = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + + await fulfillment(of: [tokenRefreshExpectation], timeout: apiTimeout) + + XCTAssertTrue(session.isSignedIn, "User should remain signed in during token refresh") + } + + // MARK: - Test: Empty token response should not cause logout + + /// Test that receiving empty tokens from refresh doesn't cause logout + /// + /// - Given: A signed-in user with expired tokens + /// - When: Token refresh returns empty/nil tokens + /// - Then: isSignedIn should still be true, but token results should fail + /// + func testEmptyTokenResponse_ShouldNotCauseLogout() async throws { + let tokenRefreshExpectation = expectation(description: "Token refresh should be called") + + let initialState = AuthState.configured( + AuthenticationState.signedIn(.testData), + AuthorizationState.sessionEstablished( + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted + ) + + let getTokensFromRefreshToken: MockIdentityProvider.MockGetTokensFromRefreshTokenResponse = { _ in + tokenRefreshExpectation.fulfill() + // Return empty/nil tokens + return GetTokensFromRefreshTokenOutput(authenticationResult: .init( + accessToken: nil, + expiresIn: 0, + idToken: nil, + refreshToken: nil + )) + } + + let plugin = configurePluginWith( + userPool: { MockIdentityProvider(mockGetTokensFromRefreshTokenResponse: getTokensFromRefreshToken) }, + initialState: initialState + ) + + let session = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + + await fulfillment(of: [tokenRefreshExpectation], timeout: apiTimeout) + + // User should still be signed in even if tokens are empty + XCTAssertTrue( + session.isSignedIn, + "User should remain signed in even when token refresh returns empty tokens" + ) + + // Token result should be a failure + let tokensResult = (session as? AuthCognitoTokensProvider)?.getCognitoTokens() + if case .success = tokensResult { + XCTFail("Expected token fetch to fail when refresh returns empty tokens") + } + } + + // MARK: - Test: Partial token response should not cause logout + + /// Test that receiving partial tokens from refresh doesn't cause logout + /// + /// - Given: A signed-in user with expired tokens + /// - When: Token refresh returns only some tokens (missing accessToken) + /// - Then: isSignedIn should still be true + /// + func testPartialTokenResponse_ShouldNotCauseLogout() async throws { + let tokenRefreshExpectation = expectation(description: "Token refresh should be called") + + let initialState = AuthState.configured( + AuthenticationState.signedIn(.testData), + AuthorizationState.sessionEstablished( + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted + ) + + let getTokensFromRefreshToken: MockIdentityProvider.MockGetTokensFromRefreshTokenResponse = { _ in + tokenRefreshExpectation.fulfill() + // Return partial tokens (missing accessToken) + return GetTokensFromRefreshTokenOutput(authenticationResult: .init( + accessToken: nil, + expiresIn: 3_600, + idToken: "idToken", + refreshToken: "refreshToken" + )) + } + + let plugin = configurePluginWith( + userPool: { MockIdentityProvider(mockGetTokensFromRefreshTokenResponse: getTokensFromRefreshToken) }, + initialState: initialState + ) + + let session = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + + await fulfillment(of: [tokenRefreshExpectation], timeout: apiTimeout) + + // User should still be signed in even if some tokens are missing + XCTAssertTrue( + session.isSignedIn, + "User should remain signed in even when token refresh returns partial tokens" + ) + } + + // MARK: - Test: Generic service error should not cause logout + + /// Test that generic service errors during token refresh don't cause logout + /// + /// - Given: A signed-in user with expired tokens + /// - When: Token refresh fails with a generic service error (InternalErrorException) + /// - Then: isSignedIn should still be true and error should NOT be sessionExpired + /// + func testGenericServiceError_ShouldNotCauseLogout() async throws { + let tokenRefreshExpectation = expectation(description: "Token refresh should be called") + + let initialState = AuthState.configured( + AuthenticationState.signedIn(.testData), + AuthorizationState.sessionEstablished( + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted + ) + + let getTokensFromRefreshToken: MockIdentityProvider.MockGetTokensFromRefreshTokenResponse = { _ in + tokenRefreshExpectation.fulfill() + // Throw a generic service error (not NotAuthorizedException) + throw AWSCognitoIdentityProvider.InternalErrorException() + } + + let plugin = configurePluginWith( + userPool: { MockIdentityProvider(mockGetTokensFromRefreshTokenResponse: getTokensFromRefreshToken) }, + initialState: initialState + ) + + let session = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + + await fulfillment(of: [tokenRefreshExpectation], timeout: apiTimeout) + + // User should still be signed in for service errors + XCTAssertTrue( + session.isSignedIn, + "User should remain signed in when token refresh fails with service error" + ) + + // Token result should NOT be sessionExpired + let tokensResult = (session as? AuthCognitoTokensProvider)?.getCognitoTokens() + if case .failure(let error) = tokensResult { + if case .sessionExpired = error { + XCTFail("Generic service error should NOT result in sessionExpired") + } + } + } + + // MARK: - Test: Keychain security error during credential fetch + + /// Test that keychain security errors during credential fetch don't cause logout + /// + /// - Given: A signed-in user with valid authentication state + /// - When: Credential store throws a keychain security error (errSecInteractionNotAllowed) + /// - Then: isSignedIn should still be true based on authentication state + /// + func testKeychainSecurityError_ShouldNotCauseLogout() async throws { + // Create a plugin with a credential store that throws security errors + let plugin = AWSCognitoAuthPlugin() + + // Set up initial signed-in state + let initialState = AuthState.configured( + AuthenticationState.signedIn(.testData), + AuthorizationState.sessionEstablished(AmplifyCredentials.testData), + .notStarted + ) + + // Create environment with a failing credential store client + let failingCredentialClient = MockFailingCredentialStoreClient( + errorToThrow: KeychainStoreError.securityError(-25_308) // errSecInteractionNotAllowed + ) + + let environment = makeAuthEnvironmentWithFailingCredentialStore( + credentialsClient: failingCredentialClient + ) + + let statemachine = AuthStateMachine( + resolver: AuthState.Resolver(), + environment: environment, + initialState: initialState + ) + + plugin.configure( + authConfiguration: Defaults.makeDefaultAuthConfigData(), + authEnvironment: environment, + authStateMachine: statemachine, + credentialStoreStateMachine: Defaults.makeDefaultCredentialStateMachine(), + hubEventHandler: MockAuthHubEventBehavior(), + analyticsHandler: MockAnalyticsHandler() + ) + + let session = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + + // CRITICAL: User should still be reported as signed in based on auth state + // even when credential store has security errors + XCTAssertTrue( + session.isSignedIn, + "User should remain signed in even when keychain throws security error" + ) + } + + /// Test that keychain decode/coding errors during credential fetch don't cause logout + /// + /// - Given: A signed-in user with valid authentication state + /// - When: Credential store throws a decoding error + /// - Then: isSignedIn should still be true based on authentication state + /// + func testKeychainDecodingError_ShouldNotCauseLogout() async throws { + let plugin = AWSCognitoAuthPlugin() + + let initialState = AuthState.configured( + AuthenticationState.signedIn(.testData), + AuthorizationState.sessionEstablished(AmplifyCredentials.testData), + .notStarted + ) + + // Create environment with a credential store that throws decoding errors + let decodingError = DecodingError.dataCorrupted( + DecodingError.Context(codingPath: [], debugDescription: "Corrupted credential data") + ) + let failingCredentialClient = MockFailingCredentialStoreClient( + errorToThrow: KeychainStoreError.codingError("Failed to decode credentials", decodingError) + ) + + let environment = makeAuthEnvironmentWithFailingCredentialStore( + credentialsClient: failingCredentialClient + ) + + let statemachine = AuthStateMachine( + resolver: AuthState.Resolver(), + environment: environment, + initialState: initialState + ) + + plugin.configure( + authConfiguration: Defaults.makeDefaultAuthConfigData(), + authEnvironment: environment, + authStateMachine: statemachine, + credentialStoreStateMachine: Defaults.makeDefaultCredentialStateMachine(), + hubEventHandler: MockAuthHubEventBehavior(), + analyticsHandler: MockAnalyticsHandler() + ) + + let session = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + + // User should still be signed in even when credential decoding fails + XCTAssertTrue( + session.isSignedIn, + "User should remain signed in even when credential decoding fails" + ) + } + + // MARK: - Helper: Create environment with failing credential store + + private func makeAuthEnvironmentWithFailingCredentialStore( + credentialsClient: CredentialStoreStateBehavior + ) -> AuthEnvironment { + let userPoolConfigData = Defaults.makeDefaultUserPoolConfigData() + let identityPoolConfigData = Defaults.makeIdentityConfigData() + + let srpAuthEnvironment = BasicSRPAuthEnvironment( + userPoolConfiguration: userPoolConfigData, + cognitoUserPoolFactory: Defaults.makeDefaultUserPool + ) + let srpSignInEnvironment = BasicSRPSignInEnvironment(srpAuthEnvironment: srpAuthEnvironment) + let userPoolEnvironment = BasicUserPoolEnvironment( + userPoolConfiguration: userPoolConfigData, + cognitoUserPoolFactory: Defaults.makeDefaultUserPool, + cognitoUserPoolASFFactory: { MockASF() }, + cognitoUserPoolAnalyticsHandlerFactory: { MockAnalyticsHandler() } + ) + let authenticationEnvironment = BasicAuthenticationEnvironment( + srpSignInEnvironment: srpSignInEnvironment, + userPoolEnvironment: userPoolEnvironment, + hostedUIEnvironment: nil + ) + let authorizationEnvironment = BasicAuthorizationEnvironment( + identityPoolConfiguration: identityPoolConfigData, + cognitoIdentityFactory: Defaults.makeIdentity + ) + + return AuthEnvironment( + configuration: Defaults.makeDefaultAuthConfigData(), + userPoolConfigData: userPoolConfigData, + identityPoolConfigData: identityPoolConfigData, + authenticationEnvironment: authenticationEnvironment, + authorizationEnvironment: authorizationEnvironment, + credentialsClient: credentialsClient, + logger: Amplify.Logging.logger(forCategory: "awsCognitoAuthPluginTest") + ) + } +} + +// MARK: - Mock Failing Credential Store Client + +/// A mock credential store client that throws specified errors when fetching credentials +struct MockFailingCredentialStoreClient: CredentialStoreStateBehavior { + let errorToThrow: Error + + func fetchData(type: CredentialStoreDataType) async throws -> CredentialStoreData { + throw errorToThrow + } + + func storeData(data: CredentialStoreData) async throws { + // Allow stores to succeed + } + + func deleteData(type: CredentialStoreDataType) async throws { + // Allow deletes to succeed + } +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthNetworkTimeoutDuringTokenRefreshTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthNetworkTimeoutDuringTokenRefreshTests.swift new file mode 100644 index 0000000000..14a5222b9e --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthNetworkTimeoutDuringTokenRefreshTests.swift @@ -0,0 +1,363 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +import AWSCognitoIdentity +import AWSCognitoIdentityProvider +import AWSPluginsCore +import ClientRuntime +import XCTest +@testable import Amplify +@testable import AWSCognitoAuthPlugin + +@testable import AWSPluginsTestCommon + +// MARK: - Network Timeout During Token Refresh Tests +// These tests verify that network errors during token refresh correctly +// preserve the user's signed-in state and do not incorrectly trigger logout. + +class AWSAuthNetworkTimeoutDuringTokenRefreshTests: BaseAuthorizationTests { + + // MARK: - Test: Network timeout during token refresh should preserve isSignedIn = true + + /// Test that network timeout during token refresh preserves signed-in state + /// + /// - Given: A signed-in user with expired tokens that need refresh + /// - When: Token refresh fails due to network timeout (NSURLErrorTimedOut) + /// - Then: + /// - isSignedIn should still be true (user is still authenticated) + /// - cognitoTokensResult should be a failure with service error + /// - The error should NOT be sessionExpired (which would indicate logout) + /// - The underlying error should be a network error + /// + func testNetworkTimeoutDuringTokenRefresh_ShouldPreserveSignedInState() async throws { + // Expectation to verify the mock was actually called + let tokenRefreshExpectation = expectation(description: "Token refresh should be called") + + // Setup: User is signed in with expired tokens + let initialState = AuthState.configured( + AuthenticationState.signedIn(.testData), + AuthorizationState.sessionEstablished( + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted + ) + + // Mock: Token refresh throws a network timeout error + let getTokensFromRefreshToken: MockIdentityProvider.MockGetTokensFromRefreshTokenResponse = { _ in + // Fulfill expectation to prove this mock was called + tokenRefreshExpectation.fulfill() + + // Simulate NSURLErrorTimedOut (-1001) + let networkError = NSError( + domain: NSURLErrorDomain, + code: NSURLErrorTimedOut, + userInfo: [NSLocalizedDescriptionKey: "The request timed out."] + ) + throw networkError + } + + let plugin = configurePluginWith( + userPool: { MockIdentityProvider(mockGetTokensFromRefreshTokenResponse: getTokensFromRefreshToken) }, + initialState: initialState + ) + + // Act: Fetch auth session (which will trigger token refresh) + let session = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + + // Wait for the expectation to verify mock was called + await fulfillment(of: [tokenRefreshExpectation], timeout: apiTimeout) + + // Assert: User should STILL be signed in despite network error + XCTAssertTrue( + session.isSignedIn, + "isSignedIn should be true even when token refresh fails due to network timeout" + ) + + // Assert: Token result should be a failure (but NOT sessionExpired) + let tokensResult = (session as? AuthCognitoTokensProvider)?.getCognitoTokens() + guard case .failure(let tokenError) = tokensResult else { + XCTFail("Expected token fetch to fail due to network error") + return + } + + // Assert: The error should be a service error, NOT sessionExpired + // sessionExpired would indicate the refresh token is invalid (user should re-login) + // service error indicates a transient failure (user should retry) + switch tokenError { + case .sessionExpired: + XCTFail( + "Network timeout should NOT result in sessionExpired error. " + + "sessionExpired should only occur for NotAuthorizedException (invalid refresh token)." + ) + case .service(_, _, let underlyingError): + // This is the expected behavior - network errors should be service errors + if let cognitoError = underlyingError as? AWSCognitoAuthError { + XCTAssertEqual( + cognitoError, + .network, + "Expected underlying error to be .network for timeout errors" + ) + } + // Success - network error correctly classified as service error + default: + // Other error types are acceptable as long as it's not sessionExpired + break + } + } + + // MARK: - Test: Network timeout should NOT clear credentials + + /// Test that network timeout during token refresh does not clear stored credentials + /// + /// - Given: A signed-in user with expired tokens + /// - When: Token refresh fails due to network timeout + /// - Then: The authorization state should preserve existing credentials + /// + func testNetworkTimeoutDuringTokenRefresh_ShouldNotClearCredentials() async throws { + // Expectation to verify the mock was actually called + let tokenRefreshExpectation = expectation(description: "Token refresh should be called") + + let initialState = AuthState.configured( + AuthenticationState.signedIn(.testData), + AuthorizationState.sessionEstablished( + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted + ) + + let getTokensFromRefreshToken: MockIdentityProvider.MockGetTokensFromRefreshTokenResponse = { _ in + tokenRefreshExpectation.fulfill() + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorTimedOut, + userInfo: [NSLocalizedDescriptionKey: "The request timed out."] + ) + } + + let plugin = configurePluginWith( + userPool: { MockIdentityProvider(mockGetTokensFromRefreshTokenResponse: getTokensFromRefreshToken) }, + initialState: initialState + ) + + let session = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + + // Wait for the expectation to verify mock was called + await fulfillment(of: [tokenRefreshExpectation], timeout: apiTimeout) + + // The session should still report signed in + XCTAssertTrue(session.isSignedIn) + + // Identity ID should still be available (from cached credentials) + _ = (session as? AuthCognitoIdentityProvider)?.getIdentityId() + // Note: Identity ID may fail if it depends on token refresh, but the key point + // is that isSignedIn remains true + } + + // MARK: - Test: Connection lost during token refresh + + /// Test that connection lost error during token refresh preserves signed-in state + /// + func testConnectionLostDuringTokenRefresh_ShouldPreserveSignedInState() async throws { + // Expectation to verify the mock was actually called + let tokenRefreshExpectation = expectation(description: "Token refresh should be called") + + let initialState = AuthState.configured( + AuthenticationState.signedIn(.testData), + AuthorizationState.sessionEstablished( + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted + ) + + let getTokensFromRefreshToken: MockIdentityProvider.MockGetTokensFromRefreshTokenResponse = { _ in + tokenRefreshExpectation.fulfill() + // Simulate NSURLErrorNetworkConnectionLost (-1005) + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorNetworkConnectionLost, + userInfo: [NSLocalizedDescriptionKey: "The network connection was lost."] + ) + } + + let plugin = configurePluginWith( + userPool: { MockIdentityProvider(mockGetTokensFromRefreshTokenResponse: getTokensFromRefreshToken) }, + initialState: initialState + ) + + let session = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + + // Wait for the expectation to verify mock was called + await fulfillment(of: [tokenRefreshExpectation], timeout: apiTimeout) + + XCTAssertTrue( + session.isSignedIn, + "isSignedIn should be true even when token refresh fails due to connection lost" + ) + } + + // MARK: - Test: No internet connection during token refresh + + /// Test that no internet connection error during token refresh preserves signed-in state + /// + func testNoInternetDuringTokenRefresh_ShouldPreserveSignedInState() async throws { + // Expectation to verify the mock was actually called + let tokenRefreshExpectation = expectation(description: "Token refresh should be called") + + let initialState = AuthState.configured( + AuthenticationState.signedIn(.testData), + AuthorizationState.sessionEstablished( + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted + ) + + let getTokensFromRefreshToken: MockIdentityProvider.MockGetTokensFromRefreshTokenResponse = { _ in + tokenRefreshExpectation.fulfill() + // Simulate NSURLErrorNotConnectedToInternet (-1009) + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorNotConnectedToInternet, + userInfo: [NSLocalizedDescriptionKey: "The Internet connection appears to be offline."] + ) + } + + let plugin = configurePluginWith( + userPool: { MockIdentityProvider(mockGetTokensFromRefreshTokenResponse: getTokensFromRefreshToken) }, + initialState: initialState + ) + + let session = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + + // Wait for the expectation to verify mock was called + await fulfillment(of: [tokenRefreshExpectation], timeout: apiTimeout) + + XCTAssertTrue( + session.isSignedIn, + "isSignedIn should be true even when token refresh fails due to no internet" + ) + } + + // MARK: - Test: Contrast with actual session expiry (NotAuthorizedException) + + /// Test that NotAuthorizedException correctly results in sessionExpired error + /// This contrasts with network errors to show the difference in handling + /// + /// - Given: A signed-in user with expired tokens + /// - When: Token refresh fails due to NotAuthorizedException (invalid refresh token) + /// - Then: + /// - isSignedIn should still be true (authentication state is preserved) + /// - cognitoTokensResult should be sessionExpired (indicating re-login needed) + /// + func testNotAuthorizedException_ShouldResultInSessionExpired() async throws { + // Expectation to verify the mock was actually called + let tokenRefreshExpectation = expectation(description: "Token refresh should be called") + + let initialState = AuthState.configured( + AuthenticationState.signedIn(.testData), + AuthorizationState.sessionEstablished( + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted + ) + + let getTokensFromRefreshToken: MockIdentityProvider.MockGetTokensFromRefreshTokenResponse = { _ in + tokenRefreshExpectation.fulfill() + // This is the ONLY error that should result in sessionExpired + throw AWSCognitoIdentityProvider.NotAuthorizedException() + } + + let plugin = configurePluginWith( + userPool: { MockIdentityProvider(mockGetTokensFromRefreshTokenResponse: getTokensFromRefreshToken) }, + initialState: initialState + ) + + let session = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + + // Wait for the expectation to verify mock was called + await fulfillment(of: [tokenRefreshExpectation], timeout: apiTimeout) + + // isSignedIn is still true - the authentication state is preserved + XCTAssertTrue(session.isSignedIn) + + // But the token result should be sessionExpired + let tokensResult = (session as? AuthCognitoTokensProvider)?.getCognitoTokens() + guard case .failure(let tokenError) = tokensResult, + case .sessionExpired = tokenError else { + XCTFail("NotAuthorizedException should result in sessionExpired error") + return + } + } + + // MARK: - Test: App-level error handling simulation + + /// This test demonstrates how apps might incorrectly handle session errors + /// and provides guidance on correct error handling + /// + func testAppErrorHandling_ShouldDistinguishNetworkFromSessionExpiry() async throws { + // Expectation to verify the mock was actually called + let tokenRefreshExpectation = expectation(description: "Token refresh should be called") + + let initialState = AuthState.configured( + AuthenticationState.signedIn(.testData), + AuthorizationState.sessionEstablished( + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted + ) + + // Simulate network timeout + let getTokensFromRefreshToken: MockIdentityProvider.MockGetTokensFromRefreshTokenResponse = { _ in + tokenRefreshExpectation.fulfill() + throw NSError( + domain: NSURLErrorDomain, + code: NSURLErrorTimedOut, + userInfo: nil + ) + } + + let plugin = configurePluginWith( + userPool: { MockIdentityProvider(mockGetTokensFromRefreshTokenResponse: getTokensFromRefreshToken) }, + initialState: initialState + ) + + let session = try await plugin.fetchAuthSession(options: AuthFetchSessionRequest.Options()) + + // Wait for the expectation to verify mock was called + await fulfillment(of: [tokenRefreshExpectation], timeout: apiTimeout) + + // CORRECT app-level error handling: + // 1. First check isSignedIn - if true, user is still authenticated + if session.isSignedIn { + // User is authenticated, check token availability + let tokensResult = (session as? AuthCognitoTokensProvider)?.getCognitoTokens() + + switch tokensResult { + case .success: + // Tokens available - proceed normally + break + + case .failure(let error): + switch error { + case .sessionExpired: + // ONLY in this case should the app prompt for re-login + // This means the refresh token is invalid + break + + case .service: + // Network or service error - DO NOT log out! + // Retry later or show "offline" message + XCTAssertTrue(session.isSignedIn, "User should remain signed in for service errors") + + default: + // Other errors - handle appropriately but don't log out + break + } + + case .none: + break + } + } else { + // User is not signed in - this is the only case where logout is appropriate + } + } +} diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/AuthSessionHelper.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/AuthSessionHelper.swift index c5296d1095..6b61554338 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/AuthSessionHelper.swift +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/AuthSessionHelper.swift @@ -30,6 +30,9 @@ struct AuthSessionHelper { static func clearSession() { let store = KeychainStore(service: "com.amplify.awsCognitoAuthPlugin") try? store._removeAll() + + let sharedStore = KeychainStore(service: "com.amplify.awsCognitoAuthPluginShared") + try? sharedStore._removeAll() } static func invalidateSession(with amplifyConfiguration: AmplifyConfiguration) { diff --git a/AmplifyPlugins/Logging/Sources/AWSCloudWatchLoggingPlugin/Persistence/LogFile.swift b/AmplifyPlugins/Logging/Sources/AWSCloudWatchLoggingPlugin/Persistence/LogFile.swift index da560f6e21..3f93a65359 100644 --- a/AmplifyPlugins/Logging/Sources/AWSCloudWatchLoggingPlugin/Persistence/LogFile.swift +++ b/AmplifyPlugins/Logging/Sources/AWSCloudWatchLoggingPlugin/Persistence/LogFile.swift @@ -30,14 +30,14 @@ final class LogFile { self.sizeLimitInBytes = sizeLimitInBytes self.handle = try FileHandle(forUpdating: fileURL) if #available(macOS 12.0, iOS 13.4, watchOS 6.2, tvOS 13.4, *) { - self.count = try self.handle.offset() + self.count = try handle.offset() } else { self.count = handle.offsetInFile } } deinit { - try? self.handle.close() + try? handle.close() } /// Returns the number of bytes available in the underlying file. @@ -68,7 +68,7 @@ final class LogFile { /// Data to the underlying log file. func write(data: Data) throws { if #available(macOS 12.0, iOS 13.4, watchOS 6.2, tvOS 13.4, *) { - try self.handle.write(contentsOf: data) + try handle.write(contentsOf: data) } else { handle.write(data) }