diff --git a/AmplifyPlugins/Analytics/Tests/AWSPinpointAnalyticsPluginUnitTests/Configuration/AWSPinpointAnalyticsPluginConfigurationTests.swift b/AmplifyPlugins/Analytics/Tests/AWSPinpointAnalyticsPluginUnitTests/Configuration/AWSPinpointAnalyticsPluginConfigurationTests.swift index f7b8f7216f..d520084ba0 100644 --- a/AmplifyPlugins/Analytics/Tests/AWSPinpointAnalyticsPluginUnitTests/Configuration/AWSPinpointAnalyticsPluginConfigurationTests.swift +++ b/AmplifyPlugins/Analytics/Tests/AWSPinpointAnalyticsPluginUnitTests/Configuration/AWSPinpointAnalyticsPluginConfigurationTests.swift @@ -5,7 +5,7 @@ // SPDX-License-Identifier: Apache-2.0 // -import Amplify +@testable import Amplify import XCTest @_spi(InternalAWSPinpoint) @testable import InternalAWSPinpoint @testable import AWSPinpointAnalyticsPlugin @@ -339,7 +339,7 @@ class AWSPinpointAnalyticsPluginConfigurationTests: XCTestCase { } } - func testThrowsOnMissingConfig() throws { + func testThrowsOnMissingConfig() async throws { let plugin = AWSPinpointAnalyticsPlugin() try Amplify.add(plugin: plugin) @@ -354,6 +354,8 @@ class AWSPinpointAnalyticsPluginConfigurationTests: XCTestCase { return } } + + await Amplify.reset() } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignOut/InitiateSignOut.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignOut/InitiateSignOut.swift index dddb53bd49..8f95607b86 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignOut/InitiateSignOut.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignOut/InitiateSignOut.swift @@ -17,21 +17,49 @@ struct InitiateSignOut: Action { func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async { logVerbose("\(#fileID) Starting execution", environment: environment) - + let updatedSignedInData = await getUpdatedSignedInData(environment: environment) let event: SignOutEvent if case .hostedUI(let options) = signedInData.signInMethod, options.preferPrivateSession == false { event = SignOutEvent(eventType: .invokeHostedUISignOut(signOutEventData, - signedInData)) + updatedSignedInData)) } else if signOutEventData.globalSignOut { - event = SignOutEvent(eventType: .signOutGlobally(signedInData)) + event = SignOutEvent(eventType: .signOutGlobally(updatedSignedInData)) } else { - event = SignOutEvent(eventType: .revokeToken(signedInData)) + event = SignOutEvent(eventType: .revokeToken(updatedSignedInData)) } logVerbose("\(#fileID) Sending event \(event.type)", environment: environment) await dispatcher.send(event) } + private func getUpdatedSignedInData( + environment: Environment + ) async -> SignedInData { + let credentialStoreClient = (environment as? AuthEnvironment)?.credentialsClient + do { + let data = try await credentialStoreClient?.fetchData( + type: .amplifyCredentials + ) + guard case .amplifyCredentials(let credentials) = data else { + return signedInData + } + + // Update SignedInData based on credential type + switch credentials { + case .userPoolOnly(let updatedSignedInData): + return updatedSignedInData + case .userPoolAndIdentityPool(let updatedSignedInData, _, _): + return updatedSignedInData + case .identityPoolOnly, .identityPoolWithFederation, .noCredentials: + return signedInData + } + } catch { + let logger = (environment as? LoggerProvider)?.logger + logger?.error("Unable to update credentials with error: \(error)") + return signedInData + } + } + } extension InitiateSignOut: CustomDebugDictionaryConvertible { diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignOut/ShowHostedUISignOut.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignOut/ShowHostedUISignOut.swift index 9cdf64017b..04ccb81fbe 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignOut/ShowHostedUISignOut.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignOut/ShowHostedUISignOut.swift @@ -46,6 +46,14 @@ class ShowHostedUISignOut: NSObject, Action { inPrivate: false, presentationAnchor: signOutEvent.presentationAnchor) await sendEvent(with: nil, dispatcher: dispatcher, environment: environment) + } catch HostedUIError.cancelled { + if signInData.isRefreshTokenExpired == true { + self.logVerbose("\(#fileID) Received user cancelled error, but session is expired and continue signing out.", environment: environment) + await sendEvent(with: nil, dispatcher: dispatcher, environment: environment) + } else { + self.logVerbose("\(#fileID) Received error \(HostedUIError.cancelled)", environment: environment) + await sendEvent(with: HostedUIError.cancelled, dispatcher: dispatcher, environment: environment) + } } catch { self.logVerbose("\(#fileID) Received error \(error)", environment: environment) await sendEvent(with: error, dispatcher: dispatcher, environment: environment) @@ -101,4 +109,3 @@ extension ShowHostedUISignOut { debugDictionary.debugDescription } } -//#endif diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift index e5c0500043..6edeb58aa1 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift @@ -115,10 +115,12 @@ extension AWSCognitoAuthPlugin: AuthCategoryBehavior { let options = options ?? AuthFetchSessionRequest.Options() let request = AuthFetchSessionRequest(options: options) let forceReconfigure = secureStoragePreferences?.accessGroup?.name != nil - let task = AWSAuthFetchSessionTask(request, - authStateMachine: authStateMachine, - configuration: authConfiguration, - forceReconfigure: forceReconfigure) + let task = AWSAuthFetchSessionTask( + request, + authStateMachine: authStateMachine, + configuration: authConfiguration, + environment: authEnvironment, + forceReconfigure: forceReconfigure) return try await taskQueue.sync { return try await task.value } as! AuthSession diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/FetchAuthSessionOperationHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/FetchAuthSessionOperationHelper.swift index 8069b50163..11252fb2b3 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/FetchAuthSessionOperationHelper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/FetchAuthSessionOperationHelper.swift @@ -7,10 +7,12 @@ import Foundation import Amplify +import AWSPluginsCore class FetchAuthSessionOperationHelper { typealias FetchAuthSessionCompletion = (Result) -> Void + var environment: Environment? = nil func fetch(_ authStateMachine: AuthStateMachine, forceRefresh: Bool = false) async throws -> AuthSession { @@ -98,7 +100,7 @@ class FetchAuthSessionOperationHelper { case .sessionEstablished(let credentials): return credentials.cognitoSession case .error(let authorizationError): - return try sessionResultWithError( + return try await sessionResultWithError( authorizationError, authenticationState: authenticationState) default: continue @@ -111,7 +113,7 @@ class FetchAuthSessionOperationHelper { func sessionResultWithError( _ error: AuthorizationError, authenticationState: AuthenticationState - ) throws -> AuthSession { + ) async throws -> AuthSession { log.verbose("Received fetch auth session error - \(error)") var isSignedIn = false @@ -129,8 +131,10 @@ class FetchAuthSessionOperationHelper { authError = fetchError.authError } case .sessionExpired(let error): + await setRefreshTokenExpiredInSignedInData() let session = AuthCognitoSignedInSessionHelper.makeExpiredSignedInSession( underlyingError: error) + return session default: break @@ -143,6 +147,43 @@ class FetchAuthSessionOperationHelper { cognitoTokensResult: .failure(authError)) return session } + + func setRefreshTokenExpiredInSignedInData() async { + let credentialStoreClient = (environment as? AuthEnvironment)?.credentialsClient + do { + let data = try await credentialStoreClient?.fetchData( + type: .amplifyCredentials + ) + guard case .amplifyCredentials(var credentials) = data else { + return + } + + // Update SignedInData based on credential type + switch credentials { + case .userPoolOnly(var signedInData): + signedInData.isRefreshTokenExpired = true + credentials = .userPoolOnly(signedInData: signedInData) + + case .userPoolAndIdentityPool(var signedInData, let identityId, let awsCredentials): + signedInData.isRefreshTokenExpired = true + credentials = .userPoolAndIdentityPool( + signedInData: signedInData, + identityID: identityId, + credentials: awsCredentials) + + case .identityPoolOnly, .identityPoolWithFederation, .noCredentials: + return + } + + try await credentialStoreClient?.storeData(data: .amplifyCredentials(credentials)) + } catch KeychainStoreError.itemNotFound { + let logger = (environment as? LoggerProvider)?.logger + logger?.info("No existing credentials found.") + } catch { + let logger = (environment as? LoggerProvider)?.logger + logger?.error("Unable to update credentials with error: \(error)") + } + } } extension FetchAuthSessionOperationHelper: DefaultLogger { } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignedInData.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignedInData.swift index da3dc0bf02..ebadc980e3 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignedInData.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignedInData.swift @@ -14,6 +14,7 @@ struct SignedInData { let signInMethod: SignInMethod let deviceMetadata: DeviceMetadata let cognitoUserPoolTokens: AWSCognitoUserPoolTokens + var isRefreshTokenExpired: Bool? init(signedInDate: Date, signInMethod: SignInMethod, @@ -27,6 +28,7 @@ struct SignedInData { self.signInMethod = signInMethod self.deviceMetadata = deviceMetadata self.cognitoUserPoolTokens = cognitoUserPoolTokens + self.isRefreshTokenExpired = false } } @@ -42,7 +44,8 @@ extension SignedInData: CustomDebugDictionaryConvertible { "signedInDate": signedInDate, "signInMethod": signInMethod, "deviceMetadata": deviceMetadata, - "tokens": cognitoUserPoolTokens + "tokens": cognitoUserPoolTokens, + "refreshTokenExpired": isRefreshTokenExpired ?? false ] } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthFetchSessionTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthFetchSessionTask.swift index 808363e6ee..9a7c744f30 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthFetchSessionTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthFetchSessionTask.swift @@ -24,11 +24,13 @@ class AWSAuthFetchSessionTask: AuthFetchSessionTask, DefaultLogger { _ request: AuthFetchSessionRequest, authStateMachine: AuthStateMachine, configuration: AuthConfiguration, + environment: Environment, forceReconfigure: Bool = false ) { self.request = request self.authStateMachine = authStateMachine self.fetchAuthSessionHelper = FetchAuthSessionOperationHelper() + self.fetchAuthSessionHelper.environment = environment self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) self.configuration = configuration self.forceReconfigure = forceReconfigure diff --git a/AmplifyPlugins/Logging/Sources/AWSCloudWatchLoggingPlugin/AWSCloudWatchLoggingSessionController.swift b/AmplifyPlugins/Logging/Sources/AWSCloudWatchLoggingPlugin/AWSCloudWatchLoggingSessionController.swift index 85f2d86ed6..a35054fe65 100644 --- a/AmplifyPlugins/Logging/Sources/AWSCloudWatchLoggingPlugin/AWSCloudWatchLoggingSessionController.swift +++ b/AmplifyPlugins/Logging/Sources/AWSCloudWatchLoggingPlugin/AWSCloudWatchLoggingSessionController.swift @@ -132,14 +132,19 @@ final class AWSCloudWatchLoggingSessionController { } self.batchSubscription = producer.logBatchPublisher.sink { [weak self] batch in guard self?.networkMonitor.isOnline == true else { return } + + // Capture strong references to consumer and batch before the async task + let strongConsumer = consumer + let strongBatch = batch + Task { do { - try await consumer.consume(batch: batch) + try await strongConsumer.consume(batch: strongBatch) } catch { Amplify.Logging.default.error("Error flushing logs with error \(error.localizedDescription)") let payload = HubPayload(eventName: HubPayload.EventName.Logging.flushLogFailure, context: error.localizedDescription) Amplify.Hub.dispatch(to: HubChannel.logging, payload: payload) - try batch.complete() + try strongBatch.complete() } } } @@ -178,8 +183,15 @@ final class AWSCloudWatchLoggingSessionController { } private func consumeLogBatch(_ batch: LogBatch) async throws { + // Check if consumer exists before trying to use it + guard let consumer = self.consumer else { + // If consumer is nil, still mark the batch as completed to prevent memory leaks + try batch.complete() + return + } + do { - try await consumer?.consume(batch: batch) + try await consumer.consume(batch: batch) } catch { Amplify.Logging.default.error("Error flushing logs with error \(error.localizedDescription)") let payload = HubPayload(eventName: HubPayload.EventName.Logging.flushLogFailure, context: error.localizedDescription) diff --git a/AmplifyPlugins/Logging/Sources/AWSCloudWatchLoggingPlugin/Consumer/CloudWatchLoggingConsumer.swift b/AmplifyPlugins/Logging/Sources/AWSCloudWatchLoggingPlugin/Consumer/CloudWatchLoggingConsumer.swift index 3ff0d65068..bdcc17c458 100644 --- a/AmplifyPlugins/Logging/Sources/AWSCloudWatchLoggingPlugin/Consumer/CloudWatchLoggingConsumer.swift +++ b/AmplifyPlugins/Logging/Sources/AWSCloudWatchLoggingPlugin/Consumer/CloudWatchLoggingConsumer.swift @@ -18,7 +18,12 @@ class CloudWatchLoggingConsumer { private let logGroupName: String private var logStreamName: String? private var ensureLogStreamExistsComplete: Bool = false - private let encoder = JSONEncoder() + private let encoderLock = NSLock() + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .millisecondsSince1970 + return encoder + }() init( client: CloudWatchLogsClientProtocol, @@ -29,6 +34,12 @@ class CloudWatchLoggingConsumer { self.formatter = CloudWatchLoggingStreamNameFormatter(userIdentifier: userIdentifier) self.logGroupName = logGroupName } + + private func safeEncode(_ value: T) throws -> Data { + return try encoderLock.withLock { + return try encoder.encode(value) + } + } } extension CloudWatchLoggingConsumer: LogBatchConsumer { @@ -40,28 +51,54 @@ extension CloudWatchLoggingConsumer: LogBatchConsumer { } await ensureLogStreamExists() - let batchByteSize = try encoder.encode(entries).count - if entries.count > AWSCloudWatchConstants.maxLogEvents { - let smallerEntries = entries.chunked(into: AWSCloudWatchConstants.maxLogEvents) + // Add safety check for nil logStreamName + guard let _ = self.logStreamName else { + Amplify.Logging.error("Log stream name is nil, cannot send logs") + try batch.complete() + return + } + + // Create a strong reference to entries to prevent deallocation during encoding + let entriesCopy = entries + + var batchByteSize: Int + do { + batchByteSize = try safeEncode(entriesCopy).count + } catch { + Amplify.Logging.error("Failed to encode log entries: \(error)") + try batch.complete() + return + } + + if entriesCopy.count > AWSCloudWatchConstants.maxLogEvents { + let smallerEntries = entriesCopy.chunked(into: AWSCloudWatchConstants.maxLogEvents) for entries in smallerEntries { - let entrySize = try encoder.encode(entries).count - if entrySize > AWSCloudWatchConstants.maxBatchByteSize { - let chunks = try chunk(entries, into: AWSCloudWatchConstants.maxBatchByteSize) - for chunk in chunks { - try await sendLogEvents(chunk) + do { + let entrySize = try safeEncode(entries).count + if entrySize > AWSCloudWatchConstants.maxBatchByteSize { + let chunks = try chunk(entries, into: AWSCloudWatchConstants.maxBatchByteSize) + for chunk in chunks { + try await sendLogEvents(chunk) + } + } else { + try await sendLogEvents(entries) } - } else { - try await sendLogEvents(entries) + } catch { + Amplify.Logging.error("Error processing log batch: \(error)") + continue } } - } else if batchByteSize > AWSCloudWatchConstants.maxBatchByteSize { - let smallerEntries = try chunk(entries, into: AWSCloudWatchConstants.maxBatchByteSize) - for entries in smallerEntries { - try await sendLogEvents(entries) + do { + let smallerEntries = try chunk(entriesCopy, into: AWSCloudWatchConstants.maxBatchByteSize) + for entries in smallerEntries { + try await sendLogEvents(entries) + } + } catch { + Amplify.Logging.error("Error chunking log entries: \(error)") } } else { - try await sendLogEvents(entries) + try await sendLogEvents(entriesCopy) } try batch.complete() @@ -72,47 +109,103 @@ extension CloudWatchLoggingConsumer: LogBatchConsumer { return } - defer { - ensureLogStreamExistsComplete = true - } - + // Only mark as complete after everything has finished successfully + // to avoid potential race conditions with incomplete state + if logStreamName == nil { - self.logStreamName = await formatter.formattedStreamName() + do { + // Explicitly capture self to avoid potential memory issues + let streamName = await self.formatter.formattedStreamName() + // Check if self is still valid and streamName is not nil before assigning + if !streamName.isEmpty { + self.logStreamName = streamName + } else { + // Fallback to a default if the stream name couldn't be determined + self.logStreamName = "default.\(UUID().uuidString)" + } + } catch { + // Handle any potential errors from async call + Amplify.Logging.error("Failed to get formatted stream name: \(error)") + // Fallback to a default + self.logStreamName = "default.\(UUID().uuidString)" + } } - let stream = try? await self.client.describeLogStreams(input: DescribeLogStreamsInput( - logGroupName: self.logGroupName, - logStreamNamePrefix: self.logStreamName - )).logStreams?.first(where: { stream in - return stream.logStreamName == self.logStreamName - }) - if stream != nil { + // Safety check - ensure we have a valid stream name before proceeding + guard let logStreamName = self.logStreamName, !logStreamName.isEmpty else { + Amplify.Logging.error("Invalid log stream name") + ensureLogStreamExistsComplete = true return } - - _ = try? await self.client.createLogStream(input: CreateLogStreamInput( - logGroupName: self.logGroupName, - logStreamName: self.logStreamName - )) + + do { + let stream = try? await self.client.describeLogStreams(input: DescribeLogStreamsInput( + logGroupName: self.logGroupName, + logStreamNamePrefix: logStreamName + )).logStreams?.first(where: { stream in + return stream.logStreamName == logStreamName + }) + + if stream == nil { + _ = try? await self.client.createLogStream(input: CreateLogStreamInput( + logGroupName: self.logGroupName, + logStreamName: logStreamName + )) + } + + // Mark as complete only after all operations finished + ensureLogStreamExistsComplete = true + } catch { + Amplify.Logging.error("Error ensuring log stream exists: \(error)") + // Still mark as complete to avoid getting stuck in a failed state + ensureLogStreamExistsComplete = true + } } private func sendLogEvents(_ entries: [LogEntry]) async throws { + // Safety check for empty entries + if entries.isEmpty { + return + } + + // Safety check for logStreamName + guard let logStreamName = self.logStreamName, !logStreamName.isEmpty else { + Amplify.Logging.error("Cannot send log events: Log stream name is nil or empty") + return + } + let events = convertToCloudWatchInputLogEvents(for: entries) - let response = try await self.client.putLogEvents(input: PutLogEventsInput( - logEvents: events, - logGroupName: self.logGroupName, - logStreamName: self.logStreamName, - sequenceToken: nil - )) - let retriableEntries = retriable(entries: entries, in: response) - if !retriableEntries.isEmpty { - let retriableEvents = convertToCloudWatchInputLogEvents(for: retriableEntries) - _ = try await self.client.putLogEvents(input: PutLogEventsInput( - logEvents: retriableEvents, + + // Safety check for empty events + if events.isEmpty { + Amplify.Logging.warn("No valid events to send to CloudWatch") + return + } + + do { + let response = try await self.client.putLogEvents(input: PutLogEventsInput( + logEvents: events, logGroupName: self.logGroupName, - logStreamName: self.logStreamName, + logStreamName: logStreamName, sequenceToken: nil )) + + // Handle retriable entries + let retriableEntries = retriable(entries: entries, in: response) + if !retriableEntries.isEmpty { + let retriableEvents = convertToCloudWatchInputLogEvents(for: retriableEntries) + if !retriableEvents.isEmpty { + _ = try await self.client.putLogEvents(input: PutLogEventsInput( + logEvents: retriableEvents, + logGroupName: self.logGroupName, + logStreamName: logStreamName, + sequenceToken: nil + )) + } + } + } catch { + Amplify.Logging.error("Failed to send log events: \(error)") + throw error } } @@ -147,19 +240,48 @@ extension CloudWatchLoggingConsumer: LogBatchConsumer { var chunks: [[LogEntry]] = [] var chunk: [LogEntry] = [] var currentChunkSize = 0 + for entry in entries { - let entrySize = try encoder.encode(entry).count + // Wrap the encoding in a do-catch to handle potential errors + var entrySize: Int + do { + entrySize = try encoder.encode(entry).count + } catch { + Amplify.Logging.error("Failed to encode log entry: \(error)") + // Skip this entry and continue with the next one + continue + } + if currentChunkSize + entrySize < maxByteSize { chunk.append(entry) currentChunkSize = currentChunkSize + entrySize } else { - chunks.append(chunk) + // Only add non-empty chunks + if !chunk.isEmpty { + chunks.append(chunk) + } chunk = [entry] - currentChunkSize = currentChunkSize + entrySize + currentChunkSize = entrySize } } - + + // Add the last chunk if it's not empty + if !chunk.isEmpty { + chunks.append(chunk) + } + + // Return even if chunks is empty to avoid null pointer issues return chunks } // swiftlint:enable shorthand_operator } + +private extension NSLock { + func withLock(_ block: () throws -> T) throws -> T { + lock() + defer { unlock() } + return try block() + } +} + +