diff --git a/.gitignore b/.gitignore index 9e3127542f..ee2ee74060 100644 --- a/.gitignore +++ b/.gitignore @@ -86,4 +86,5 @@ GraphQLModelBasedTests-amplifyconfiguration.json GraphQLWithIAMIntegrationTests-amplifyconfiguration.json GraphQLWithIAMIntegrationTests-credentials.json -AWSDataStoreCategoryPluginIntegrationTests-amplifyconfiguration.json \ No newline at end of file +AWSDataStoreCategoryPluginIntegrationTests-amplifyconfiguration.json +*.code-workspace diff --git a/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift b/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift index 73ca4510d0..dfd92b9e92 100644 --- a/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift +++ b/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift @@ -74,4 +74,16 @@ extension AuthCategory: AuthCategoryBehavior { ) async throws { try await plugin.confirmResetPassword(for: username, with: newPassword, confirmationCode: confirmationCode, options: options) } + + public func setUpTOTP() async throws -> TOTPSetupDetails { + try await plugin.setUpTOTP() + } + + public func verifyTOTPSetup( + code: String, + options: VerifyTOTPSetupRequest.Options? = nil + ) async throws { + try await plugin.verifyTOTPSetup(code: code, options: options) + } + } diff --git a/Amplify/Categories/Auth/AuthCategoryBehavior.swift b/Amplify/Categories/Auth/AuthCategoryBehavior.swift index 4a1b4a9dcf..dd42213863 100644 --- a/Amplify/Categories/Auth/AuthCategoryBehavior.swift +++ b/Amplify/Categories/Auth/AuthCategoryBehavior.swift @@ -124,4 +124,25 @@ public protocol AuthCategoryBehavior: AuthCategoryUserBehavior, AuthCategoryDevi /// - options: Parameters specific to plugin behavior func confirmResetPassword(for username: String, with newPassword: String, confirmationCode: String, options: AuthConfirmResetPasswordRequest.Options?) async throws + /// Initiates TOTP Setup + /// + /// Invoke this operation to setup TOTP for the user while signed in. + /// Calling this method will initiate TOTP setup process and returns a shared secret that can be used to generate QR code. + /// The setup details also contains a URI generator helper that can be used to retireve a TOTP Setup URI. + /// + func setUpTOTP() async throws -> TOTPSetupDetails + + /// Verifies TOTP Setup + /// + /// Invoke this operation to verify TOTP setup for the user while signed in. + /// Calling this method with the verification code from the associated Authenticator app will complete the TOTP setup process. + /// + /// - Parameters: + /// - code: verification code from the associated Authenticator app + /// - options: Parameters specific to plugin behavior + func verifyTOTPSetup( + code: String, + options: VerifyTOTPSetupRequest.Options? + ) async throws + } diff --git a/Amplify/Categories/Auth/Models/AuthSignInStep.swift b/Amplify/Categories/Auth/Models/AuthSignInStep.swift index a7d2b2c5b7..e99fc9adf4 100644 --- a/Amplify/Categories/Auth/Models/AuthSignInStep.swift +++ b/Amplify/Categories/Auth/Models/AuthSignInStep.swift @@ -5,6 +5,9 @@ // SPDX-License-Identifier: Apache-2.0 // +/// Set of allowed MFA types that would be used for continuing sign in during MFA selection step +public typealias AllowedMFATypes = Set + /// Auth SignIn flow steps /// /// @@ -23,6 +26,19 @@ public enum AuthSignInStep { /// case confirmSignInWithNewPassword(AdditionalInfo?) + /// Auth step is TOTP multi factor authentication. + /// + /// Confirmation code for the MFA will be retrieved from the associated Authenticator app + case confirmSignInWithTOTPCode + + /// Auth step is for continuing sign in by setting up TOTP multi factor authentication. + /// + case continueSignInWithTOTPSetup(TOTPSetupDetails) + + /// Auth step is for continuing sign in by selecting multi factor authentication type + /// + case continueSignInWithMFASelection(AllowedMFATypes) + /// Auth step required the user to change their password. /// case resetPassword(AdditionalInfo?) diff --git a/Amplify/Categories/Auth/Models/MFAType.swift b/Amplify/Categories/Auth/Models/MFAType.swift new file mode 100644 index 0000000000..2726503aa1 --- /dev/null +++ b/Amplify/Categories/Auth/Models/MFAType.swift @@ -0,0 +1,15 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public enum MFAType: String { + + /// Short Messaging Service linked with a phone number + case sms + + /// Time-based One Time Password linked with an authenticator app + case totp +} diff --git a/Amplify/Categories/Auth/Models/TOTPSetupDetails.swift b/Amplify/Categories/Auth/Models/TOTPSetupDetails.swift new file mode 100644 index 0000000000..608ddcab77 --- /dev/null +++ b/Amplify/Categories/Auth/Models/TOTPSetupDetails.swift @@ -0,0 +1,42 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public struct TOTPSetupDetails { + + /// Secret code returned by the service to help setting up TOTP + public let sharedSecret: String + + /// username that will be used to construct the URI + public let username: String + + public init(sharedSecret: String, username: String) { + self.sharedSecret = sharedSecret + self.username = username + } + /// Returns a TOTP setup URI that can help the customers avoid barcode scanning and use native password manager to handle TOTP association + /// Example: On iOS and MacOS, URI will redirect to associated Password Manager for the platform + /// + /// throws AuthError.validation if a `URL` cannot be formed with the supplied parameters + /// (for example, if the parameter string contains characters that are illegal in a URL, or is an empty string). + public func getSetupURI( + appName: String, + accountName: String? = nil) throws -> URL { + guard let URL = URL( + string: "otpauth://totp/\(appName):\(accountName ?? username)?secret=\(sharedSecret)&issuer=\(appName)") else { + + throw AuthError.validation( + "appName or accountName", + "Invalid Parameters. Cannot form URL from the supplied appName or accountName", + "Please make sure that the supplied parameters don't contain any characters that are illegal in a URL or is an empty String", + nil) + } + return URL + } + +} diff --git a/Amplify/Categories/Auth/Request/VerifyTOTPSetupRequest.swift b/Amplify/Categories/Auth/Request/VerifyTOTPSetupRequest.swift new file mode 100644 index 0000000000..4a03d3ff21 --- /dev/null +++ b/Amplify/Categories/Auth/Request/VerifyTOTPSetupRequest.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to verify TOTP setup +public struct VerifyTOTPSetupRequest: AmplifyOperationRequest { + + /// Code from the associated Authenticator app that will be used for verification + public var code: String + + /// Extra request options defined in `VerifyTOTPSetupRequest.Options` + public var options: Options + + public init( + code: String, + options: Options) { + self.code = code + self.options = options + } +} + +public extension VerifyTOTPSetupRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+Configure.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+Configure.swift index 31eb09ecaa..99895a0d3b 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+Configure.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+Configure.swift @@ -11,7 +11,6 @@ import Amplify import AWSCognitoIdentity import AWSCognitoIdentityProvider import AWSPluginsCore - import ClientRuntime extension AWSCognitoAuthPlugin { diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+EscapeHatch.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+EscapeHatch.swift index 687f9d048b..37bbb02b0c 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+EscapeHatch.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+EscapeHatch.swift @@ -12,26 +12,6 @@ import AWSCognitoIdentityProvider public extension AWSCognitoAuthPlugin { - func federateToIdentityPool( - withProviderToken: String, - for provider: AuthProvider, - options: AuthFederateToIdentityPoolRequest.Options? = nil - ) async throws -> FederateToIdentityPoolResult { - - let options = options ?? AuthFederateToIdentityPoolRequest.Options() - let request = AuthFederateToIdentityPoolRequest(token: withProviderToken, provider: provider, options: options) - let task = AWSAuthFederateToIdentityPoolTask(request, authStateMachine: authStateMachine) - return try await task.value - - } - - func clearFederationToIdentityPool(options: AuthClearFederationToIdentityPoolRequest.Options? = nil) async throws { - let options = options ?? AuthClearFederationToIdentityPoolRequest.Options() - let request = AuthClearFederationToIdentityPoolRequest(options: options) - let task = AWSAuthClearFederationToIdentityPoolTask(request, authStateMachine: authStateMachine) - try await task.value - } - func getEscapeHatch() -> AWSCognitoAuthService { var service: AWSCognitoAuthService? switch authConfiguration { diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+PluginSpecificAPI.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+PluginSpecificAPI.swift new file mode 100644 index 0000000000..bc5adbfa95 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+PluginSpecificAPI.swift @@ -0,0 +1,55 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import AWSCognitoIdentity +import AWSCognitoIdentityProvider + +public extension AWSCognitoAuthPlugin { + + func federateToIdentityPool( + withProviderToken: String, + for provider: AuthProvider, + options: AuthFederateToIdentityPoolRequest.Options? = nil + ) async throws -> FederateToIdentityPoolResult { + + let options = options ?? AuthFederateToIdentityPoolRequest.Options() + let request = AuthFederateToIdentityPoolRequest(token: withProviderToken, provider: provider, options: options) + let task = AWSAuthFederateToIdentityPoolTask(request, authStateMachine: authStateMachine) + return try await task.value + + } + + func clearFederationToIdentityPool( + options: AuthClearFederationToIdentityPoolRequest.Options? = nil + ) async throws { + let options = options ?? AuthClearFederationToIdentityPoolRequest.Options() + let request = AuthClearFederationToIdentityPoolRequest(options: options) + let task = AWSAuthClearFederationToIdentityPoolTask(request, authStateMachine: authStateMachine) + try await task.value + } + + func fetchMFAPreference() async throws -> UserMFAPreference { + let task = FetchMFAPreferenceTask( + authStateMachine: authStateMachine, + userPoolFactory: authEnvironment.cognitoUserPoolFactory) + return try await task.value + } + + func updateMFAPreference( + sms: MFAPreference?, + totp: MFAPreference? + ) async throws { + let task = UpdateMFAPreferenceTask( + smsPreference: sms, + totpPreference: totp, + authStateMachine: authStateMachine, + userPoolFactory: authEnvironment.cognitoUserPoolFactory) + return try await task.value + } + +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPluginBehavior.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPluginBehavior.swift index 10f619c870..ffc07ce349 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPluginBehavior.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPluginBehavior.swift @@ -34,6 +34,21 @@ protocol AWSCognitoAuthPluginBehavior: AuthCategoryPlugin { /// /// - Parameters: /// - options: Parameters specific to plugin behavior. - func clearFederationToIdentityPool(options: AuthClearFederationToIdentityPoolRequest.Options?) async throws + func clearFederationToIdentityPool( + options: AuthClearFederationToIdentityPoolRequest.Options? + ) async throws + /// Fetches users MFA preferences + /// + func fetchMFAPreference() async throws -> UserMFAPreference + + /// Updates users MFA preferences + /// + /// - Parameters: + /// - sms: The preference that needs to be updated for SMS + /// - totp: The preference that needs to be updated for TOTP + func updateMFAPreference( + sms: MFAPreference?, + totp: MFAPreference? + ) async throws } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/DeviceSRPAuth/InitiateAuthDeviceSRP.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/DeviceSRPAuth/InitiateAuthDeviceSRP.swift index 9894c83fd8..7a89fa6615 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/DeviceSRPAuth/InitiateAuthDeviceSRP.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/DeviceSRPAuth/InitiateAuthDeviceSRP.swift @@ -85,21 +85,12 @@ struct InitiateAuthDeviceSRP: Action { func parseResponse( _ response: SignInResponseBehavior, with stateData: SRPStateData) -> StateMachineEvent { - - if let challengeName = response.challengeName { - switch challengeName { - case .devicePasswordVerifier: - return SignInEvent(eventType: .respondDevicePasswordVerifier(stateData, response)) - default: - let message = "Unsupported challenge response during DeviceSRPAuth \(challengeName)" - let error = SignInError.unknown(message: message) - return SignInEvent(eventType: .throwAuthError(error)) - } - } else { - let message = "Response did not contain challenge info" - let error = SignInError.invalidServiceResponse(message: message) + guard case .devicePasswordVerifier = response.challengeName else { + let message = "Unsupported challenge response during DeviceSRPAuth \(response.challengeName ?? .sdkUnknown("Response did not contain challenge info"))" + let error = SignInError.unknown(message: message) return SignInEvent(eventType: .throwAuthError(error)) } + return SignInEvent(eventType: .respondDevicePasswordVerifier(stateData, response)) } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/CompleteTOTPSetup.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/CompleteTOTPSetup.swift new file mode 100644 index 0000000000..9b33c77138 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/CompleteTOTPSetup.swift @@ -0,0 +1,117 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import AWSCognitoIdentityProvider + +struct CompleteTOTPSetup: Action { + + var identifier: String = "CompleteTOTPSetup" + let userSession: String + let signInEventData: SignInEventData + + func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async { + logVerbose("\(#fileID) Starting execution", environment: environment) + + do { + var deviceMetadata = DeviceMetadata.noData + guard let username = signInEventData.username else { + throw SignInError.unknown(message: "Unable to unwrap username during TOTP verification") + } + let authEnv = try environment.authEnvironment() + let userpoolEnv = try environment.userPoolEnvironment() + let challengeType: CognitoIdentityProviderClientTypes.ChallengeNameType = .mfaSetup + + deviceMetadata = await DeviceMetadataHelper.getDeviceMetadata( + for: username, + with: environment) + + var challengeResponses = [ + "USERNAME": username + ] + let userPoolClientId = userpoolEnv.userPoolConfiguration.clientId + + if let clientSecret = userpoolEnv.userPoolConfiguration.clientSecret { + let clientSecretHash = ClientSecretHelper.clientSecretHash( + username: username, + userPoolClientId: userPoolClientId, + clientSecret: clientSecret + ) + challengeResponses["SECRET_HASH"] = clientSecretHash + } + + if case .metadata(let data) = deviceMetadata { + challengeResponses["DEVICE_KEY"] = data.deviceKey + } + + let asfDeviceId = try await CognitoUserPoolASF.asfDeviceID( + for: username, + credentialStoreClient: authEnv.credentialsClient) + + var userContextData: CognitoIdentityProviderClientTypes.UserContextDataType? + if let encodedData = CognitoUserPoolASF.encodedContext( + username: username, + asfDeviceId: asfDeviceId, + asfClient: userpoolEnv.cognitoUserPoolASFFactory(), + userPoolConfiguration: userpoolEnv.userPoolConfiguration) { + userContextData = .init(encodedData: encodedData) + } + + let analyticsMetadata = userpoolEnv + .cognitoUserPoolAnalyticsHandlerFactory() + .analyticsMetadata() + + let input = RespondToAuthChallengeInput( + analyticsMetadata: analyticsMetadata, + challengeName: challengeType, + challengeResponses: challengeResponses, + clientId: userPoolClientId, + session: userSession, + userContextData: userContextData) + + let responseEvent = try await UserPoolSignInHelper.sendRespondToAuth( + request: input, + for: username, + signInMethod: signInEventData.signInMethod, + environment: userpoolEnv) + logVerbose("\(#fileID) Sending event \(responseEvent)", + environment: environment) + await dispatcher.send(responseEvent) + + } catch let error as SignInError { + logError(error.authError.errorDescription, environment: environment) + let errorEvent = SignInEvent(eventType: .throwAuthError(error)) + logVerbose("\(#fileID) Sending event \(errorEvent)", + environment: environment) + await dispatcher.send(errorEvent) + } catch { + let error = SignInError.service(error: error) + logError(error.authError.errorDescription, environment: environment) + let errorEvent = SignInEvent(eventType: .throwAuthError(error)) + logVerbose("\(#fileID) Sending event \(errorEvent)", + environment: environment) + await dispatcher.send(errorEvent) + } + } + +} + +extension CompleteTOTPSetup: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "identifier": identifier, + "session": userSession.masked(), + "signInEventData": signInEventData.debugDictionary + ] + } +} + +extension CompleteTOTPSetup: CustomDebugStringConvertible { + var debugDescription: String { + debugDictionary.debugDescription + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/InitializeTOTPSetup.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/InitializeTOTPSetup.swift new file mode 100644 index 0000000000..8fd033dae4 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/InitializeTOTPSetup.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +struct InitializeTOTPSetup: Action { + + var identifier: String = "InitializeTOTPSetup" + let authResponse: SignInResponseBehavior + + func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async { + logVerbose("\(#fileID) Start execution", environment: environment) + let event = SetUpTOTPEvent( + id: UUID().uuidString, + eventType: .setUpTOTP(authResponse)) + logVerbose("\(#fileID) Sending event \(event.type)", environment: environment) + await dispatcher.send(event) + } +} + +extension InitializeTOTPSetup: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "identifier": identifier, + "challengeName": authResponse.challengeName?.rawValue ?? "", + "session": authResponse.session?.masked() ?? "", + "challengeParameters": authResponse.challengeParameters ?? [:] + ] + } +} + +extension InitializeTOTPSetup: CustomDebugStringConvertible { + var debugDescription: String { + debugDictionary.debugDescription + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/SetUpTOTP.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/SetUpTOTP.swift new file mode 100644 index 0000000000..ff9f8633ac --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/SetUpTOTP.swift @@ -0,0 +1,80 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import Foundation +import AWSCognitoIdentityProvider + +struct SetUpTOTP: Action { + + var identifier: String = "SetUpTOTP" + let authResponse: SignInResponseBehavior + let signInEventData: SignInEventData + + func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async { + logVerbose("\(#fileID) Starting execution", environment: environment) + + do { + let userpoolEnv = try environment.userPoolEnvironment() + let client = try userpoolEnv.cognitoUserPoolFactory() + let input = AssociateSoftwareTokenInput(session: authResponse.session) + + // Initiate Set Up TOTP + let result = try await client.associateSoftwareToken(input: input) + + guard let username = signInEventData.username else { + throw SignInError.unknown(message: "Unable unwrap username to for use during TOTP setup") + } + + guard let session = result.session, + let secretCode = result.secretCode else { + throw SignInError.unknown(message: "Error unwrapping result associateSoftwareToken result") + } + + let responseEvent = SetUpTOTPEvent(eventType: + .waitForAnswer(.init( + secretCode: secretCode, + session: session, + username: username))) + logVerbose("\(#fileID) Sending event \(responseEvent)", + environment: environment) + await dispatcher.send(responseEvent) + } catch let error as SignInError { + logError(error.authError.errorDescription, environment: environment) + let errorEvent = SetUpTOTPEvent(eventType: .throwError(error)) + logVerbose("\(#fileID) Sending event \(errorEvent)", + environment: environment) + await dispatcher.send(errorEvent) + } catch { + let error = SignInError.service(error: error) + logError(error.authError.errorDescription, environment: environment) + let errorEvent = SetUpTOTPEvent(eventType: .throwError(error)) + logVerbose("\(#fileID) Sending event \(errorEvent)", + environment: environment) + await dispatcher.send(errorEvent) + } + } + +} + +extension SetUpTOTP: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "identifier": identifier, + "challengeName": authResponse.challengeName?.rawValue ?? "", + "session": authResponse.session?.masked() ?? "", + "challengeParameters": authResponse.challengeParameters ?? [:], + "signInEventData": signInEventData.debugDictionary + ] + } +} + +extension SetUpTOTP: CustomDebugStringConvertible { + var debugDescription: String { + debugDictionary.debugDescription + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/VerifyTOTPSetup.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/VerifyTOTPSetup.swift new file mode 100644 index 0000000000..dd1b37450d --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/VerifyTOTPSetup.swift @@ -0,0 +1,75 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import Foundation +import AWSCognitoIdentityProvider + +struct VerifyTOTPSetup: Action { + + var identifier: String = "VerifyTOTPSetup" + let session: String + let totpCode: String + let friendlyDeviceName: String? + + func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async { + logVerbose("\(#fileID) Starting execution", environment: environment) + + do { + let userpoolEnv = try environment.userPoolEnvironment() + let client = try userpoolEnv.cognitoUserPoolFactory() + let input = VerifySoftwareTokenInput( + friendlyDeviceName: friendlyDeviceName, + session: session, + userCode: totpCode) + + // Initiate TOTP verification + let result = try await client.verifySoftwareToken(input: input) + + guard let session = result.session else { + throw SignInError.unknown(message: "Unable to retrieve the session value from VerifySoftwareToken response") + } + + let responseEvent = SetUpTOTPEvent(eventType: + .respondToAuthChallenge(session)) + logVerbose("\(#fileID) Sending event \(responseEvent)", + environment: environment) + await dispatcher.send(responseEvent) + } catch let error as SignInError { + logError(error.authError.errorDescription, environment: environment) + let errorEvent = SetUpTOTPEvent(eventType: .throwError(error)) + logVerbose("\(#fileID) Sending event \(errorEvent)", + environment: environment) + await dispatcher.send(errorEvent) + } catch { + let error = SignInError.service(error: error) + logError(error.authError.errorDescription, environment: environment) + let errorEvent = SetUpTOTPEvent(eventType: .throwError(error)) + logVerbose("\(#fileID) Sending event \(errorEvent)", + environment: environment) + await dispatcher.send(errorEvent) + } + } + +} + +extension VerifyTOTPSetup: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "identifier": identifier, + "session": session.masked(), + "totpCode": totpCode.redacted(), + "friendlyDeviceName": friendlyDeviceName ?? "" + ] + } +} + +extension VerifyTOTPSetup: CustomDebugStringConvertible { + var debugDescription: String { + debugDictionary.debugDescription + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift index a999daedf7..0dc729a532 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift @@ -166,4 +166,31 @@ extension AWSCognitoAuthPlugin: AuthCategoryBehavior { return try await task.value } } + + public func setUpTOTP() async throws -> TOTPSetupDetails { + let task = SetUpTOTPTask( + authStateMachine: authStateMachine, + userPoolFactory: authEnvironment.cognitoUserPoolFactory) + return try await taskQueue.sync { + return try await task.value + } as! TOTPSetupDetails + } + + + public func verifyTOTPSetup( + code: String, + options: VerifyTOTPSetupRequest.Options? + ) async throws { + let options = options ?? VerifyTOTPSetupRequest.Options() + let request = VerifyTOTPSetupRequest( + code: code, + options: options) + let task = VerifyTOTPSetupTask( + request, + authStateMachine: authStateMachine, + userPoolFactory: authEnvironment.cognitoUserPoolFactory) + _ = try await taskQueue.sync { + return try await task.value + } + } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+UserBehavior.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+UserBehavior.swift index cfd6519a50..c8355d5c7c 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+UserBehavior.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+UserBehavior.swift @@ -75,30 +75,7 @@ public extension AWSCognitoAuthPlugin { } func getCurrentUser() async throws -> AuthUser { - - await AWSAuthTaskHelper(authStateMachine: authStateMachine).didStateMachineConfigured() - let authState = await authStateMachine.currentState - - guard case .configured(let authenticationState, _) = authState else { - throw AuthError.configuration( - "Plugin not configured", - AuthPluginErrorConstants.configurationError) - } - - switch authenticationState { - case .notConfigured: - throw AuthError.configuration("UserPool configuration is missing", AuthPluginErrorConstants.configurationError) - case .signedIn(let signInData): - let authUser = AWSAuthUser(username: signInData.username, userId: signInData.userId) - return authUser - case .signedOut, .configured: - throw AuthError.signedOut( - "There is no user signed in to retrieve current user", - "Call Auth.signIn to sign in a user and then call Auth.getCurrentUser", nil) - case .error(let authNError): - throw authNError.authError - default: - throw AuthError.invalidState("Auth State not in a valid state", AuthPluginErrorConstants.invalidStateError, nil) - } + let taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) + return try await taskHelper.getCurrentUser() } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthChallengeType.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthChallengeType.swift index a0c3e874b8..499aa62175 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthChallengeType.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthChallengeType.swift @@ -16,7 +16,13 @@ enum AuthChallengeType { case newPasswordRequired - case unknown + case totpMFA + + case selectMFAType + + case setUpMFA + + case unknown(CognitoIdentityProviderClientTypes.ChallengeNameType) } @@ -29,10 +35,18 @@ extension CognitoIdentityProviderClientTypes.ChallengeNameType { return .newPasswordRequired case .smsMfa: return .smsMfa + case .softwareTokenMfa: + return .totpMFA + case .selectMfaType: + return .selectMFAType + case .mfaSetup: + return .setUpMFA default: - return .unknown + return .unknown(self) } } } extension AuthChallengeType: Codable { } + +extension AuthChallengeType: Equatable { } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFAPreference.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFAPreference.swift new file mode 100644 index 0000000000..e135b06156 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFAPreference.swift @@ -0,0 +1,55 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import AWSCognitoIdentityProvider +import Foundation + +/// Input for updating the MFA preference for a MFA Type +public enum MFAPreference { + + // enabled: false + case disabled + + // enabled: true + case enabled + + // enabled: true, preferred: true + case preferred + + // enabled: true, preferred: false + case notPreferred + +} + +extension MFAPreference { + + var smsSetting: CognitoIdentityProviderClientTypes.SMSMfaSettingsType? { + switch self { + case .enabled: + return .init(enabled: true) + case .preferred: + return .init(enabled: true, preferredMfa: true) + case .notPreferred: + return .init(enabled: true, preferredMfa: false) + case .disabled: + return .init(enabled: false) + } + } + + var softwareTokenSetting: CognitoIdentityProviderClientTypes.SoftwareTokenMfaSettingsType? { + switch self { + case .enabled: + return .init(enabled: true) + case .preferred: + return .init(enabled: true, preferredMfa: true) + case .notPreferred: + return .init(enabled: true, preferredMfa: false) + case .disabled: + return .init(enabled: false) + } + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFATypeExtension.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFATypeExtension.swift new file mode 100644 index 0000000000..afeedeb0c3 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFATypeExtension.swift @@ -0,0 +1,38 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import Foundation + +extension MFAType: DefaultLogger { + + internal init?(rawValue: String) { + if rawValue.caseInsensitiveCompare("SMS_MFA") == .orderedSame { + self = .sms + } else if rawValue.caseInsensitiveCompare("SOFTWARE_TOKEN_MFA") == .orderedSame { + self = .totp + } else { + Self.log.error("Tried to initialize an unsupported MFA type with value: \(rawValue) ") + return nil + } + } + + /// String value of MFA Type + public var rawValue: String { + return challengeResponse + } + + /// String value to be used as an input parameter during MFA selection for confirmSignIn API + public var challengeResponse: String { + switch self { + case .sms: + return "SMS_MFA" + case .totp: + return "SOFTWARE_TOKEN_MFA" + } + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInOptions.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInOptions.swift index 356310be55..ce54b5a908 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInOptions.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInOptions.swift @@ -13,8 +13,15 @@ public struct AWSAuthConfirmSignInOptions { public let metadata: [String: String]? - public init(userAttributes: [AuthUserAttribute]? = nil, metadata: [String: String]? = nil) { - self.userAttributes = userAttributes - self.metadata = metadata - } + /// Device name that would be provided to Cognito when setting up TOTP + public let friendlyDeviceName: String? + + public init( + userAttributes: [AuthUserAttribute]? = nil, + metadata: [String: String]? = nil, + friendlyDeviceName: String? = nil) { + self.userAttributes = userAttributes + self.metadata = metadata + self.friendlyDeviceName = friendlyDeviceName + } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/VerifyTOTPSetupOptions.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/VerifyTOTPSetupOptions.swift new file mode 100644 index 0000000000..58d50a9818 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/VerifyTOTPSetupOptions.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public struct VerifyTOTPSetupOptions { + + /// Device name that would be provided to Cognito when setting up TOTP + public let friendlyDeviceName: String? + + public init(friendlyDeviceName: String? = nil) { + self.friendlyDeviceName = friendlyDeviceName + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/UserMFAPreference.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/UserMFAPreference.swift new file mode 100644 index 0000000000..6f7068cfe8 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/UserMFAPreference.swift @@ -0,0 +1,24 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify + +/// Output for fetching MFA preference +public struct UserMFAPreference { + + /// nil if none enabled + public let enabled: Set? + + /// nil if no preference + public let preferred: MFAType? + + internal init(enabled: Set?, preferred: MFAType?) { + self.enabled = enabled + self.preferred = preferred + } + +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/CognitoUserPoolBehavior.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/CognitoUserPoolBehavior.swift index 4d1121f510..a38b7302f3 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/CognitoUserPoolBehavior.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/CognitoUserPoolBehavior.swift @@ -77,4 +77,16 @@ protocol CognitoUserPoolBehavior { /// Throws ConfirmDeviceOutputError func confirmDevice(input: ConfirmDeviceInput) async throws -> ConfirmDeviceOutputResponse + /// Creates a new request to associate a new software token for the user + /// Throws AssociateSoftwareTokenOutputError + func associateSoftwareToken(input: AssociateSoftwareTokenInput) async throws -> AssociateSoftwareTokenOutputResponse + + /// Register a user's entered time-based one-time password (TOTP) code and mark the user's software token MFA status as "verified" if successful. + /// Throws VerifySoftwareTokenOutputError + func verifySoftwareToken(input: VerifySoftwareTokenInput) async throws -> VerifySoftwareTokenOutputResponse + + /// Set the user's multi-factor authentication (MFA) method preference, including which MFA factors are activated and if any are preferred. + /// Throws SetUserMFAPreferenceOutputError + func setUserMFAPreference(input: SetUserMFAPreferenceInput) async throws -> SetUserMFAPreferenceOutputResponse + } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/AssociateSoftwareTokenOutputError+AuthError.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/AssociateSoftwareTokenOutputError+AuthError.swift new file mode 100644 index 0000000000..4adf7e6a0c --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/AssociateSoftwareTokenOutputError+AuthError.swift @@ -0,0 +1,51 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import AWSCognitoIdentityProvider + +extension AssociateSoftwareTokenOutputError: AuthErrorConvertible { + var authError: AuthError { + switch self { + case .concurrentModificationException(let concurrentModificationException): + return .service( + concurrentModificationException.message ?? "Concurrent modification error", + AuthPluginErrorConstants.concurrentModificationException) + case .forbiddenException(let forbiddenException): + return .service( + forbiddenException.message ?? "Access to the requested resource is forbidden", + AuthPluginErrorConstants.forbiddenError) + case .internalErrorException(let internalErrorException): + return .unknown( + internalErrorException.message ?? "Internal exception occurred") + case .invalidParameterException(let invalidParameterException): + return .service( + invalidParameterException.message ?? "Invalid parameter error", + AuthPluginErrorConstants.invalidParameterError, + AWSCognitoAuthError.invalidParameter) + case .notAuthorizedException(let notAuthorizedException): + return .notAuthorized( + notAuthorizedException.message ?? "Not authorized Error", + AuthPluginErrorConstants.notAuthorizedError, + nil) + case .resourceNotFoundException(let resourceNotFoundException): + return AuthError.service( + resourceNotFoundException.message ?? "Resource not found error", + AuthPluginErrorConstants.resourceNotFoundError, + AWSCognitoAuthError.resourceNotFound) + case .softwareTokenMFANotFoundException(let exception): + return AuthError.service( + exception.message ?? "Software token TOTP multi-factor authentication (MFA) is not enabled for the user pool.", + AuthPluginErrorConstants.softwareTokenNotFoundError, + AWSCognitoAuthError.mfaMethodNotFound) + case .unknown(let unknownAWSHttpServiceError): + let statusCode = unknownAWSHttpServiceError._statusCode?.rawValue ?? -1 + let message = unknownAWSHttpServiceError._message ?? "" + return .unknown("Unknown service error occurred with status \(statusCode) \(message)") + } + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/RespondToAuthChallengeOutputError+AuthError.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/RespondToAuthChallengeOutputError+AuthError.swift index 0e6fd0e2f5..eae169e4e6 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/RespondToAuthChallengeOutputError+AuthError.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/RespondToAuthChallengeOutputError+AuthError.swift @@ -49,11 +49,11 @@ extension RespondToAuthChallengeOutputError: AuthErrorConvertible { AWSCognitoAuthError.invalidParameter) case .invalidSmsRoleAccessPolicyException(let exception): return AuthError.service(exception.message ?? "Invalid SMS Role Access Policy error", - AuthPluginErrorConstants.invalidParameterError, + AuthPluginErrorConstants.invalidSMSRoleError, AWSCognitoAuthError.smsRole) case .invalidSmsRoleTrustRelationshipException(let exception): return AuthError.service(exception.message ?? "Invalid SMS Role Trust Relationship error", - AuthPluginErrorConstants.invalidParameterError, + AuthPluginErrorConstants.invalidSMSRoleError, AWSCognitoAuthError.smsRole) case .invalidUserPoolConfigurationException(let exception): return AuthError.configuration(exception.message ?? "Invalid UserPool Configuration error", diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/SetUserMFAPreferenceOutputError+AuthError.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/SetUserMFAPreferenceOutputError+AuthError.swift new file mode 100644 index 0000000000..0a58116414 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/SetUserMFAPreferenceOutputError+AuthError.swift @@ -0,0 +1,57 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import AWSCognitoIdentityProvider +import Amplify + +extension SetUserMFAPreferenceOutputError: AuthErrorConvertible { + + var authError: AuthError { + switch self { + case .internalErrorException(let exception): + return .unknown( + exception.message ?? "Internal exception occurred") + case .invalidParameterException(let exception): + return AuthError.service( + exception.message ?? "Invalid parameter error", + AuthPluginErrorConstants.invalidParameterError, + AWSCognitoAuthError.invalidParameter) + case .notAuthorizedException(let exception): + return .notAuthorized( + exception.message ?? "Not authorized error", + AuthPluginErrorConstants.notAuthorizedError) + case .passwordResetRequiredException(let exception): + return .service( + exception.message ?? "Password reset required error", + AuthPluginErrorConstants.passwordResetRequired, + AWSCognitoAuthError.passwordResetRequired) + case .resourceNotFoundException(let exception): + return .service( + exception.message ?? "Resource not found error", + AuthPluginErrorConstants.resourceNotFoundError, + AWSCognitoAuthError.resourceNotFound) + case .userNotConfirmedException(let userNotConfirmedException): + return .service( + userNotConfirmedException.message ?? "User not confirmed error", + AuthPluginErrorConstants.userNotConfirmedError, + AWSCognitoAuthError.userNotConfirmed) + case .userNotFoundException(let exception): + return .service( + exception.message ?? "User not found error", + AuthPluginErrorConstants.userNotFoundError, + AWSCognitoAuthError.userNotFound) + case .forbiddenException(let forbiddenException): + return .service( + forbiddenException.message ?? "Access to the requested resource is forbidden", + AuthPluginErrorConstants.forbiddenError) + case .unknown(let serviceError): + let statusCode = serviceError._statusCode?.rawValue ?? -1 + let message = serviceError._message ?? "" + return .unknown("Unknown service error occurred with status \(statusCode) \(message)") + } + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/VerifySoftwareTokenOutputError+AuthError.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/VerifySoftwareTokenOutputError+AuthError.swift new file mode 100644 index 0000000000..d7b56959ad --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/VerifySoftwareTokenOutputError+AuthError.swift @@ -0,0 +1,81 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import AWSCognitoIdentityProvider + +extension VerifySoftwareTokenOutputError: AuthErrorConvertible { + var authError: AuthError { + switch self { + case .codeMismatchException(let exception): + return AuthError.service( + exception.message ?? "Provided code does not match what the server was expecting.", + AuthPluginErrorConstants.codeMismatchError, + AWSCognitoAuthError.codeMismatch) + case .enableSoftwareTokenMFAException(let exception): + return AuthError.service( + exception.message ?? "Unable to enable software token MFA", + AuthPluginErrorConstants.softwareTokenNotFoundError, + AWSCognitoAuthError.softwareTokenMFANotEnabled) + case .forbiddenException(let forbiddenException): + return .service( + forbiddenException.message ?? "Access to the requested resource is forbidden", + AuthPluginErrorConstants.forbiddenError) + case .internalErrorException(let internalErrorException): + return .unknown( + internalErrorException.message ?? "Internal exception occurred") + case .invalidParameterException(let invalidParameterException): + return .service( + invalidParameterException.message ?? "Invalid parameter error", + AuthPluginErrorConstants.invalidParameterError, + AWSCognitoAuthError.invalidParameter) + case .invalidUserPoolConfigurationException(let exception): + return .configuration( + exception.message ?? "Invalid UserPool Configuration error", + AuthPluginErrorConstants.configurationError) + case .notAuthorizedException(let notAuthorizedException): + return .notAuthorized( + notAuthorizedException.message ?? "Not authorized Error", + AuthPluginErrorConstants.notAuthorizedError, + nil) + case .passwordResetRequiredException(let exception): + return AuthError.service( + exception.message ?? "Password reset required error", + AuthPluginErrorConstants.passwordResetRequired, + AWSCognitoAuthError.passwordResetRequired) + case .resourceNotFoundException(let resourceNotFoundException): + return AuthError.service( + resourceNotFoundException.message ?? "Resource not found error", + AuthPluginErrorConstants.resourceNotFoundError, + AWSCognitoAuthError.resourceNotFound) + case .softwareTokenMFANotFoundException(let exception): + return AuthError.service( + exception.message ?? "Software token TOTP multi-factor authentication (MFA) is not enabled for the user pool.", + AuthPluginErrorConstants.softwareTokenNotFoundError, + AWSCognitoAuthError.mfaMethodNotFound) + case .tooManyRequestsException(let exception): + return AuthError.service( + exception.message ?? "Too many requests error", + AuthPluginErrorConstants.tooManyRequestError, + AWSCognitoAuthError.requestLimitExceeded) + case .userNotConfirmedException(let exception): + return AuthError.service( + exception.message ?? "User not confirmed error", + AuthPluginErrorConstants.userNotConfirmedError, + AWSCognitoAuthError.userNotConfirmed) + case .userNotFoundException(let exception): + return AuthError.service( + exception.message ?? "User not found error", + AuthPluginErrorConstants.userNotFoundError, + AWSCognitoAuthError.userNotFound) + case .unknown(let unknownAWSHttpServiceError): + let statusCode = unknownAWSHttpServiceError._statusCode?.rawValue ?? -1 + let message = unknownAWSHttpServiceError._message ?? "" + return .unknown("Unknown service error occurred with status \(statusCode) \(message)") + } + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ConfirmSignInEventData.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ConfirmSignInEventData.swift index f12b729ecc..448339c31c 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ConfirmSignInEventData.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ConfirmSignInEventData.swift @@ -12,6 +12,7 @@ struct ConfirmSignInEventData { let answer: String let attributes: [String: String] let metadata: [String: String]? + let friendlyDeviceName: String? } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/RespondToAuthChallenge.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/RespondToAuthChallenge.swift index 6428c8530b..02adc4211b 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/RespondToAuthChallenge.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/RespondToAuthChallenge.swift @@ -39,6 +39,32 @@ extension RespondToAuthChallenge { attributeKey: nil) } + var getAllowedMFATypesForSelection: Set { + return getMFATypes(forKey: "MFAS_CAN_CHOOSE") + } + + var getAllowedMFATypesForSetup: Set { + return getMFATypes(forKey: "MFAS_CAN_SETUP") + } + + /// Helper method to extract MFA types from parameters + private func getMFATypes(forKey key: String) -> Set { + var mfaTypes = Set() + guard let mfaTypeParametersData = parameters?[key]?.data(using: .utf8), + let mfaTypesArray = try? JSONDecoder().decode( + [String].self, from: mfaTypeParametersData) else { + return mfaTypes + } + + for mfaTypeValue in mfaTypesArray { + if let mfaType = MFAType(rawValue: String(mfaTypeValue)) { + mfaTypes.insert(mfaType) + } + } + + return mfaTypes + } + var debugDictionary: [String: Any] { return ["challenge": challenge, "username": username.masked()] @@ -46,11 +72,12 @@ extension RespondToAuthChallenge { func getChallengeKey() throws -> String { switch challenge { - case .customChallenge: return "ANSWER" + case .customChallenge, .selectMfaType: return "ANSWER" case .smsMfa: return "SMS_MFA_CODE" + case .softwareTokenMfa: return "SOFTWARE_TOKEN_MFA_CODE" case .newPasswordRequired: return "NEW_PASSWORD" default: - let message = "UnSupported challenge response \(challenge)" + let message = "Unsupported challenge type for response key generation \(challenge)" let error = SignInError.unknown(message: message) throw error } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignInTOTPSetupData.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignInTOTPSetupData.swift new file mode 100644 index 0000000000..30ae659725 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignInTOTPSetupData.swift @@ -0,0 +1,30 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify + +struct SignInTOTPSetupData { + + let secretCode: String + let session: String + let username: String + +} + +extension SignInTOTPSetupData: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "sharedSecret": secretCode.redacted(), + "session": session.masked(), + "username": username.masked() + ] + } +} + +extension SignInTOTPSetupData: Codable { } + +extension SignInTOTPSetupData: Equatable { } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SetUpTOTPEvent.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SetUpTOTPEvent.swift new file mode 100644 index 0000000000..d21caecae7 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SetUpTOTPEvent.swift @@ -0,0 +1,71 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Session value created by the service +typealias UserSession = String + +struct SetUpTOTPEvent: StateMachineEvent { + + enum EventType { + + case setUpTOTP(SignInResponseBehavior) + + case waitForAnswer(SignInTOTPSetupData) + + case verifyChallengeAnswer(ConfirmSignInEventData) + + case respondToAuthChallenge(UserSession) + + case verified + + case throwError(SignInError) + + } + + let id: String + let eventType: EventType + let time: Date? + + var type: String { + switch eventType { + case .setUpTOTP: return "SetUpTOTPEvent.setUpTOTP" + case .verified: return "SetUpTOTPEvent.verified" + case .verifyChallengeAnswer: return "SetUpTOTPEvent.verifyChallengeAnswer" + case .waitForAnswer: return "SetUpTOTPEvent.waitForAnswer" + case .respondToAuthChallenge: return "SetUpTOTPEvent.respondToAuthChallenge" + case .throwError: return "SetUpTOTPEvent.throwError" + } + } + + init(id: String = UUID().uuidString, + eventType: EventType, + time: Date? = nil) { + self.id = id + self.eventType = eventType + self.time = time + } +} + +extension SetUpTOTPEvent.EventType: Equatable { + static func == (lhs: SetUpTOTPEvent.EventType, rhs: SetUpTOTPEvent.EventType) -> Bool { + switch (lhs, rhs) { + case (.setUpTOTP, .setUpTOTP), + (.verified, .verified), + (.verifyChallengeAnswer, .verifyChallengeAnswer), + (.waitForAnswer, .waitForAnswer), + (.respondToAuthChallenge, .respondToAuthChallenge), + (.throwError, .throwError): + return true + default: + return false + } + } + + +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInEvent.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInEvent.swift index 731782ec6d..78ef7dfdc5 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInEvent.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInEvent.swift @@ -38,6 +38,8 @@ struct SignInEvent: StateMachineEvent { case respondDevicePasswordVerifier(SRPStateData, SignInResponseBehavior) + case initiateTOTPSetup(Username, SignInResponseBehavior) + case throwPasswordVerifierError(SignInError) case finalizeSignIn(SignedInData) @@ -76,6 +78,7 @@ struct SignInEvent: StateMachineEvent { case .receivedChallenge: return "SignInEvent.receivedChallenge" case .verifySMSChallenge: return "SignInEvent.verifySMSChallenge" case .retryRespondPasswordVerifier: return "SignInEvent.retryRespondPasswordVerifier" + case .initiateTOTPSetup: return "SignInEvent.initiateTOTPSetup" } } @@ -109,7 +112,8 @@ extension SignInEvent.EventType: Equatable { (.throwAuthError, .throwAuthError), (.receivedChallenge, .receivedChallenge), (.verifySMSChallenge, .verifySMSChallenge), - (.retryRespondPasswordVerifier, .retryRespondPasswordVerifier): + (.retryRespondPasswordVerifier, .retryRespondPasswordVerifier), + (.initiateTOTPSetup, .initiateTOTPSetup): return true default: return false } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInState+Debug.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInState+Debug.swift index 627f9e105a..63e91680db 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInState+Debug.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInState+Debug.swift @@ -40,6 +40,10 @@ extension SignInState { additionalMetadataDictionary = ["DeviceSRPState": deviceSRPState.debugDictionary] case .signedIn(let data): additionalMetadataDictionary = ["SignedInData": data.debugDictionary] + case .resolvingTOTPSetup(let signInTOTPSetupState, let signInEventData): + additionalMetadataDictionary = [ + "SignInTOTPSetupState": signInTOTPSetupState.debugDictionary, + "SignInEventData" : signInEventData.debugDictionary] case .error: additionalMetadataDictionary = [:] } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInTOTPSetupState+Debug.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInTOTPSetupState+Debug.swift new file mode 100644 index 0000000000..e071f9a646 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInTOTPSetupState+Debug.swift @@ -0,0 +1,28 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension SignInTOTPSetupState { + + var debugDictionary: [String: Any] { + var additionalMetadataDictionary: [String: Any] = [:] + switch self { + case .waitingForAnswer(let signInTOTPSetupData): + additionalMetadataDictionary = signInTOTPSetupData.debugDictionary + case .verifying(let signInSetupData, let confirmSignInEventData): + additionalMetadataDictionary = confirmSignInEventData.debugDictionary + additionalMetadataDictionary = additionalMetadataDictionary.merging( + signInSetupData.debugDictionary, + uniquingKeysWith: {$1}) + case .error(let error): + additionalMetadataDictionary["error"] = error + default: additionalMetadataDictionary = [:] + } + return [type: additionalMetadataDictionary] + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInState.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInState.swift index 5456f34725..5333ee124f 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInState.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInState.swift @@ -14,6 +14,7 @@ enum SignInState: State { case signingInWithCustom(CustomSignInState, SignInEventData) case signingInViaMigrateAuth(MigrateSignInState, SignInEventData) case resolvingChallenge(SignInChallengeState, AuthChallengeType, SignInMethod) + case resolvingTOTPSetup(SignInTOTPSetupState, SignInEventData) case signingInWithHostedUI(HostedUISignInState) case confirmingDevice case resolvingDeviceSrpa(DeviceSRPState) @@ -32,6 +33,7 @@ extension SignInState { case .signingInWithCustom: return "SignInState.signingInWithCustom" case .signingInViaMigrateAuth: return "SignInState.signingInViaMigrateAuth" case .resolvingChallenge: return "SignInState.resolvingChallenge" + case .resolvingTOTPSetup: return "SignInState.resolvingTOTPSetup" case .confirmingDevice: return "SignInState.confirmingDevice" case .resolvingDeviceSrpa: return "SignInState.resolvingDeviceSrpa" case .signedIn: return "SignInState.signedIn" diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInTOTPSetupState.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInTOTPSetupState.swift new file mode 100644 index 0000000000..151154688b --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInTOTPSetupState.swift @@ -0,0 +1,57 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +enum SignInTOTPSetupState: State { + + case notStarted + + case setUpTOTP + + case waitingForAnswer(SignInTOTPSetupData) + + case verifying(SignInTOTPSetupData, ConfirmSignInEventData) + + case respondingToAuthChallenge + + case success + + case error(SignInTOTPSetupData?, SignInError) +} + +extension SignInTOTPSetupState { + + var type: String { + switch self { + case .notStarted: return "SignInTOTPSetupState.notStarted" + case .setUpTOTP: return "SignInTOTPSetupState.setUpTOTP" + case .waitingForAnswer: return "SignInTOTPSetupState.waitingForAnswer" + case .verifying: return "SignInTOTPSetupState.verifying" + case .respondingToAuthChallenge: return "SignInTOTPSetupState.respondingToAuthChallenge" + case .success: return "SignInTOTPSetupState.success" + case .error: return "SignInTOTPSetupState.error" + } + } +} + +extension SignInTOTPSetupState: Equatable { + static func == (lhs: SignInTOTPSetupState, rhs: SignInTOTPSetupState) -> Bool { + switch (lhs, rhs) { + case (.notStarted, .notStarted), + (.setUpTOTP, .setUpTOTP), + (.waitingForAnswer, .waitingForAnswer), + (.verifying, .verifying), + (.respondingToAuthChallenge, .respondingToAuthChallenge), + (.success, .success), + (.error, .error): + return true + default: return false + } + } + +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/ErrorMapping/SignInError+Helper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/ErrorMapping/SignInError+Helper.swift index a301ce2228..9cec199958 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/ErrorMapping/SignInError+Helper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/ErrorMapping/SignInError+Helper.swift @@ -25,6 +25,12 @@ extension SignInError { case .userNotConfirmedException = internalError { return true } + + if let internalError: VerifySoftwareTokenOutputError = serviceError.internalAWSServiceError(), + case .userNotConfirmedException = internalError { + return true + } + default: break } return false @@ -42,6 +48,11 @@ extension SignInError { case .passwordResetRequiredException = internalError { return true } + + if let internalError: VerifySoftwareTokenOutputError = serviceError.internalAWSServiceError(), + case .passwordResetRequiredException = internalError { + return true + } default: break } return false diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/SignInState+Resolver.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/SignInState+Resolver.swift index 75715f7630..644e9fabc2 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/SignInState+Resolver.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/SignInState+Resolver.swift @@ -98,6 +98,14 @@ extension SignInState { actions: [action]) } + if let signInEvent = event as? SignInEvent, + case .initiateTOTPSetup(_, let challengeResponse) = signInEvent.eventType { + let action = InitializeTOTPSetup( + authResponse: challengeResponse) + return .init(newState: .resolvingTOTPSetup(.notStarted, signInEventData), + actions: [action]) + } + let resolution = SRPSignInState.Resolver().resolve(oldState: srpSignInState, byApplying: event) let signingInWithSRP = SignInState.signingInWithSRP(resolution.newState, @@ -134,6 +142,14 @@ extension SignInState { actions: [action]) } + if let signInEvent = event as? SignInEvent, + case .initiateTOTPSetup(_, let challengeResponse) = signInEvent.eventType { + let action = InitializeTOTPSetup( + authResponse: challengeResponse) + return .init(newState: .resolvingTOTPSetup(.notStarted, signInEventData), + actions: [action]) + } + let resolution = CustomSignInState.Resolver().resolve( oldState: customSignInState, byApplying: event) let signingInWithCustom = SignInState.signingInWithCustom( @@ -170,6 +186,14 @@ extension SignInState { actions: [action]) } + if let signInEvent = event as? SignInEvent, + case .initiateTOTPSetup(_, let challengeResponse) = signInEvent.eventType { + let action = InitializeTOTPSetup( + authResponse: challengeResponse) + return .init(newState: .resolvingTOTPSetup(.notStarted, signInEventData), + actions: [action]) + } + let resolution = MigrateSignInState.Resolver().resolve( oldState: migrateSignInState, byApplying: event) let signingInWithMigration = SignInState.signingInViaMigrateAuth( @@ -207,6 +231,19 @@ extension SignInState { signInMethod), actions: [action]) } + if let signInEvent = event as? SignInEvent, + case .initiateTOTPSetup(let username, let challengeResponse) = signInEvent.eventType { + let action = InitializeTOTPSetup( + authResponse: challengeResponse) + return .init( + newState: .resolvingTOTPSetup( + .notStarted, + .init(username: username, + password: nil, + signInMethod: signInMethod)), + actions: [action]) + } + let resolution = SignInChallengeState.Resolver().resolve( oldState: challengeState, byApplying: event) @@ -238,6 +275,14 @@ extension SignInState { actions: [action]) } + if let signInEvent = event as? SignInEvent, + case .initiateTOTPSetup(_, let challengeResponse) = signInEvent.eventType { + let action = InitializeTOTPSetup( + authResponse: challengeResponse) + return .init(newState: .resolvingTOTPSetup(.notStarted, signInEventData), + actions: [action]) + } + if let signInEvent = event as? SignInEvent, case .confirmDevice(let signedInData) = signInEvent.eventType { let action = ConfirmDevice(signedInData: signedInData) @@ -250,6 +295,53 @@ extension SignInState { let signingInWithSRP = SignInState.signingInWithSRPCustom(resolution.newState, signInEventData) return .init(newState: signingInWithSRP, actions: resolution.actions) + + + case .resolvingTOTPSetup(let setUpTOTPState, let signInEventData): + + if case .finalizeSignIn(let signedInData) = event.isSignInEvent { + return .init(newState: .signedIn(signedInData), + actions: [SignInComplete(signedInData: signedInData)]) + } + + if let signInEvent = event as? SignInEvent, + case .receivedChallenge(let challenge) = signInEvent.eventType { + let action = InitializeResolveChallenge(challenge: challenge, + signInMethod: signInEventData.signInMethod) + let subState = SignInChallengeState.notStarted + return .init(newState: .resolvingChallenge( + subState, + challenge.challenge.authChallengeType, + signInEventData.signInMethod + ), actions: [action]) + } + + if let signInEvent = event as? SignInEvent, + case .confirmDevice(let signedInData) = signInEvent.eventType { + let action = ConfirmDevice(signedInData: signedInData) + return .init(newState: .confirmingDevice, + actions: [action]) + } + + if let signInEvent = event as? SignInEvent, + case .initiateDeviceSRP(let username, let challengeResponse) = signInEvent.eventType { + let action = StartDeviceSRPFlow( + username: username, + authResponse: challengeResponse) + return .init(newState: .resolvingDeviceSrpa(.notStarted), + actions: [action]) + } + + let resolution = SignInTOTPSetupState.Resolver( + signInEventData: signInEventData).resolve( + oldState: setUpTOTPState, + byApplying: event) + let settingUpTOTPState = SignInState.resolvingTOTPSetup( + resolution.newState, + signInEventData) + return .init(newState: settingUpTOTPState, actions: resolution.actions) + + case .resolvingDeviceSrpa(let deviceSrpState): let signInMethod = SignInMethod.apiBased(.userSRP) if let signInEvent = event as? SignInEvent, @@ -263,6 +355,19 @@ extension SignInState { signInMethod), actions: [action]) } + if let signInEvent = event as? SignInEvent, + case .initiateTOTPSetup(let username, let challengeResponse) = signInEvent.eventType { + let action = InitializeTOTPSetup( + authResponse: challengeResponse) + return .init(newState: + .resolvingTOTPSetup( + .notStarted, + .init(username: username, + password: nil, + signInMethod: signInMethod)), + actions: [action]) + } + let resolution = DeviceSRPState.Resolver().resolve(oldState: deviceSrpState, byApplying: event) let resolvingDeviceSrpa = SignInState.resolvingDeviceSrpa(resolution.newState) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/TOTPSetup/SignInTOTPSetupState+Resolver.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/TOTPSetup/SignInTOTPSetupState+Resolver.swift new file mode 100644 index 0000000000..d9ac9e2dee --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/TOTPSetup/SignInTOTPSetupState+Resolver.swift @@ -0,0 +1,145 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension SignInTOTPSetupState { + struct Resolver: StateMachineResolver { + typealias StateType = SignInTOTPSetupState + let defaultState = SignInTOTPSetupState.notStarted + + let signInEventData: SignInEventData + + func resolve( + oldState: SignInTOTPSetupState, + byApplying event: StateMachineEvent + ) -> StateResolution { + + guard let setupTOTPEvent = event as? SetUpTOTPEvent else { + return .from(oldState) + } + + switch oldState { + case .notStarted: + return resolveNotStarted(byApplying: setupTOTPEvent) + case .setUpTOTP: + return resolveSetUpTOTPState(byApplying: setupTOTPEvent) + case .waitingForAnswer(let signInTOTPSetupData): + return resolveWaitForAnswer( + byApplying: setupTOTPEvent, + with: signInTOTPSetupData) + case .verifying(let signInTOTPSetupData, _): + return resolveVerifyingState( + byApplying: setupTOTPEvent, with: signInTOTPSetupData) + case .error(let signInTOTPSetupData, _): + return resolveErrorState(byApplying: setupTOTPEvent, + with: signInTOTPSetupData) + default: + return .from(.notStarted) + } + } + + private func resolveNotStarted( + byApplying signInEvent: SetUpTOTPEvent) -> StateResolution { + switch signInEvent.eventType { + case .setUpTOTP(let authResponse): + let action = SetUpTOTP( + authResponse: authResponse, + signInEventData: signInEventData) + return StateResolution( + newState: SignInTOTPSetupState.setUpTOTP, + actions: [action] + ) + case .throwError(let error): + return .init(newState: .error(nil, error)) + default: + return .from(.notStarted) + } + } + + private func resolveSetUpTOTPState( + byApplying signInEvent: SetUpTOTPEvent) -> StateResolution { + switch signInEvent.eventType { + case .waitForAnswer(let totpSetupResponse): + return StateResolution( + newState: SignInTOTPSetupState.waitingForAnswer(totpSetupResponse), + actions: [] + ) + case .throwError(let error): + return .init(newState: .error(nil, error)) + default: + return .from(.notStarted) + } + } + + private func resolveWaitForAnswer( + byApplying signInEvent: SetUpTOTPEvent, + with signInTOTPSetupData: SignInTOTPSetupData) -> StateResolution { + switch signInEvent.eventType { + case .verifyChallengeAnswer(let confirmSignInEventData): + let action = VerifyTOTPSetup( + session: signInTOTPSetupData.session, + totpCode: confirmSignInEventData.answer, + friendlyDeviceName: confirmSignInEventData.friendlyDeviceName) + return StateResolution( + newState: SignInTOTPSetupState.verifying( + signInTOTPSetupData, + confirmSignInEventData), + actions: [action] + ) + case .throwError(let error): + return .init(newState: .error(signInTOTPSetupData, error)) + default: + return .from(.notStarted) + } + } + + private func resolveVerifyingState( + byApplying signInEvent: SetUpTOTPEvent, + with signInTOTPSetupData: SignInTOTPSetupData) -> StateResolution { + switch signInEvent.eventType { + case .respondToAuthChallenge(let session): + let action = CompleteTOTPSetup( + userSession: session, + signInEventData: signInEventData) + return StateResolution( + newState: SignInTOTPSetupState.respondingToAuthChallenge, + actions: [action] + ) + case .throwError(let error): + return .init(newState: .error(signInTOTPSetupData, error)) + default: + return .from(.notStarted) + } + } + + private func resolveErrorState( + byApplying signInEvent: SetUpTOTPEvent, + with signInTOTPSetupData: SignInTOTPSetupData?) -> StateResolution { + + switch signInEvent.eventType { + case .verifyChallengeAnswer(let confirmSignInEventData): + guard let signInTOTPSetupData = signInTOTPSetupData else { + return .from(.notStarted) + } + let action = VerifyTOTPSetup( + session: signInTOTPSetupData.session, + totpCode: confirmSignInEventData.answer, + friendlyDeviceName: confirmSignInEventData.friendlyDeviceName) + return StateResolution( + newState: SignInTOTPSetupState.verifying( + signInTOTPSetupData, + confirmSignInEventData), + actions: [action] + ) + default: + return .from(.notStarted) + } + } + + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Constants/AuthPluginErrorConstants.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Constants/AuthPluginErrorConstants.swift index 233088b8c4..c43ec28007 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Constants/AuthPluginErrorConstants.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Constants/AuthPluginErrorConstants.swift @@ -214,6 +214,12 @@ extension AuthPluginErrorConstants { "Make sure that a valid challenge response is passed for confirmSignIn" ) + static let confirmSignInMFASelectionResponseError: AuthPluginValidationErrorString = ( + "challengeResponse", + "challengeResponse for MFA selection can only have SMS_MFA or SOFTWARE_TOKEN_MFA.", + "Make sure that a valid challenge response is passed for confirmSignIn. Try using `MFAType.totp.challengeResponse` or `MFAType.sms.challengeResponse` as the challenge response" + ) + static let confirmResetPasswordUsernameError: AuthPluginValidationErrorString = ( "username", "username is required to confirmResetPassword", @@ -332,4 +338,8 @@ extension AuthPluginErrorConstants { Check if you are allowed to make this request based on the web ACL thats associated with your user pool """ + static let concurrentModificationException: RecoverySuggestion = """ + Make sure the requests sent are controlled and concurrent operations are handled properly + """ + } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift index 17a1ad634b..8e23f842bb 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift @@ -43,6 +43,15 @@ struct UserPoolSignInHelper: DefaultLogger { } else if case .resolvingChallenge(let challengeState, let challengeType, _) = signInState, case .waitingForAnswer(let challenge, _) = challengeState { return try validateResult(for: challengeType, with: challenge) + + } else if case .resolvingTOTPSetup(let totpSetupState, _) = signInState, + case .error(_, let signInError) = totpSetupState { + return try validateError(signInError: signInError) + + } else if case .resolvingTOTPSetup(let totpSetupState, _) = signInState, + case .waitingForAnswer(let totpSetupData) = totpSetupState { + return .init(nextStep: .continueSignInWithTOTPSetup( + .init(sharedSecret: totpSetupData.secretCode, username: totpSetupData.username))) } return nil } @@ -54,12 +63,18 @@ struct UserPoolSignInHelper: DefaultLogger { case .smsMfa: let delivery = challenge.codeDeliveryDetails return .init(nextStep: .confirmSignInWithSMSMFACode(delivery, challenge.parameters)) + case .totpMFA: + return .init(nextStep: .confirmSignInWithTOTPCode) case .customChallenge: return .init(nextStep: .confirmSignInWithCustomChallenge(challenge.parameters)) case .newPasswordRequired: return .init(nextStep: .confirmSignInWithNewPassword(challenge.parameters)) - case .unknown: - throw AuthError.unknown("Challenge not supported", nil) + case .selectMFAType: + return .init(nextStep: .continueSignInWithMFASelection(challenge.getAllowedMFATypesForSelection)) + case .setUpMFA: + throw AuthError.unknown("Invalid state flow. setUpMFA is handled internally in `SignInState.resolvingTOTPSetup` state.") + case .unknown(let cognitoChallengeType): + throw AuthError.unknown("Challenge not supported\(cognitoChallengeType)", nil) } } @@ -123,12 +138,21 @@ struct UserPoolSignInHelper: DefaultLogger { parameters: parameters) switch challengeName { - case .smsMfa, .customChallenge, .newPasswordRequired: + case .smsMfa, .customChallenge, .newPasswordRequired, .softwareTokenMfa, .selectMfaType: return SignInEvent(eventType: .receivedChallenge(respondToAuthChallenge)) case .deviceSrpAuth: return SignInEvent(eventType: .initiateDeviceSRP(username, response)) + case .mfaSetup: + let allowedMFATypesForSetup = respondToAuthChallenge.getAllowedMFATypesForSetup + if allowedMFATypesForSetup.contains(.totp) { + return SignInEvent(eventType: .initiateTOTPSetup(username, response)) + } else { + let message = "Cannot initiate MFA setup from available Types: \(allowedMFATypesForSetup)" + let error = SignInError.invalidServiceResponse(message: message) + return SignInEvent(eventType: .throwAuthError(error)) + } default: - let message = "UnSupported challenge response \(challengeName)" + let message = "Unsupported challenge response \(challengeName)" let error = SignInError.unknown(message: message) return SignInEvent(eventType: .throwAuthError(error)) } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInTask.swift index d30194fa54..0ff9daa9f9 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInTask.swift @@ -49,17 +49,32 @@ class AWSAuthConfirmSignInTask: AuthConfirmSignInTask, DefaultLogger { AuthPluginErrorConstants.invalidStateError, nil) guard case .configured(let authNState, _) = await authStateMachine.currentState, - case .signingIn(let signInState) = authNState, - case .resolvingChallenge(let challengeState, _, _) = signInState else { + case .signingIn(let signInState) = authNState else { throw invalidStateError } - switch challengeState { - case .waitingForAnswer, .error: - log.verbose("Sending confirm signIn event: \(challengeState)") - await sendConfirmSignInEvent() - default: - throw invalidStateError + if case .resolvingChallenge(let challengeState, let challengeType, _) = signInState { + + // Validate if request valid MFA selection + if case .selectMFAType = challengeType { + try validateRequestForMFASelection() + } + + switch challengeState { + case .waitingForAnswer, .error: + log.verbose("Sending confirm signIn event: \(challengeState)") + await sendConfirmSignInEvent() + default: + throw invalidStateError + } + } else if case .resolvingTOTPSetup(let resolvingSetupTokenState, _) = signInState { + switch resolvingSetupTokenState { + case .waitingForAnswer, .error: + log.verbose("Sending confirm signIn event: \(resolvingSetupTokenState)") + await sendConfirmTOTPSetupEvent() + default: + throw invalidStateError + } } let stateSequences = await authStateMachine.listen() @@ -94,7 +109,30 @@ class AWSAuthConfirmSignInTask: AuthConfirmSignInTask, DefaultLogger { throw invalidStateError } + func validateRequestForMFASelection() throws { + let challengeResponse = request.challengeResponse + + guard let _ = MFAType(rawValue: challengeResponse) else { + throw AuthError.validation( + AuthPluginErrorConstants.confirmSignInMFASelectionResponseError.field, + AuthPluginErrorConstants.confirmSignInMFASelectionResponseError.errorDescription, + AuthPluginErrorConstants.confirmSignInMFASelectionResponseError.recoverySuggestion) + } + } + func sendConfirmSignInEvent() async { + let event = SignInChallengeEvent( + eventType: .verifyChallengeAnswer(createConfirmSignInEventData())) + await authStateMachine.send(event) + } + + func sendConfirmTOTPSetupEvent() async { + let event = SetUpTOTPEvent( + eventType: .verifyChallengeAnswer(createConfirmSignInEventData())) + await authStateMachine.send(event) + } + + private func createConfirmSignInEventData() -> ConfirmSignInEventData { let pluginOptions = (request.options.pluginOptions as? AWSAuthConfirmSignInOptions) // Convert the attributes to [String: String] @@ -103,13 +141,11 @@ class AWSAuthConfirmSignInTask: AuthConfirmSignInTask, DefaultLogger { into: [String: String]()) { $0[attributePrefix + $1.key.rawValue] = $1.value } ?? [:] - let confirmSignInData = ConfirmSignInEventData( + return ConfirmSignInEventData( answer: self.request.challengeResponse, attributes: attributes, - metadata: pluginOptions?.metadata) - let event = SignInChallengeEvent( - eventType: .verifyChallengeAnswer(confirmSignInData)) - await authStateMachine.send(event) + metadata: pluginOptions?.metadata, + friendlyDeviceName: pluginOptions?.friendlyDeviceName) } public static var log: Logger { diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/FetchMFAPreferenceTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/FetchMFAPreferenceTask.swift new file mode 100644 index 0000000000..008b3647c1 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/FetchMFAPreferenceTask.swift @@ -0,0 +1,84 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Amplify +import AWSPluginsCore +import ClientRuntime +import AWSCognitoIdentityProvider + +protocol AuthFetchMFAPreferenceTask: AmplifyAuthTask where Request == Never, + Success == UserMFAPreference, + Failure == AuthError {} + +public extension HubPayload.EventName.Auth { + /// eventName for HubPayloads emitted by this operation + static let fetchMFAPreferenceAPI = "Auth.fetchMFAPreferenceAPI" +} + +class FetchMFAPreferenceTask: AuthFetchMFAPreferenceTask, DefaultLogger { + + typealias CognitoUserPoolFactory = () throws -> CognitoUserPoolBehavior + + private let authStateMachine: AuthStateMachine + private let userPoolFactory: CognitoUserPoolFactory + private let taskHelper: AWSAuthTaskHelper + + var eventName: HubPayloadEventName { + HubPayload.EventName.Auth.fetchMFAPreferenceAPI + } + + init(authStateMachine: AuthStateMachine, + userPoolFactory: @escaping CognitoUserPoolFactory) { + self.authStateMachine = authStateMachine + self.userPoolFactory = userPoolFactory + self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) + } + + func execute() async throws -> UserMFAPreference { + do { + await taskHelper.didStateMachineConfigured() + let accessToken = try await taskHelper.getAccessToken() + return try await fetchMFAPreference(with: accessToken) + } catch let error as AuthErrorConvertible { + throw error.authError + } catch let error as AuthError { + throw error + } catch let error { + throw AuthError.unknown("Unable to execute auth task", error) + } + } + + func fetchMFAPreference(with accessToken: String) async throws -> UserMFAPreference { + let userPoolService = try userPoolFactory() + let input = GetUserInput(accessToken: accessToken) + let result = try await userPoolService.getUser(input: input) + + var enabledList: Set? = nil + var preferred: MFAType? = nil + + for mfaValue in result.userMFASettingList ?? [] { + + guard let mfaType = MFAType(rawValue: mfaValue) else { + continue + } + + if enabledList == nil { + enabledList = Set() + enabledList?.insert(mfaType) + } else { + enabledList?.insert(mfaType) + } + } + + if let preference = result.preferredMfaSetting { + preferred = MFAType(rawValue: preference) + } + + return .init(enabled: enabledList, preferred: preferred) + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Helpers/AWSAuthTaskHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Helpers/AWSAuthTaskHelper.swift index dda21f4875..5c9dbeaeae 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Helpers/AWSAuthTaskHelper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Helpers/AWSAuthTaskHelper.swift @@ -82,6 +82,33 @@ class AWSAuthTaskHelper: DefaultLogger { } } + func getCurrentUser() async throws -> AuthUser { + await didStateMachineConfigured() + let authState = await authStateMachine.currentState + + guard case .configured(let authenticationState, _) = authState else { + throw AuthError.configuration( + "Plugin not configured", + AuthPluginErrorConstants.configurationError) + } + + switch authenticationState { + case .notConfigured: + throw AuthError.configuration("UserPool configuration is missing", AuthPluginErrorConstants.configurationError) + case .signedIn(let signInData): + let authUser = AWSAuthUser(username: signInData.username, userId: signInData.userId) + return authUser + case .signedOut, .configured: + throw AuthError.signedOut( + "There is no user signed in to retrieve current user", + "Call Auth.signIn to sign in a user and then call Auth.getCurrentUser", nil) + case .error(let authNError): + throw authNError.authError + default: + throw AuthError.invalidState("Auth State not in a valid state", AuthPluginErrorConstants.invalidStateError, nil) + } + } + public static var log: Logger { Amplify.Logging.logger(forCategory: CategoryType.auth.displayName, forNamespace: String(describing: self)) } @@ -89,4 +116,5 @@ class AWSAuthTaskHelper: DefaultLogger { public var log: Logger { Self.log } + } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthDeleteUserTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthDeleteUserTask.swift index b9f36d687b..942873e618 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthDeleteUserTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthDeleteUserTask.swift @@ -7,7 +7,7 @@ import Foundation import Amplify -protocol AuthDeleteUserTask: AmplifyAuthTask where Request == Void, Success == Void, Failure == AuthError { } +protocol AuthDeleteUserTask: AmplifyAuthTask where Request == Never, Success == Void, Failure == AuthError { } public extension HubPayload.EventName.Auth { diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthSetUpTOTPTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthSetUpTOTPTask.swift new file mode 100644 index 0000000000..9a71406bbe --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthSetUpTOTPTask.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Amplify + +protocol AuthSetUpTOTPTask: AmplifyAuthTask where Request == Never, Success == TOTPSetupDetails, Failure == AuthError {} + +public extension HubPayload.EventName.Auth { + + /// eventName for HubPayloads emitted by this operation + static let setUpTOTPAPI = "Auth.setUpTOTPAPI" +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthVerifyTOTPSetupTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthVerifyTOTPSetupTask.swift new file mode 100644 index 0000000000..f0b54d5bcb --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthVerifyTOTPSetupTask.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Amplify + +protocol AuthVerifyTOTPSetupTask: AmplifyAuthTask where Request == VerifyTOTPSetupRequest, Success == Void, Failure == AuthError {} + +public extension HubPayload.EventName.Auth { + + /// eventName for HubPayloads emitted by this operation + static let verifyTOTPSetupAPI = "Auth.verifyTOTPSetupAPI" +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/SetUpTOTPTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/SetUpTOTPTask.swift new file mode 100644 index 0000000000..78d90b3b6b --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/SetUpTOTPTask.swift @@ -0,0 +1,73 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Amplify +import AWSPluginsCore +import ClientRuntime +import AWSCognitoIdentityProvider + +class SetUpTOTPTask: AuthSetUpTOTPTask, DefaultLogger { + + typealias CognitoUserPoolFactory = () throws -> CognitoUserPoolBehavior + + private let authStateMachine: AuthStateMachine + private let userPoolFactory: CognitoUserPoolFactory + private let taskHelper: AWSAuthTaskHelper + + var eventName: HubPayloadEventName { + HubPayload.EventName.Auth.setUpTOTPAPI + } + + init(authStateMachine: AuthStateMachine, + userPoolFactory: @escaping CognitoUserPoolFactory) { + self.authStateMachine = authStateMachine + self.userPoolFactory = userPoolFactory + self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) + } + + func execute() async throws -> TOTPSetupDetails { + do { + await taskHelper.didStateMachineConfigured() + let accessToken = try await taskHelper.getAccessToken() + return try await setUpTOTP(with: accessToken) + } catch let error as AuthErrorConvertible { + throw error.authError + } catch let error as AuthError { + throw error + } catch let error { + throw AuthError.unknown("Unable to execute auth task", error) + } + } + + func setUpTOTP(with accessToken: String) async throws -> TOTPSetupDetails { + let userPoolService = try userPoolFactory() + let input = AssociateSoftwareTokenInput(accessToken: accessToken) + let result = try await userPoolService.associateSoftwareToken(input: input) + + guard let secretCode = result.secretCode else { + throw AuthError.service("Secret code cannot be retrieved", "") + } + + // Get the current user for passing in the result, so that TOTP URI could constructed + let authUser: AuthUser + + let currentState = await authStateMachine.currentState + if case .configured(let authNState, _) = currentState, + case .signedIn(let signInData) = authNState { + authUser = AWSAuthUser(username: signInData.username, userId: signInData.userId) + } else { + throw AuthError.invalidState( + "Auth State not in a valid state for the user", + AuthPluginErrorConstants.invalidStateError, + nil) + } + return .init(sharedSecret: secretCode, + username: authUser.username) + + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/UpdateMFAPreferenceTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/UpdateMFAPreferenceTask.swift new file mode 100644 index 0000000000..1e3b24882c --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/UpdateMFAPreferenceTask.swift @@ -0,0 +1,70 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Amplify +import AWSPluginsCore +import ClientRuntime +import AWSCognitoIdentityProvider + +protocol AuthUpdateMFAPreferenceTask: AmplifyAuthTask where Request == Never, + Success == Void, + Failure == AuthError {} + +public extension HubPayload.EventName.Auth { + /// eventName for HubPayloads emitted by this operation + static let updateMFAPreferenceAPI = "Auth.updateMFAPreferenceAPI" +} + +class UpdateMFAPreferenceTask: AuthUpdateMFAPreferenceTask, DefaultLogger { + + typealias CognitoUserPoolFactory = () throws -> CognitoUserPoolBehavior + + private let smsPreference: MFAPreference? + private let totpPreference: MFAPreference? + private let authStateMachine: AuthStateMachine + private let userPoolFactory: CognitoUserPoolFactory + private let taskHelper: AWSAuthTaskHelper + + var eventName: HubPayloadEventName { + HubPayload.EventName.Auth.updateMFAPreferenceAPI + } + + init(smsPreference: MFAPreference?, + totpPreference: MFAPreference?, + authStateMachine: AuthStateMachine, + userPoolFactory: @escaping CognitoUserPoolFactory) { + self.smsPreference = smsPreference + self.totpPreference = totpPreference + self.authStateMachine = authStateMachine + self.userPoolFactory = userPoolFactory + self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) + } + + func execute() async throws { + do { + await taskHelper.didStateMachineConfigured() + let accessToken = try await taskHelper.getAccessToken() + return try await updateMFAPreference(with: accessToken) + } catch let error as AuthErrorConvertible { + throw error.authError + } catch let error as AuthError { + throw error + } catch let error { + throw AuthError.unknown("Unable to execute auth task", error) + } + } + + func updateMFAPreference(with accessToken: String) async throws { + let userPoolService = try userPoolFactory() + let input = SetUserMFAPreferenceInput( + accessToken: accessToken, + smsMfaSettings: smsPreference?.smsSetting, + softwareTokenMfaSettings: totpPreference?.softwareTokenSetting) + _ = try await userPoolService.setUserMFAPreference(input: input) + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/VerifyTOTPSetupTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/VerifyTOTPSetupTask.swift new file mode 100644 index 0000000000..38d49134b4 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/VerifyTOTPSetupTask.swift @@ -0,0 +1,77 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Amplify +import AWSPluginsCore +import ClientRuntime +import AWSCognitoIdentityProvider + +class VerifyTOTPSetupTask: AuthVerifyTOTPSetupTask, DefaultLogger { + + typealias CognitoUserPoolFactory = () throws -> CognitoUserPoolBehavior + + private let request: VerifyTOTPSetupRequest + private let authStateMachine: AuthStateMachine + private let userPoolFactory: CognitoUserPoolFactory + private let taskHelper: AWSAuthTaskHelper + + var eventName: HubPayloadEventName { + HubPayload.EventName.Auth.verifyTOTPSetupAPI + } + + init(_ request: VerifyTOTPSetupRequest, + authStateMachine: AuthStateMachine, + userPoolFactory: @escaping CognitoUserPoolFactory) { + self.request = request + self.authStateMachine = authStateMachine + self.userPoolFactory = userPoolFactory + self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) + } + + func execute() async throws { + do { + await taskHelper.didStateMachineConfigured() + let accessToken = try await taskHelper.getAccessToken() + try await verifyTOTPSetup( + with: accessToken, userCode: request.code) + } catch let error as AuthErrorConvertible { + throw error.authError + } catch let error as AuthError { + throw error + } catch let error { + throw AuthError.unknown("Unable to execute auth task", error) + } + } + + func verifyTOTPSetup(with accessToken: String, userCode: String) async throws { + let userPoolService = try userPoolFactory() + let friendlyDeviceName = (request.options.pluginOptions as? VerifyTOTPSetupOptions)?.friendlyDeviceName + let input = VerifySoftwareTokenInput( + accessToken: accessToken, + friendlyDeviceName: friendlyDeviceName, + userCode: userCode) + let result = try await userPoolService.verifySoftwareToken(input: input) + + guard let output = result.status else { + throw AuthError.service("Verify TOTP Result cannot be retrieved", AmplifyErrorMessages.shouldNotHappenReportBugToAWS()) + } + + switch output { + case .error: + throw AuthError.service("Unknown service error occurred", + AmplifyErrorMessages.reportBugToAWS()) + case .success: + return + case .sdkUnknown(let error): + throw AuthError.service( + error, + AmplifyErrorMessages.reportBugToAWS()) + } + + } +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/VerifySignInChallenge/VerifySignInChallengeTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/VerifySignInChallenge/VerifySignInChallengeTests.swift index dc39645570..b0d5dba2c0 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/VerifySignInChallenge/VerifySignInChallengeTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/VerifySignInChallenge/VerifySignInChallengeTests.swift @@ -18,9 +18,11 @@ class VerifySignInChallengeTests: XCTestCase { username: "usernameMock", session: "mockSession", parameters: [:]) - let mockConfirmEvent = ConfirmSignInEventData(answer: "1233", - attributes: [:], - metadata: [:]) + let mockConfirmEvent = ConfirmSignInEventData( + answer: "1233", + attributes: [:], + metadata: [:], + friendlyDeviceName: nil) /// Test if valid input are given the service call is made /// diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/HubEventTests/AuthHubEventHandlerTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/HubEventTests/AuthHubEventHandlerTests.swift index ee09998510..097407401d 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/HubEventTests/AuthHubEventHandlerTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/HubEventTests/AuthHubEventHandlerTests.swift @@ -314,7 +314,7 @@ class AuthHubEventHandlerTests: XCTestCase { private func configurePluginForConfirmSignInEvent() { let initialState = AuthState.configured( AuthenticationState.signingIn(.resolvingChallenge( - .waitingForAnswer(.testData, .apiBased(.userSRP)), + .waitingForAnswer(.testData(), .apiBased(.userSRP)), .smsMfa, .apiBased(.userSRP))), AuthorizationState.sessionEstablished(.testData)) diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SRPSignInState/SRPTestData.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SRPSignInState/SRPTestData.swift index e9cee893f9..b4f3ea5bdc 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SRPSignInState/SRPTestData.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SRPSignInState/SRPTestData.swift @@ -107,13 +107,32 @@ extension RespondToAuthChallengeOutputResponse { challengeParameters: [:], session: "session") } + + static func testData( + challenge: CognitoIdentityProviderClientTypes.ChallengeNameType = .smsMfa, + challengeParameters: [String: String] = [:]) -> RespondToAuthChallengeOutputResponse { + return RespondToAuthChallengeOutputResponse( + authenticationResult: nil, + challengeName: challenge, + challengeParameters: challengeParameters, + session: "session") + } + } extension RespondToAuthChallenge { - static let testData = RespondToAuthChallenge(challenge: .smsMfa, - username: "username", - session: "session", - parameters: [:]) + + static func testData( + challenge: CognitoIdentityProviderClientTypes.ChallengeNameType = .smsMfa, + username: String = "username", + session: String = "session", + parameters: [String: String] = [:]) -> RespondToAuthChallenge { + RespondToAuthChallenge( + challenge: challenge, + username: username, + session: session, + parameters: parameters) + } } extension SignInEvent { diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/DefaultConfig.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/DefaultConfig.swift index d7da779ccd..1c7505fbec 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/DefaultConfig.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/DefaultConfig.swift @@ -30,7 +30,7 @@ enum Defaults { "UserAgent": "aws-amplify/cli", "Version": "0.1.0", "IdentityManager": [ - "Default": [] + "Default": [String: String]() ], "CredentialsProvider": [ "CognitoIdentity": [ diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/MockIdentityProvider.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/MockIdentityProvider.swift index 92cc78d1e3..ba6b25f2a9 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/MockIdentityProvider.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/MockIdentityProvider.swift @@ -68,6 +68,15 @@ struct MockIdentityProvider: CognitoUserPoolBehavior { typealias MockConfirmDeviceResponse = (ConfirmDeviceInput) async throws -> ConfirmDeviceOutputResponse + typealias MockSetUserMFAPreferenceResponse = (SetUserMFAPreferenceInput) async throws + -> SetUserMFAPreferenceOutputResponse + + typealias MockAssociateSoftwareTokenResponse = (AssociateSoftwareTokenInput) async throws + -> AssociateSoftwareTokenOutputResponse + + typealias MockVerifySoftwareTokenResponse = (VerifySoftwareTokenInput) async throws + -> VerifySoftwareTokenOutputResponse + let mockSignUpResponse: MockSignUpResponse? let mockRevokeTokenResponse: MockRevokeTokenResponse? let mockInitiateAuthResponse: MockInitiateAuthResponse? @@ -87,6 +96,9 @@ struct MockIdentityProvider: CognitoUserPoolBehavior { let mockRememberDeviceResponse: MockRememberDeviceResponse? let mockForgetDeviceResponse: MockForgetDeviceResponse? let mockConfirmDeviceResponse: MockConfirmDeviceResponse? + let mockSetUserMFAPreferenceResponse: MockSetUserMFAPreferenceResponse? + let mockAssociateSoftwareTokenResponse: MockAssociateSoftwareTokenResponse? + let mockVerifySoftwareTokenResponse: MockVerifySoftwareTokenResponse? init( mockSignUpResponse: MockSignUpResponse? = nil, @@ -107,7 +119,10 @@ struct MockIdentityProvider: CognitoUserPoolBehavior { mockListDevicesOutputResponse: MockListDevicesOutputResponse? = nil, mockRememberDeviceResponse: MockRememberDeviceResponse? = nil, mockForgetDeviceResponse: MockForgetDeviceResponse? = nil, - mockConfirmDeviceResponse: MockConfirmDeviceResponse? = nil + mockConfirmDeviceResponse: MockConfirmDeviceResponse? = nil, + mockSetUserMFAPreferenceResponse: MockSetUserMFAPreferenceResponse? = nil, + mockAssociateSoftwareTokenResponse: MockAssociateSoftwareTokenResponse? = nil, + mockVerifySoftwareTokenResponse: MockVerifySoftwareTokenResponse? = nil ) { self.mockSignUpResponse = mockSignUpResponse self.mockRevokeTokenResponse = mockRevokeTokenResponse @@ -128,6 +143,9 @@ struct MockIdentityProvider: CognitoUserPoolBehavior { self.mockRememberDeviceResponse = mockRememberDeviceResponse self.mockForgetDeviceResponse = mockForgetDeviceResponse self.mockConfirmDeviceResponse = mockConfirmDeviceResponse + self.mockSetUserMFAPreferenceResponse = mockSetUserMFAPreferenceResponse + self.mockAssociateSoftwareTokenResponse = mockAssociateSoftwareTokenResponse + self.mockVerifySoftwareTokenResponse = mockVerifySoftwareTokenResponse } /// Throws InitiateAuthOutputError @@ -213,4 +231,16 @@ struct MockIdentityProvider: CognitoUserPoolBehavior { func confirmDevice(input: ConfirmDeviceInput) async throws -> ConfirmDeviceOutputResponse { return try await mockConfirmDeviceResponse!(input) } + + func associateSoftwareToken(input: AWSCognitoIdentityProvider.AssociateSoftwareTokenInput) async throws -> AWSCognitoIdentityProvider.AssociateSoftwareTokenOutputResponse { + return try await mockAssociateSoftwareTokenResponse!(input) + } + + func verifySoftwareToken(input: AWSCognitoIdentityProvider.VerifySoftwareTokenInput) async throws -> AWSCognitoIdentityProvider.VerifySoftwareTokenOutputResponse { + return try await mockVerifySoftwareTokenResponse!(input) + } + + func setUserMFAPreference(input: SetUserMFAPreferenceInput) async throws -> SetUserMFAPreferenceOutputResponse { + return try await mockSetUserMFAPreferenceResponse!(input) + } } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFetchSignInSessionOperationTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFetchSignInSessionOperationTests.swift index f70d3b81bd..51e0e00d86 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFetchSignInSessionOperationTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFetchSignInSessionOperationTests.swift @@ -704,7 +704,7 @@ class AWSAuthFetchSignInSessionOperationTests: BaseAuthorizationTests { /// func testSessionWhenWaitingConfirmSignIn() async throws { let signInMethod = SignInMethod.apiBased(.userSRP) - let challenge = SignInChallengeState.waitingForAnswer(.testData, signInMethod) + let challenge = SignInChallengeState.waitingForAnswer(.testData(), signInMethod) let initialState = AuthState.configured( AuthenticationState.signingIn( .resolvingChallenge(challenge, .smsMfa, signInMethod)), diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AWSAuthSignOutTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthSignOutTaskTests.swift similarity index 100% rename from AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AWSAuthSignOutTaskTests.swift rename to AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthSignOutTaskTests.swift diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthenticationProviderDeleteUserTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AuthenticationProviderDeleteUserTests.swift similarity index 100% rename from AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthenticationProviderDeleteUserTests.swift rename to AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AuthenticationProviderDeleteUserTests.swift diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/ClientBehaviorConfirmResetPasswordTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/ClientBehaviorConfirmResetPasswordTests.swift index c14afa5b88..c518c7ab0a 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/ClientBehaviorConfirmResetPasswordTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/ClientBehaviorConfirmResetPasswordTests.swift @@ -54,7 +54,7 @@ class ClientBehaviorConfirmResetPasswordTests: AWSCognitoAuthClientBehaviorTests /// Test a successful confirmResetPassword call /// - /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response /// - When: /// - I invoke confirmSignup with a valid username, a new password and a confirmation code /// - Then: @@ -71,7 +71,7 @@ class ClientBehaviorConfirmResetPasswordTests: AWSCognitoAuthClientBehaviorTests /// Test a confirmResetPassword call with empty username /// - /// - Given: an auth plugin with mocked service. Mocked service should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response /// - When: /// - I invoke confirmResetPassword with an empty username, a new password and a confirmation code /// - Then: @@ -97,7 +97,7 @@ class ClientBehaviorConfirmResetPasswordTests: AWSCognitoAuthClientBehaviorTests /// Test a confirmResetPassword call with plugin options /// - /// - Given: an auth plugin with mocked service. Mocked service should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response /// - When: /// - I invoke confirmResetPassword with an empty username, a new password and a confirmation code /// - Then: @@ -121,7 +121,7 @@ class ClientBehaviorConfirmResetPasswordTests: AWSCognitoAuthClientBehaviorTests /// Test a confirmResetPassword call with empty new password /// - /// - Given: an auth plugin with mocked service. Mocked service should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response /// - When: /// - I invoke confirmResetPassword with a valid username, an empty new password and a confirmation code /// - Then: diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/ClientBehaviorResetPasswordTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/ClientBehaviorResetPasswordTests.swift index 9ef8fe78ed..59e440aa9e 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/ClientBehaviorResetPasswordTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/ClientBehaviorResetPasswordTests.swift @@ -53,7 +53,7 @@ class ClientBehaviorResetPasswordTests: AWSCognitoAuthClientBehaviorTests { /// Test a successful resetPassword call /// - /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response /// - When: /// - I invoke resetPassword with username /// - Then: @@ -73,7 +73,7 @@ class ClientBehaviorResetPasswordTests: AWSCognitoAuthClientBehaviorTests { /// Test a resetPassword call with nil UserCodeDeliveryDetails /// - /// - Given: an auth plugin with mocked service. Mocked service should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response /// - When: /// - I invoke resetPassword with username /// - Then: @@ -100,7 +100,7 @@ class ClientBehaviorResetPasswordTests: AWSCognitoAuthClientBehaviorTests { /// Test a resetPassword call with empty username /// - /// - Given: an auth plugin with mocked service. Mocked service should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response /// - When: /// - I invoke resetPassword with empty username /// - Then: diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/MFA/FetchMFAPreferenceTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/MFA/FetchMFAPreferenceTaskTests.swift new file mode 100644 index 0000000000..11a7b322a9 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/MFA/FetchMFAPreferenceTaskTests.swift @@ -0,0 +1,409 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +import XCTest +import Amplify +@testable import AWSCognitoAuthPlugin +import AWSCognitoIdentityProvider + +// swiftlint:disable type_body_length +// swiftlint:disable file_length +class FetchMFAPreferenceTaskTests: BasePluginTest { + + /// Test a successful fetchMFAPreference call + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get a successful result + /// + func testSuccessfulPreferenceFetchWithTOTPPreferred() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockGetUserAttributeResponse: { request in + return .init( + preferredMfaSetting: "SOFTWARE_TOKEN_MFA", + userMFASettingList: ["SOFTWARE_TOKEN_MFA", "SMS_MFA"] + ) + }) + + do { + let fetchMFAPreferenceResult = try await plugin.fetchMFAPreference() + XCTAssertEqual(fetchMFAPreferenceResult.enabled, [.totp, .sms]) + XCTAssertEqual(fetchMFAPreferenceResult.preferred, .totp) + } catch { + XCTFail("Received failure with error \(error)") + } + } + + /// Test a successful fetchMFAPreference call + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get a successful result + /// + func testSuccessfulPreferenceFetchWithSMSPreferred() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockGetUserAttributeResponse: { request in + return .init( + preferredMfaSetting: "SMS_MFA", + userMFASettingList: ["SOFTWARE_TOKEN_MFA", "SMS_MFA"] + ) + }) + + do { + let fetchMFAPreferenceResult = try await plugin.fetchMFAPreference() + XCTAssertEqual(fetchMFAPreferenceResult.enabled, [.totp, .sms]) + XCTAssertEqual(fetchMFAPreferenceResult.preferred, .sms) + } catch { + XCTFail("Received failure with error \(error)") + } + } + + /// Test a successful fetchMFAPreference call + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get a successful result + /// + func testSuccessfulPreferenceFetchWithNonePreferred() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockGetUserAttributeResponse: { request in + return .init( + userMFASettingList: ["SOFTWARE_TOKEN_MFA", "SMS_MFA"] + ) + }) + + do { + let fetchMFAPreferenceResult = try await plugin.fetchMFAPreference() + XCTAssertEqual(fetchMFAPreferenceResult.enabled, [.totp, .sms]) + XCTAssertNil(fetchMFAPreferenceResult.preferred) + } catch { + XCTFail("Received failure with error \(error)") + } + } + + /// Test a successful fetchMFAPreference call + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response with an invalid MFA type string + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get a successful result + /// + func testInvalidResponseForUserMFASettingsList() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockGetUserAttributeResponse: { request in + return .init( + userMFASettingList: ["DUMMY"] + ) + }) + + do { + let fetchMFAPreferenceResult = try await plugin.fetchMFAPreference() + XCTAssertNil(fetchMFAPreferenceResult.enabled) + XCTAssertNil(fetchMFAPreferenceResult.preferred) + } catch { + XCTFail("Received failure with error \(error)") + } + } + + /// Test a successful fetchMFAPreference call + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response with an invalid MFA type string + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get a successful result + /// + func testInvalidResponseForUserMFAPreference() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockGetUserAttributeResponse: { request in + return .init( + preferredMfaSetting: "DUMMY", + userMFASettingList: ["SOFTWARE_TOKEN_MFA", "SMS_MFA"] + ) + }) + + do { + let fetchMFAPreferenceResult = try await plugin.fetchMFAPreference() + XCTAssertNotNil(fetchMFAPreferenceResult.enabled) + XCTAssertNil(fetchMFAPreferenceResult.preferred) + } catch { + XCTFail("Received failure with error \(error)") + } + } + + /// Test a successful fetchMFAPreference call + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get a successful result + /// + func testSuccessfulPreferenceFetchWithNonePreferredAndNoneEnabled() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockGetUserAttributeResponse: { request in + return .init() + }) + + do { + let fetchMFAPreferenceResult = try await plugin.fetchMFAPreference() + XCTAssertNil(fetchMFAPreferenceResult.enabled) + XCTAssertNil(fetchMFAPreferenceResult.preferred) + } catch { + XCTFail("Received failure with error \(error)") + } + } + + // MARK: Service error handling test + + /// Test a fetchMFAPreference call with InternalErrorException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a InternalErrorException response + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get an .unknown error + /// + func testFetchMFAPreferenceWithInternalErrorException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockGetUserAttributeResponse: { _ in + throw GetUserOutputError.unknown(.init(httpResponse: .init(body: .empty, statusCode: .ok))) + }) + + do { + _ = try await plugin.fetchMFAPreference() + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.unknown = error else { + XCTFail("Should produce an unknown error instead of \(error)") + return + } + } + } + + /// Test a fetchMFAPreference call with InvalidParameterException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a InvalidParameterException response + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get a .service error with .invalidParameter as underlyingError + /// + func testFetchMFAPreferenceWithInvalidParameterException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockGetUserAttributeResponse: { _ in + throw GetUserOutputError.invalidParameterException(.init()) + }) + + do { + _ = try await plugin.fetchMFAPreference() + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .invalidParameter = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidParameter \(error)") + return + } + } + } + + /// Test a fetchMFAPreference call with InvalidParameterException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a NotAuthorizedException response + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get a .service error with .notAuthorized as underlyingError + /// + func testFetchMFAPreferenceWithNotAuthorizedException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockGetUserAttributeResponse: { _ in + throw GetUserOutputError.notAuthorizedException(.init(message: "message")) + }) + + do { + _ = try await plugin.fetchMFAPreference() + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.notAuthorized = error else { + XCTFail("Should produce notAuthorized error instead of \(error)") + return + } + } + } + + /// Test a fetchMFAPreference call with PasswordResetRequiredException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// PasswordResetRequiredException response + /// + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get a .service error with .passwordResetRequired as underlyingError + /// + func testFetchMFAPreferenceWithPasswordResetRequiredException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockGetUserAttributeResponse: { _ in + throw GetUserOutputError.passwordResetRequiredException(.init()) + }) + + do { + _ = try await plugin.fetchMFAPreference() + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .passwordResetRequired = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be passwordResetRequired \(error)") + return + } + } + } + + /// Test a fetchMFAPreference call with ResourceNotFoundException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// ResourceNotFoundException response + /// + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get a .service error with .resourceNotFound as underlyingError + /// + func testFetchMFAPreferenceWithResourceNotFoundException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockGetUserAttributeResponse: { _ in + throw GetUserOutputError.resourceNotFoundException(.init()) + }) + + do { + _ = try await plugin.fetchMFAPreference() + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .resourceNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be passwordResetRequired \(error)") + return + } + } + } + + /// Test a fetchMFAPreference call with TooManyRequestsException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// TooManyRequestsException response + /// + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get a .service error with .requestLimitExceeded as underlyingError + /// + func testFetchMFAPreferenceWithTooManyRequestsException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockGetUserAttributeResponse: { _ in + throw GetUserOutputError.tooManyRequestsException(.init()) + }) + + do { + _ = try await plugin.fetchMFAPreference() + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .requestLimitExceeded = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be requestLimitExceeded \(error)") + return + } + } + } + + /// Test a fetchMFAPreference call with UserNotConfirmedException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// UserNotConfirmedException response + /// + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get a .service error with .userNotConfirmed as underlyingError + /// + func testFetchMFAPreferenceWithUserNotConfirmedException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockGetUserAttributeResponse: { _ in + throw GetUserOutputError.userNotConfirmedException(.init()) + }) + do { + _ = try await plugin.fetchMFAPreference() + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .userNotConfirmed = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be userNotConfirmed \(error)") + return + } + } + } + + /// Test a fetchMFAPreference call with UserNotFoundException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// UserNotFoundException response + /// + /// - When: + /// - I invoke fetchMFAPreference + /// - Then: + /// - I should get a .service error with .userNotFound as underlyingError + /// + func testFetchMFAPreferenceWithUserNotFoundException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockGetUserAttributeResponse: { _ in + throw GetUserOutputError.userNotFoundException(.init()) + }) + do { + _ = try await plugin.fetchMFAPreference() + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .userNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be userNotFound \(error)") + return + } + } + } +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/MFA/SetUpTOTPTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/MFA/SetUpTOTPTaskTests.swift new file mode 100644 index 0000000000..67083280a7 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/MFA/SetUpTOTPTaskTests.swift @@ -0,0 +1,290 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +import XCTest +import Amplify +@testable import AWSCognitoAuthPlugin +import AWSCognitoIdentityProvider + +// swiftlint:disable type_body_length +// swiftlint:disable file_length +class SetUpTOTPTaskTests: BasePluginTest { + + /// Test a successful set up TOTP call + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response + /// - When: + /// - I invoke setUpTOTP + /// - Then: + /// - I should get a successful result with a secret + /// + func testSuccessfulSetUpTOTPRequest() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockAssociateSoftwareTokenResponse: { request in + return .init(secretCode: "sharedSecret") + }) + + do { + let setUpTOTPResult = try await plugin.setUpTOTP() + XCTAssertEqual(setUpTOTPResult.sharedSecret, "sharedSecret") + } catch { + XCTFail("Received failure with error \(error)") + } + } + + // MARK: Service error handling test + + /// Test a setUpTOTP call with concurrentModificationException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// concurrentModificationException response + /// - When: + /// - I invoke setUpTOTP with a valid confirmation code + /// - Then: + /// - I should get a .service error + /// + func testSetUpTOTPWithConcurrentModificationException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockAssociateSoftwareTokenResponse: { request in + throw AssociateSoftwareTokenOutputError + .concurrentModificationException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.setUpTOTP() + XCTFail("Should return an error if the result from service is invalid") + + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + XCTAssertNil(underlyingError) + } + + } + + /// Test a setUpTOTP call with forbiddenException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// forbiddenException response + /// - When: + /// - I invoke setUpTOTP with a valid confirmation code + /// - Then: + /// - I should get a .service error + /// + func testSetUpTOTPWithForbiddenException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockAssociateSoftwareTokenResponse: { request in + throw AssociateSoftwareTokenOutputError + .forbiddenException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.setUpTOTP() + XCTFail("Should return an error if the result from service is invalid") + + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + XCTAssertNil(underlyingError) + } + + } + + /// Test a setUpTOTP call with internalErrorException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// internalErrorException response + /// - When: + /// - I invoke setUpTOTP with a valid confirmation code + /// - Then: + /// - I should get a .service error + /// + func testSetUpTOTPWithInternalErrorException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockAssociateSoftwareTokenResponse: { request in + throw AssociateSoftwareTokenOutputError + .internalErrorException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.setUpTOTP() + XCTFail("Should return an error if the result from service is invalid") + + } catch { + guard case AuthError.unknown = error else { + XCTFail("Should produce an unknown error instead of \(error)") + return + } + } + + } + + /// Test a setUpTOTP call with InvalidParameterException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// InvalidParameterException response + /// + /// - When: + /// - I invoke setUpTOTP + /// - Then: + /// - I should get a .service error with .invalidParameter as underlyingError + /// + func testSetUpTOTPWithInvalidParameterException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockAssociateSoftwareTokenResponse: { request in + throw AssociateSoftwareTokenOutputError + .invalidParameterException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.setUpTOTP() + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .invalidParameter = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidParameter \(error)") + return + } + } + } + + /// Test a setUpTOTP call with notAuthorizedException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// notAuthorizedException response + /// + /// - When: + /// - I invoke setUpTOTP + /// - Then: + /// - I should get a .service error + /// + func testSetUpTOTPWithNotAuthorizedException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockAssociateSoftwareTokenResponse: { request in + throw AssociateSoftwareTokenOutputError + .notAuthorizedException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.setUpTOTP() + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.notAuthorized(_, _, _) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + } + } + + /// Test a setUpTOTP call with SoftwareTokenMFANotFoundException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// SoftwareTokenMFANotFoundException response + /// + /// - When: + /// - I invoke setUpTOTP + /// - Then: + /// - I should get a .service error with .softwareTokenMFANotEnabled as underlyingError + /// + func testSetUpWithSoftwareTokenMFANotFoundException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockAssociateSoftwareTokenResponse: { request in + throw AssociateSoftwareTokenOutputError + .softwareTokenMFANotFoundException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.setUpTOTP() + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .mfaMethodNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be softwareTokenMFANotEnabled \(error)") + return + } + } + } + + /// Test a setUpTOTP call with resourceNotFoundException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// resourceNotFoundException response + /// - When: + /// - I invoke setUpTOTP with a valid confirmation code + /// - Then: + /// - I should get a .service error with .resourceNotFound as underlyingError + /// + func testSetUpTOTPInWithResourceNotFoundException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockAssociateSoftwareTokenResponse: { request in + throw AssociateSoftwareTokenOutputError + .resourceNotFoundException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.setUpTOTP() + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .resourceNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidParameter \(error)") + return + } + } + } + + /// Test a setUpTOTP call with unknown response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// unknown response + /// - When: + /// - I invoke setUpTOTP with a valid confirmation code + /// - Then: + /// - I should get a .service + /// + func testSetUpWithUnknownException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockAssociateSoftwareTokenResponse: { request in + throw AssociateSoftwareTokenOutputError + .unknown(.init(httpResponse: .init(body: .empty, statusCode: .ok))) + }) + + do { + let _ = try await plugin.setUpTOTP() + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.unknown = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + } + } + +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/MFA/UpdateMFAPreferenceTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/MFA/UpdateMFAPreferenceTaskTests.swift new file mode 100644 index 0000000000..24e7f97d16 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/MFA/UpdateMFAPreferenceTaskTests.swift @@ -0,0 +1,287 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +import XCTest +import Amplify +@testable import AWSCognitoAuthPlugin +import AWSCognitoIdentityProvider + +// swiftlint:disable type_body_length +// swiftlint:disable file_length +class UpdateMFAPreferenceTaskTests: BasePluginTest { + + /// Test a successful updateMFAPreference call + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response + /// - When: + /// - I invoke updateMFAPreference + /// - Then: + /// - I should get a successful result + /// + func testSuccessfulUpdatePreference() async { + + let allSMSPreferences: [MFAPreference] = [.disabled, .enabled, .preferred, .notPreferred] + let allTOTPPreference: [MFAPreference] = [.disabled, .enabled, .preferred, .notPreferred] + + // Test all the combinations for preference types + for smsPreference in allSMSPreferences { + for totpPreference in allTOTPPreference { + self.mockIdentityProvider = MockIdentityProvider( + mockSetUserMFAPreferenceResponse: { request in + XCTAssertEqual( + request.smsMfaSettings, + smsPreference.smsSetting) + XCTAssertEqual( + request.softwareTokenMfaSettings, + totpPreference.softwareTokenSetting) + + return .init() + }) + + do { + try await plugin.updateMFAPreference( + sms: smsPreference, + totp: totpPreference) + } catch { + XCTFail("Received failure with error \(error)") + } + } + } + } + + // MARK: Service error handling test + + /// Test a updateMFAPreference call with InternalErrorException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a InternalErrorException response + /// - When: + /// - I invoke updateMFAPreference + /// - Then: + /// - I should get an .unknown error + /// + func testUpdateMFAPreferenceWithInternalErrorException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockSetUserMFAPreferenceResponse: { _ in + throw SetUserMFAPreferenceOutputError.unknown(.init(httpResponse: .init(body: .empty, statusCode: .ok))) + }) + + do { + _ = try await plugin.updateMFAPreference(sms: .enabled, totp: .enabled) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.unknown = error else { + XCTFail("Should produce an unknown error instead of \(error)") + return + } + } + } + + /// Test a updateMFAPreference call with InvalidParameterException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a InvalidParameterException response + /// - When: + /// - I invoke updateMFAPreference + /// - Then: + /// - I should get a .service error with .invalidParameter as underlyingError + /// + func testUpdateMFAPreferenceWithInvalidParameterException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockSetUserMFAPreferenceResponse: { _ in + throw SetUserMFAPreferenceOutputError.invalidParameterException(.init()) + }) + + do { + _ = try await plugin.updateMFAPreference(sms: .enabled, totp: .enabled) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .invalidParameter = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidParameter \(error)") + return + } + } + } + + /// Test a updateMFAPreference call with InvalidParameterException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a NotAuthorizedException response + /// - When: + /// - I invoke updateMFAPreference + /// - Then: + /// - I should get a .service error with .notAuthorized as underlyingError + /// + func testUpdateMFAPreferenceWithNotAuthorizedException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockSetUserMFAPreferenceResponse: { _ in + throw SetUserMFAPreferenceOutputError.notAuthorizedException(.init(message: "message")) + }) + + do { + _ = try await plugin.updateMFAPreference(sms: .enabled, totp: .enabled) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.notAuthorized = error else { + XCTFail("Should produce notAuthorized error instead of \(error)") + return + } + } + } + + /// Test a updateMFAPreference call with PasswordResetRequiredException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// PasswordResetRequiredException response + /// + /// - When: + /// - I invoke updateMFAPreference + /// - Then: + /// - I should get a .service error with .passwordResetRequired as underlyingError + /// + func testUpdateMFAPreferenceWithPasswordResetRequiredException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockSetUserMFAPreferenceResponse: { _ in + throw SetUserMFAPreferenceOutputError.passwordResetRequiredException(.init()) + }) + + do { + _ = try await plugin.updateMFAPreference(sms: .enabled, totp: .enabled) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .passwordResetRequired = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be passwordResetRequired \(error)") + return + } + } + } + + /// Test a updateMFAPreference call with ResourceNotFoundException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// ResourceNotFoundException response + /// + /// - When: + /// - I invoke updateMFAPreference + /// - Then: + /// - I should get a .service error with .resourceNotFound as underlyingError + /// + func testUpdateMFAPreferenceWithResourceNotFoundException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockSetUserMFAPreferenceResponse: { _ in + throw SetUserMFAPreferenceOutputError.resourceNotFoundException(.init()) + }) + + do { + _ = try await plugin.updateMFAPreference(sms: .enabled, totp: .enabled) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .resourceNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be passwordResetRequired \(error)") + return + } + } + } + + /// Test a updateMFAPreference call with ForbiddenException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// ForbiddenException response + /// + /// - When: + /// - I invoke updateMFAPreference + /// - Then: + /// - I should get a .service error with .requestLimitExceeded as underlyingError + /// + func testUpdateMFAPreferenceWithForbiddenException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockSetUserMFAPreferenceResponse: { _ in + throw SetUserMFAPreferenceOutputError.forbiddenException(.init()) + }) + + do { + _ = try await plugin.updateMFAPreference(sms: .enabled, totp: .enabled) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + } + } + + /// Test a updateMFAPreference call with UserNotConfirmedException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// UserNotConfirmedException response + /// + /// - When: + /// - I invoke updateMFAPreference + /// - Then: + /// - I should get a .service error with .userNotConfirmed as underlyingError + /// + func testUpdateMFAPreferenceWithUserNotConfirmedException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockSetUserMFAPreferenceResponse: { _ in + throw SetUserMFAPreferenceOutputError.userNotConfirmedException(.init()) + }) + do { + _ = try await plugin.updateMFAPreference(sms: .enabled, totp: .enabled) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .userNotConfirmed = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be userNotConfirmed \(error)") + return + } + } + } + + /// Test a updateMFAPreference call with UserNotFoundException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// UserNotFoundException response + /// + /// - When: + /// - I invoke updateMFAPreference + /// - Then: + /// - I should get a .service error with .userNotFound as underlyingError + /// + func testUpdateMFAPreferenceWithUserNotFoundException() async throws { + + mockIdentityProvider = MockIdentityProvider(mockSetUserMFAPreferenceResponse: { _ in + throw SetUserMFAPreferenceOutputError.userNotFoundException(.init()) + }) + do { + _ = try await plugin.updateMFAPreference(sms: .enabled, totp: .enabled) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .userNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be userNotFound \(error)") + return + } + } + } +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/MFA/VerifyTOTPSetupTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/MFA/VerifyTOTPSetupTaskTests.swift new file mode 100644 index 0000000000..125df7031e --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/MFA/VerifyTOTPSetupTaskTests.swift @@ -0,0 +1,475 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +import XCTest +import Amplify +@testable import AWSCognitoAuthPlugin +import AWSCognitoIdentityProvider + +// swiftlint:disable type_body_length +// swiftlint:disable file_length +class VerifyTOTPSetupTaskTests: BasePluginTest { + + /// Test a successful verify TOTP setup call + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response + /// - When: + /// - I invoke verifyTOTPSetup + /// - Then: + /// - I should get a successful result + /// + func testSuccessfulVerifyTOTPSetupRequest() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + XCTAssertEqual(request.userCode, "123456") + XCTAssertEqual(request.friendlyDeviceName, "device") + return .init(session: "session", status: .success) + }) + + do { + let pluginOptions = VerifyTOTPSetupOptions(friendlyDeviceName: "device") + try await plugin.verifyTOTPSetup( + code: "123456", options: .init(pluginOptions: pluginOptions)) + } catch { + XCTFail("Received failure with error \(error)") + } + } + + // MARK: Service error handling test + + /// Test a verifyTOTPSetup call with forbiddenException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// forbiddenException response + /// - When: + /// - I invoke verifyTOTPSetup with a valid confirmation code + /// - Then: + /// - I should get a .service error + /// + func testVerifyTOTPSetupWithForbiddenException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .forbiddenException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + XCTAssertNil(underlyingError) + } + + } + + /// Test a verifyTOTPSetup call with internalErrorException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// internalErrorException response + /// - When: + /// - I invoke verifyTOTPSetup with a valid confirmation code + /// - Then: + /// - I should get a .service error + /// + func testVerifyTOTPSetupWithInternalErrorException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .internalErrorException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + + } catch { + guard case AuthError.unknown = error else { + XCTFail("Should produce an unknown error instead of \(error)") + return + } + } + + } + + /// Test a verifyTOTPSetup call with InvalidParameterException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// InvalidParameterException response + /// + /// - When: + /// - I invoke verifyTOTPSetup + /// - Then: + /// - I should get a .service error with .invalidParameter as underlyingError + /// + func testVerifyTOTPSetupWithInvalidParameterException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .invalidParameterException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .invalidParameter = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidParameter \(error)") + return + } + } + } + + /// Test a verifyTOTPSetup call with notAuthorizedException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// notAuthorizedException response + /// + /// - When: + /// - I invoke verifyTOTPSetup + /// - Then: + /// - I should get a .service error + /// + func testVerifyTOTPSetupWithNotAuthorizedException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .notAuthorizedException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.notAuthorized(_, _, _) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + } + } + + /// Test a verifyTOTPSetup call with SoftwareTokenMFANotFoundException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// SoftwareTokenMFANotFoundException response + /// + /// - When: + /// - I invoke verifyTOTPSetup + /// - Then: + /// - I should get a .service error with .mfaMethodNotFound as underlyingError + /// + func testVerifyTOTPSetupWithSoftwareTokenMFANotFoundException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .softwareTokenMFANotFoundException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .mfaMethodNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be softwareTokenMFANotEnabled \(error)") + return + } + } + } + + /// Test a verifyTOTPSetup call with resourceNotFoundException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// resourceNotFoundException response + /// - When: + /// - I invoke verifyTOTPSetup with a valid confirmation code + /// - Then: + /// - I should get a .service error with .resourceNotFound as underlyingError + /// + func testVerifyTOTPSetupInWithResourceNotFoundException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .resourceNotFoundException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .resourceNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidParameter \(error)") + return + } + } + } + + /// Test a verifyTOTPSetup call with unknown response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// unknown response + /// - When: + /// - I invoke verifyTOTPSetup with a valid confirmation code + /// - Then: + /// - I should get a .service + /// + func testVerifyTOTPSetupWithUnknownException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .unknown(.init(httpResponse: .init(body: .empty, statusCode: .ok))) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.unknown = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + } + } + + /// Test a verifyTOTPSetup call with CodeMismatchException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// CodeMismatchException response + /// - When: + /// - I invoke verifyTOTPSetup with a valid confirmation code + /// - Then: + /// - I should get a .service error with .codeMismatch as underlyingError + /// + func testVerifyTOTPSetupInWithCodeMismatchException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .codeMismatchException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .codeMismatch = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidParameter \(error)") + return + } + } + } + + /// Test a verifyTOTPSetup call with EnableSoftwareTokenMFAException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// EnableSoftwareTokenMFAException response + /// - When: + /// - I invoke verifyTOTPSetup with a valid confirmation code + /// - Then: + /// - I should get a .service error with .softwareTokenMFANotEnabled as underlyingError + /// + func testVerifyTOTPSetupInWithEnableSoftwareTokenMFAException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .enableSoftwareTokenMFAException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .softwareTokenMFANotEnabled = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidParameter \(error)") + return + } + } + } + + /// Test a verifyTOTPSetup call with PasswordResetRequiredException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// PasswordResetRequiredException response + /// - When: + /// - I invoke verifyTOTPSetup with a valid confirmation code + /// - Then: + /// - I should get a .service error with .passwordResetRequired as underlyingError + /// + func testVerifyTOTPSetupInWithPasswordResetRequiredException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .passwordResetRequiredException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .passwordResetRequired = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be passwordResetRequired \(error)") + return + } + } + } + + /// Test a verifyTOTPSetup call with TooManyRequestsException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// TooManyRequestsException response + /// - When: + /// - I invoke verifyTOTPSetup with a valid confirmation code + /// - Then: + /// - I should get a .service error with .requestLimitExceeded as underlyingError + /// + func testVerifyTOTPSetupInWithTooManyRequestsException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .tooManyRequestsException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .requestLimitExceeded = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be requestLimitExceeded \(error)") + return + } + } + } + + /// Test a verifyTOTPSetup call with UserNotFoundException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// UserNotFoundException response + /// - When: + /// - I invoke verifyTOTPSetup with a valid confirmation code + /// - Then: + /// - I should get a .service error with .userNotFound as underlyingError + /// + func testVerifyTOTPSetupInWithUserNotFoundException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .userNotFoundException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .userNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be userNotFound \(error)") + return + } + } + } + + /// Test a verifyTOTPSetup call with UserNotConfirmedException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// UserNotConfirmedException response + /// - When: + /// - I invoke verifyTOTPSetup with a valid confirmation code + /// - Then: + /// - I should get a .service error with .userNotConfirmed as underlyingError + /// + func testVerifyTOTPSetupInWithUserNotConfirmedException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .userNotConfirmedException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .userNotConfirmed = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be userNotConfirmed \(error)") + return + } + } + } + + /// Test a verifyTOTPSetup call with InvalidUserPoolConfigurationException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// InvalidUserPoolConfigurationException response + /// - When: + /// - I invoke verifyTOTPSetup with a valid confirmation code + /// - Then: + /// - I should get a .service error + /// + func testVerifyTOTPSetupInWithInvalidUserPoolConfigurationException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError + .invalidUserPoolConfigurationException(.init(message: "Exception")) + }) + + do { + let _ = try await plugin.verifyTOTPSetup(code: "123456", options: nil) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.configuration = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + } + } + +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthConfirmSignInTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInTaskTests.swift similarity index 97% rename from AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthConfirmSignInTaskTests.swift rename to AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInTaskTests.swift index 4d8c97a55f..9e21edf0bc 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthConfirmSignInTaskTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInTaskTests.swift @@ -19,14 +19,14 @@ class AuthenticationProviderConfirmSigninTests: BasePluginTest { override var initialState: AuthState { AuthState.configured( AuthenticationState.signingIn( - .resolvingChallenge(.waitingForAnswer(.testData, .apiBased(.userSRP)), + .resolvingChallenge(.waitingForAnswer(.testData(), .apiBased(.userSRP)), .smsMfa, .apiBased(.userSRP))), AuthorizationState.sessionEstablished(.testData)) } /// Test a successful confirmSignIn call with .done as next step /// - /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response /// - When: /// - I invoke confirmSignIn with a valid confirmation code /// - Then: @@ -35,7 +35,9 @@ class AuthenticationProviderConfirmSigninTests: BasePluginTest { func testSuccessfulConfirmSignIn() async { self.mockIdentityProvider = MockIdentityProvider( - mockRespondToAuthChallengeResponse: { _ in + mockRespondToAuthChallengeResponse: { request in + XCTAssertEqual(request.challengeName, .smsMfa) + XCTAssertEqual(request.challengeResponses?["SMS_MFA_CODE"], "code") return .testData() }) @@ -53,7 +55,7 @@ class AuthenticationProviderConfirmSigninTests: BasePluginTest { /// Test a confirmSignIn call with an empty confirmation code /// - /// - Given: an auth plugin with mocked service. Mocked service should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response /// - When: /// - I invoke confirmSignIn with an empty confirmation code /// - Then: @@ -63,6 +65,7 @@ class AuthenticationProviderConfirmSigninTests: BasePluginTest { self.mockIdentityProvider = MockIdentityProvider( mockRespondToAuthChallengeResponse: { _ in + XCTFail("Cognito service should not be called") return .testData() }) @@ -77,9 +80,9 @@ class AuthenticationProviderConfirmSigninTests: BasePluginTest { } } - /// Test a confirmSignIn call with an empty confirmation code followed by a second valaid confirmSignIn call + /// Test a confirmSignIn call with an empty confirmation code followed by a second valid confirmSignIn call /// - /// - Given: an auth plugin with mocked service. Mocked service should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response /// - When: /// - I invoke second confirmSignIn after confirmSignIn with an empty confirmation code /// - Then: @@ -112,7 +115,7 @@ class AuthenticationProviderConfirmSigninTests: BasePluginTest { /// Test a confirmSignIn call with client metadata and user attributes /// - /// - Given: an auth plugin with mocked service. Mocked service should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response /// - When: /// - I invoke confirmSignIn with an confirmation code and plugin options /// - Then: @@ -408,7 +411,7 @@ class AuthenticationProviderConfirmSigninTests: BasePluginTest { /// - When: /// - I invoke confirmSignIn with a valid confirmation code /// - Then: - /// - I should get a -- + /// - I should get a .service error with .smsRole as underlyingError /// func testConfirmSignInWithinvalidSmsRoleAccessPolicyException() async { self.mockIdentityProvider = MockIdentityProvider( @@ -439,7 +442,7 @@ class AuthenticationProviderConfirmSigninTests: BasePluginTest { /// - When: /// - I invoke confirmSignIn with a valid confirmation code /// - Then: - /// - I should get a -- + /// - I should get a .service error with .smsRole as underlyingError /// func testConfirmSignInWithInvalidSmsRoleTrustRelationshipException() async { self.mockIdentityProvider = MockIdentityProvider( diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AWSAuthMigrationSignInTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthMigrationSignInTaskTests.swift similarity index 100% rename from AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AWSAuthMigrationSignInTaskTests.swift rename to AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthMigrationSignInTaskTests.swift diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AWSAuthSignInOptionsTestCase.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInOptionsTestCase.swift similarity index 100% rename from AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AWSAuthSignInOptionsTestCase.swift rename to AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInOptionsTestCase.swift diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AWSAuthSignInPluginTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInPluginTests.swift similarity index 90% rename from AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AWSAuthSignInPluginTests.swift rename to AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInPluginTests.swift index 7742ef1aed..481a7324ef 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AWSAuthSignInPluginTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInPluginTests.swift @@ -645,6 +645,129 @@ class AWSAuthSignInPluginTests: BasePluginTest { } + func testSignInWithNextStepTOTP() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { _ in + RespondToAuthChallengeOutputResponse( + authenticationResult: .none, + challengeName: .softwareTokenMfa, + challengeParameters: ["paramKey": "value"], + session: "session") + }) + + let options = AuthSignInRequest.Options() + do { + let result = try await plugin.signIn(username: "username", password: "password", options: options) + guard case .confirmSignInWithTOTPCode = result.nextStep else { + XCTFail("Result should be .confirmSignInWithTOTPCode for next step") + return + } + XCTAssertFalse(result.isSignedIn, "Signin result should not be complete") + } catch { + XCTFail("Should not produce error") + } + } + + func testSignInWithNextStepSelectMFAType() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { _ in + RespondToAuthChallengeOutputResponse( + authenticationResult: .none, + challengeName: .selectMfaType, + challengeParameters: ["MFAS_CAN_CHOOSE": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\"]"], + session: "session") + }) + + let options = AuthSignInRequest.Options() + do { + let result = try await plugin.signIn(username: "username", password: "password", options: options) + guard case .continueSignInWithMFASelection(let allowedMFaTypes) = result.nextStep else { + XCTFail("Result should be .continueSignInWithMFASelection for next step") + return + } + XCTAssertTrue(!allowedMFaTypes.isEmpty, "Allowed MFA types should have TOTP and SMS") + XCTAssertEqual(allowedMFaTypes, Set([MFAType.sms, MFAType.totp])) + XCTAssertFalse(result.isSignedIn, "Signin result should not be complete") + } catch { + XCTFail("Should not produce error") + } + } + + func testSignInWithNextStepSetupMFA() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { _ in + RespondToAuthChallengeOutputResponse( + authenticationResult: .none, + challengeName: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\"]"], + session: "session") + }, mockAssociateSoftwareTokenResponse: { _ in + return .init(secretCode: "123456", session: "session") + } ) + + let options = AuthSignInRequest.Options() + do { + let result = try await plugin.signIn(username: "username", password: "password", options: options) + guard case .continueSignInWithTOTPSetup(let totpSetupDetails) = result.nextStep else { + XCTFail("Result should be .continueSignInWithTOTPSetup for next step") + return + } + XCTAssertNotNil(totpSetupDetails) + XCTAssertEqual(totpSetupDetails.sharedSecret, "123456") + XCTAssertEqual(totpSetupDetails.username, "username") + } catch { + XCTFail("Should not produce error") + } + } + + func testSignInWithNextStepSetupMFAWithUnavailableMFAType() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { _ in + RespondToAuthChallengeOutputResponse( + authenticationResult: .none, + challengeName: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\"]"], + session: "session") + }, mockAssociateSoftwareTokenResponse: { _ in + return .init(secretCode: "123456", session: "session") + } ) + + let options = AuthSignInRequest.Options() + do { + _ = try await plugin.signIn(username: "username", password: "password", options: options) + XCTFail("Should not continue as MFA type is not available for setup") + } catch { + guard case AuthError.service = error else { + XCTFail("Should produce as service error") + return + } + } + } + // MARK: - Service error for initiateAuth /// Test a signIn with `InternalErrorException` from service diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInTOTPTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInTOTPTaskTests.swift new file mode 100644 index 0000000000..8c8b3bce14 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInTOTPTaskTests.swift @@ -0,0 +1,815 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +import XCTest +import Amplify +@testable import AWSCognitoAuthPlugin +import AWSCognitoIdentityProvider + +// swiftlint:disable type_body_length +// swiftlint:disable file_length +class ConfirmSignInTOTPTaskTests: BasePluginTest { + + override var initialState: AuthState { + AuthState.configured( + AuthenticationState.signingIn( + .resolvingChallenge( + .waitingForAnswer( + .testData(challenge: .softwareTokenMfa), + .apiBased(.userSRP) + ), + .totpMFA, + .apiBased(.userSRP))), + AuthorizationState.sessionEstablished(.testData)) + } + + /// Test a successful confirmSignIn call with .done as next step + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a successful result with .done as the next step + /// + func testSuccessfulConfirmSignIn() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { request in + XCTAssertEqual(request.challengeName, .softwareTokenMfa) + XCTAssertEqual(request.challengeResponses?["SOFTWARE_TOKEN_MFA_CODE"], "code") + return .testData() + }) + + do { + let confirmSignInResult = try await plugin.confirmSignIn( + challengeResponse: "code") + guard case .done = confirmSignInResult.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(confirmSignInResult.isSignedIn, "Signin result should be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + + /// Test a confirmSignIn call with an empty confirmation code + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response + /// - When: + /// - I invoke confirmSignIn with an empty confirmation code + /// - Then: + /// - I should get an .validation error + /// + func testConfirmSignInWithEmptyResponse() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + XCTFail("Cognito service should not be called") + return .testData() + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "") + XCTFail("Should not succeed") + } catch { + guard case AuthError.validation = error else { + XCTFail("Should produce validation error instead of \(error)") + return + } + } + } + + /// Test a confirmSignIn call with an empty confirmation code followed by a second valid confirmSignIn call + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response + /// - When: + /// - I invoke second confirmSignIn after confirmSignIn with an empty confirmation code + /// - Then: + /// - I should get a successful result with .done as the next step + /// + func testSuccessfullyConfirmSignInAfterAFailedConfirmSignIn() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + return .testData() + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "") + XCTFail("Should not succeed") + } catch { + guard case AuthError.validation = error else { + XCTFail("Should produce validation error instead of \(error)") + return + } + + do { + let confirmSignInResult = try await plugin.confirmSignIn(challengeResponse: "code") + XCTAssertTrue(confirmSignInResult.isSignedIn, "Signin result should be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + } + + /// Test a confirmSignIn call with client metadata and user attributes + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response + /// - When: + /// - I invoke confirmSignIn with an confirmation code and plugin options + /// - Then: + /// - The mocked service should receive metadata and user attributes + /// + func testSuccessfullyConfirmSignInWithMetadataAndUserAttributes() async { + + let confirmSignInOptions = AWSAuthConfirmSignInOptions( + userAttributes: [.init(.email, value: "some@some.com")], + metadata: ["metadata": "test"]) + + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { input in + XCTAssertEqual(confirmSignInOptions.metadata, input.clientMetadata) + XCTAssertEqual(input.challengeResponses?["userAttributes.email"], "some@some.com") + return .testData() + }) + + do { + let confirmSignInResult = try await plugin.confirmSignIn( + challengeResponse: "code", + options: .init(pluginOptions: confirmSignInOptions)) + guard case .done = confirmSignInResult.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(confirmSignInResult.isSignedIn, "Signin result should be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + + // MARK: Service error handling test + + /// Test a confirmSignIn call with aliasExistsException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// aliasExistsException response + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .aliasExists as underlyingError + /// + func testConfirmSignInWithAliasExistsException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.aliasExistsException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .aliasExists = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be aliasExists \(error)") + return + } + } + } + + /// Test a confirmSignIn call with CodeMismatchException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// CodeMismatchException response + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .codeMismatch as underlyingError + /// + func testConfirmSignInWithCodeMismatchException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.codeMismatchException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .codeMismatch = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be codeMismatch \(error)") + return + } + } + } + + /// Test a confirmSignIn call with CodeMismatchException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// CodeMismatchException response + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .codeMismatch as underlyingError + /// + func testConfirmSignInRetryWithCodeMismatchException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.codeMismatchException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .codeMismatch = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be codeMismatch \(error)") + return + } + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + return .testData() + }) + do { + let confirmSignInResult = try await plugin.confirmSignIn(challengeResponse: "code") + XCTAssertTrue(confirmSignInResult.isSignedIn, "Signin result should be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + + } + } + + /// Test a confirmSignIn call with CodeExpiredException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// CodeExpiredException response + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .codeExpired as underlyingError + /// + func testConfirmSignInWithExpiredCodeException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.expiredCodeException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .codeExpired = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be codeExpired \(error)") + return + } + } + } + + /// Test a confirmSignIn call with InternalErrorException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a InternalErrorException response + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get an .unknown error + /// + func testConfirmSignInWithInternalErrorException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.internalErrorException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.unknown = error else { + XCTFail("Should produce an unknown error instead of \(error)") + return + } + } + } + + /// Test a confirmSignIn call with InvalidLambdaResponseException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// InvalidLambdaResponseException response + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .lambda as underlyingError + /// + func testConfirmSignInWithInvalidLambdaResponseException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.invalidLambdaResponseException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .lambda = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be lambda \(error)") + return + } + } + } + + /// Test a confirmSignIn call with InvalidParameterException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// InvalidParameterException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .invalidParameter as underlyingError + /// + func testConfirmSignInWithInvalidParameterException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.invalidParameterException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .invalidParameter = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidParameter \(error)") + return + } + } + } + + /// Test a confirmSignIn call with InvalidPasswordException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// InvalidPasswordException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .invalidPassword as underlyingError + /// + func testConfirmSignInWithInvalidPasswordException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.invalidPasswordException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .invalidPassword = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidPassword \(error)") + return + } + } + } + + /// Test a confirmSignIn call with InvalidSmsRoleAccessPolicy response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// InvalidSmsRoleAccessPolicyException response + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .smsRole as underlyingError + /// + func testConfirmSignInWithinvalidSmsRoleAccessPolicyException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.invalidSmsRoleAccessPolicyException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .smsRole = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidPassword \(error)") + return + } + } + } + + /// Test a confirmSignIn call with InvalidSmsRoleTrustRelationship response from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// CodeDeliveryFailureException response + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .smsRole as underlyingError + /// + func testConfirmSignInWithInvalidSmsRoleTrustRelationshipException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.invalidSmsRoleTrustRelationshipException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .smsRole = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidPassword \(error)") + return + } + } + } + + /// Test a confirmSignIn with User pool configuration from service + /// + /// - Given: an auth plugin with mocked service with no User Pool configuration + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .configuration error + /// + func testConfirmSignInWithInvalidUserPoolConfigurationException() async { + let identityPoolConfigData = Defaults.makeIdentityConfigData() + let authorizationEnvironment = BasicAuthorizationEnvironment( + identityPoolConfiguration: identityPoolConfigData, + cognitoIdentityFactory: Defaults.makeIdentity) + let environment = AuthEnvironment( + configuration: .identityPools(identityPoolConfigData), + userPoolConfigData: nil, + identityPoolConfigData: identityPoolConfigData, + authenticationEnvironment: nil, + authorizationEnvironment: authorizationEnvironment, + credentialsClient: Defaults.makeCredentialStoreOperationBehavior(), + logger: Amplify.Logging.logger(forCategory: "awsCognitoAuthPluginTest") + ) + let stateMachine = Defaults.authStateMachineWith(environment: environment, + initialState: .notConfigured) + let plugin = AWSCognitoAuthPlugin() + plugin.configure( + authConfiguration: .identityPools(identityPoolConfigData), + authEnvironment: environment, + authStateMachine: stateMachine, + credentialStoreStateMachine: Defaults.makeDefaultCredentialStateMachine(), + hubEventHandler: MockAuthHubEventBehavior(), + analyticsHandler: MockAnalyticsHandler()) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.configuration(_, _, _) = error else { + XCTFail("Should produce configuration instead produced \(error)") + return + } + } + + } + + /// Test a confirmSignIn with MFAMethodNotFoundException from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// MFAMethodNotFoundException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .mfaMethodNotFound as underlyingError + /// + func testCofirmSignInWithMFAMethodNotFoundException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.mFAMethodNotFoundException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should not succeed") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .mfaMethodNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be mfaMethodNotFound \(error)") + return + } + } + } + + /// Test a confirmSignIn call with NotAuthorizedException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// NotAuthorizedException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .notAuthorized error + /// + func testConfirmSignInWithNotAuthorizedException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.notAuthorizedException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.notAuthorized = error else { + XCTFail("Should produce notAuthorized error instead of \(error)") + return + } + } + } + + /// Test a confirmSignIn with PasswordResetRequiredException from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// PasswordResetRequiredException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .resetPassword as next step + /// + func testConfirmSignInWithPasswordResetRequiredException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.passwordResetRequiredException( + .init(message: "Exception")) + }) + + do { + let confirmSignInResult = try await plugin.confirmSignIn(challengeResponse: "code") + guard case .resetPassword = confirmSignInResult.nextStep else { + XCTFail("Result should be .resetPassword for next step") + return + } + } catch { + XCTFail("Should not return error \(error)") + } + } + + + /// Test a confirmSignIn call with SoftwareTokenMFANotFoundException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// SoftwareTokenMFANotFoundException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .softwareTokenMFANotEnabled as underlyingError + /// + func testConfirmSignInWithSoftwareTokenMFANotFoundException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.softwareTokenMFANotFoundException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .softwareTokenMFANotEnabled = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be softwareTokenMFANotEnabled \(error)") + return + } + } + } + + /// Test a confirmSignIn call with TooManyRequestsException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// TooManyRequestsException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .requestLimitExceeded as underlyingError + /// + func testConfirmSignInWithTooManyRequestsException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.tooManyRequestsException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .requestLimitExceeded = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be requestLimitExceeded \(error)") + return + } + } + } + + /// Test a confirmSignIn call with UnexpectedLambdaException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// UnexpectedLambdaException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .lambda as underlyingError + /// + func testConfirmSignInWithUnexpectedLambdaException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.unexpectedLambdaException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .lambda = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be lambda \(error)") + return + } + } + } + + /// Test a confirmSignIn call with UserLambdaValidationException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// UserLambdaValidationException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .lambda as underlyingError + /// + func testConfirmSignInWithUserLambdaValidationException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.userLambdaValidationException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .lambda = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be lambda \(error)") + return + } + } + } + + /// Test a confirmSignIn call with UserNotConfirmedException response from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// UserNotConfirmedException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get .confirmSignUp as next step + /// + func testConfirmSignInWithUserNotConfirmedException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.userNotConfirmedException( + .init(message: "Exception")) + }) + + do { + let confirmSignInResult = try await plugin.confirmSignIn(challengeResponse: "code") + guard case .confirmSignUp = confirmSignInResult.nextStep else { + XCTFail("Result should be .confirmSignUp for next step") + return + } + } catch { + XCTFail("Should not return error \(error)") + } + } + + /// Test a confirmSignIn call with UserNotFound response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// UserNotFoundException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .userNotFound error + /// + func testConfirmSignInWithUserNotFoundException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.userNotFoundException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "code") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .userNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be userNotFound \(error)") + return + } + } + } +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithMFASelectionTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithMFASelectionTaskTests.swift new file mode 100644 index 0000000000..b7c442f3a9 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithMFASelectionTaskTests.swift @@ -0,0 +1,851 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +import XCTest +import Amplify +@testable import AWSCognitoAuthPlugin +import AWSCognitoIdentityProvider + +// swiftlint:disable type_body_length +// swiftlint:disable file_length +class ConfirmSignInWithMFASelectionTaskTests: BasePluginTest { + + override var initialState: AuthState { + AuthState.configured( + AuthenticationState.signingIn( + .resolvingChallenge( + .waitingForAnswer( + .testData(challenge: .selectMfaType), + .apiBased(.userSRP) + ), + .selectMFAType, + .apiBased(.userSRP))), + AuthorizationState.sessionEstablished(.testData)) + } + + /// Test a successful confirmSignIn call with .confirmSignInWithSMSMFACode as next step + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response + /// - When: + /// - I invoke confirmSignIn with SMS as selection + /// - Then: + /// - I should get a successful result with .confirmSignInWithSMSMFACode as the next step + /// + func testSuccessfulConfirmSignInWithSMSAsMFASelection() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { request in + + XCTAssertEqual(request.challengeName, .selectMfaType) + XCTAssertEqual(request.challengeResponses?["ANSWER"], "SMS_MFA") + + return .testData(challenge: .smsMfa) + }) + + do { + let confirmSignInResult = try await plugin.confirmSignIn( + challengeResponse: MFAType.sms.challengeResponse) + guard case .confirmSignInWithSMSMFACode = confirmSignInResult.nextStep else { + XCTFail("Result should be .confirmSignInWithSMSMFACode for next step") + return + } + XCTAssertFalse(confirmSignInResult.isSignedIn, "Signin result should NOT be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + + /// Test a successful confirmSignIn call with .confirmSignInWithTOTPCode as next step + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response + /// - When: + /// - I invoke confirmSignIn with TOTP as selection + /// - Then: + /// - I should get a successful result with .confirmSignInWithTOTPCode as the next step + /// + func testSuccessfulConfirmSignInWithTOTPAsMFASelection() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { request in + + XCTAssertEqual(request.challengeName, .selectMfaType) + XCTAssertEqual(request.challengeResponses?["ANSWER"], "SOFTWARE_TOKEN_MFA") + + return .testData(challenge: .softwareTokenMfa) + }) + + do { + let confirmSignInResult = try await plugin.confirmSignIn( + challengeResponse: MFAType.totp.challengeResponse) + guard case .confirmSignInWithTOTPCode = confirmSignInResult.nextStep else { + XCTFail("Result should be .confirmSignInWithTOTPCode for next step") + return + } + XCTAssertFalse(confirmSignInResult.isSignedIn, "Signin result should NOT be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + + /// Test a confirmSignIn call with an invalid MFA selection + /// + /// - Given: an auth plugin with mocked service. + /// - When: + /// - I invoke confirmSignIn with a invalid (dummy) MFA selection + /// - Then: + /// - I should get an .validation error + /// + func testConfirmSignInWithInvalidMFASelection() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + XCTFail("Cognito service should not be called") + return .testData() + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "dummy") + XCTFail("Should not succeed") + } catch { + guard case AuthError.validation = error else { + XCTFail("Should produce validation error instead of \(error)") + return + } + } + } + + + /// Test a confirmSignIn call with an empty confirmation code + /// + /// - Given: an auth plugin with mocked service. + /// - When: + /// - I invoke confirmSignIn with an empty MFA selection + /// - Then: + /// - I should get an .validation error + /// + func testConfirmSignInWithEmptyResponse() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + XCTFail("Cognito service should not be called") + return .testData() + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "") + XCTFail("Should not succeed") + } catch { + guard case AuthError.validation = error else { + XCTFail("Should produce validation error instead of \(error)") + return + } + } + } + + /// Test a confirmSignIn call with an empty MFA selection followed by a second valid confirmSignIn call + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response + /// - When: + /// - I invoke second confirmSignIn after confirmSignIn with an invalid MFA selection + /// - Then: + /// - I should get a successful result with .confirmSignInWithTOTPCode as the next step + /// + func testSuccessfullyConfirmSignInAfterAFailedConfirmSignIn() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + return .testData(challenge: .softwareTokenMfa) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "dummy") + XCTFail("Should not succeed") + } catch { + guard case AuthError.validation = error else { + XCTFail("Should produce validation error instead of \(error)") + return + } + + do { + let confirmSignInResult = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + guard case .confirmSignInWithTOTPCode = confirmSignInResult.nextStep else { + XCTFail("Result should be .confirmSignInWithTOTPCode for next step") + return + } + XCTAssertFalse(confirmSignInResult.isSignedIn, "Signin result should NOT be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + } + + // MARK: Service error handling test + + /// Test a confirmSignIn call with aliasExistsException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// aliasExistsException response + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .aliasExists as underlyingError + /// + func testConfirmSignInWithAliasExistsException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.aliasExistsException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .aliasExists = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be aliasExists \(error)") + return + } + } + } + + /// Test a confirmSignIn call with CodeMismatchException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// CodeMismatchException response + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .codeMismatch as underlyingError + /// + func testConfirmSignInWithCodeMismatchException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.codeMismatchException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .codeMismatch = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be codeMismatch \(error)") + return + } + } + } + + /// Test a successful confirmSignIn call after a CodeMismatchException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// CodeMismatchException response + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .codeMismatch as underlyingError + /// - Then: + /// - I invoke confirmSignIn with a valid MFA selection, I should get a successful result + /// + func testConfirmSignInRetryWithCodeMismatchException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.codeMismatchException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .codeMismatch = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be codeMismatch \(error)") + return + } + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + return .testData(challenge: .softwareTokenMfa) + }) + do { + let confirmSignInResult = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + guard case .confirmSignInWithTOTPCode = confirmSignInResult.nextStep else { + XCTFail("Result should be .confirmSignInWithTOTPCode for next step") + return + } + XCTAssertFalse(confirmSignInResult.isSignedIn, "Signin result should NOT be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + + } + } + + /// Test a confirmSignIn call with CodeExpiredException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// CodeExpiredException response + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .codeExpired as underlyingError + /// + func testConfirmSignInWithExpiredCodeException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.expiredCodeException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .codeExpired = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be codeExpired \(error)") + return + } + } + } + + /// Test a confirmSignIn call with InternalErrorException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a InternalErrorException response + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get an .unknown error + /// + func testConfirmSignInWithInternalErrorException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.internalErrorException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.unknown = error else { + XCTFail("Should produce an unknown error instead of \(error)") + return + } + } + } + + /// Test a confirmSignIn call with InvalidLambdaResponseException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// InvalidLambdaResponseException response + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .lambda as underlyingError + /// + func testConfirmSignInWithInvalidLambdaResponseException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.invalidLambdaResponseException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .lambda = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be lambda \(error)") + return + } + } + } + + /// Test a confirmSignIn call with InvalidParameterException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// InvalidParameterException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .invalidParameter as underlyingError + /// + func testConfirmSignInWithInvalidParameterException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.invalidParameterException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .invalidParameter = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidParameter \(error)") + return + } + } + } + + /// Test a confirmSignIn call with InvalidPasswordException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// InvalidPasswordException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .invalidPassword as underlyingError + /// + func testConfirmSignInWithInvalidPasswordException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.invalidPasswordException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .invalidPassword = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidPassword \(error)") + return + } + } + } + + /// Test a confirmSignIn call with InvalidSmsRoleAccessPolicy response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// InvalidSmsRoleAccessPolicyException response + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .smsRole as underlyingError + /// + func testConfirmSignInWithinvalidSmsRoleAccessPolicyException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.invalidSmsRoleAccessPolicyException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .smsRole = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidPassword \(error)") + return + } + } + } + + /// Test a confirmSignIn call with InvalidSmsRoleTrustRelationship response from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// CodeDeliveryFailureException response + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .smsRole as underlyingError + /// + func testConfirmSignInWithInvalidSmsRoleTrustRelationshipException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.invalidSmsRoleTrustRelationshipException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .smsRole = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidPassword \(error)") + return + } + } + } + + /// Test a confirmSignIn with User pool configuration from service + /// + /// - Given: an auth plugin with mocked service with no User Pool configuration + /// + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .configuration error + /// + func testConfirmSignInWithInvalidUserPoolConfigurationException() async { + let identityPoolConfigData = Defaults.makeIdentityConfigData() + let authorizationEnvironment = BasicAuthorizationEnvironment( + identityPoolConfiguration: identityPoolConfigData, + cognitoIdentityFactory: Defaults.makeIdentity) + let environment = AuthEnvironment( + configuration: .identityPools(identityPoolConfigData), + userPoolConfigData: nil, + identityPoolConfigData: identityPoolConfigData, + authenticationEnvironment: nil, + authorizationEnvironment: authorizationEnvironment, + credentialsClient: Defaults.makeCredentialStoreOperationBehavior(), + logger: Amplify.Logging.logger(forCategory: "awsCognitoAuthPluginTest") + ) + let stateMachine = Defaults.authStateMachineWith(environment: environment, + initialState: .notConfigured) + let plugin = AWSCognitoAuthPlugin() + plugin.configure( + authConfiguration: .identityPools(identityPoolConfigData), + authEnvironment: environment, + authStateMachine: stateMachine, + credentialStoreStateMachine: Defaults.makeDefaultCredentialStateMachine(), + hubEventHandler: MockAuthHubEventBehavior(), + analyticsHandler: MockAnalyticsHandler()) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.configuration(_, _, _) = error else { + XCTFail("Should produce configuration instead produced \(error)") + return + } + } + + } + + /// Test a confirmSignIn with MFAMethodNotFoundException from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// MFAMethodNotFoundException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .mfaMethodNotFound as underlyingError + /// + func testCofirmSignInWithMFAMethodNotFoundException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.mFAMethodNotFoundException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should not succeed") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .mfaMethodNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be mfaMethodNotFound \(error)") + return + } + } + } + + /// Test a confirmSignIn call with NotAuthorizedException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// NotAuthorizedException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .notAuthorized error + /// + func testConfirmSignInWithNotAuthorizedException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.notAuthorizedException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.notAuthorized = error else { + XCTFail("Should produce notAuthorized error instead of \(error)") + return + } + } + } + + /// Test a confirmSignIn with PasswordResetRequiredException from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// PasswordResetRequiredException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .resetPassword as next step + /// + func testConfirmSignInWithPasswordResetRequiredException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.passwordResetRequiredException( + .init(message: "Exception")) + }) + + do { + let confirmSignInResult = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + guard case .resetPassword = confirmSignInResult.nextStep else { + XCTFail("Result should be .resetPassword for next step") + return + } + } catch { + XCTFail("Should not return error \(error)") + } + } + + + /// Test a confirmSignIn call with SoftwareTokenMFANotFoundException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// SoftwareTokenMFANotFoundException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .softwareTokenMFANotEnabled as underlyingError + /// + func testConfirmSignInWithSoftwareTokenMFANotFoundException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.softwareTokenMFANotFoundException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .softwareTokenMFANotEnabled = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be softwareTokenMFANotEnabled \(error)") + return + } + } + } + + /// Test a confirmSignIn call with TooManyRequestsException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// TooManyRequestsException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .requestLimitExceeded as underlyingError + /// + func testConfirmSignInWithTooManyRequestsException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.tooManyRequestsException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .requestLimitExceeded = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be requestLimitExceeded \(error)") + return + } + } + } + + /// Test a confirmSignIn call with UnexpectedLambdaException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// UnexpectedLambdaException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .lambda as underlyingError + /// + func testConfirmSignInWithUnexpectedLambdaException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.unexpectedLambdaException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .lambda = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be lambda \(error)") + return + } + } + } + + /// Test a confirmSignIn call with UserLambdaValidationException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// UserLambdaValidationException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .service error with .lambda as underlyingError + /// + func testConfirmSignInWithUserLambdaValidationException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.userLambdaValidationException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .lambda = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be lambda \(error)") + return + } + } + } + + /// Test a confirmSignIn call with UserNotConfirmedException response from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// UserNotConfirmedException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get .confirmSignUp as next step + /// + func testConfirmSignInWithUserNotConfirmedException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.userNotConfirmedException( + .init(message: "Exception")) + }) + + do { + let confirmSignInResult = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + guard case .confirmSignUp = confirmSignInResult.nextStep else { + XCTFail("Result should be .confirmSignUp for next step") + return + } + } catch { + XCTFail("Should not return error \(error)") + } + } + + /// Test a confirmSignIn call with UserNotFound response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// UserNotFoundException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid MFA selection + /// - Then: + /// - I should get a .userNotFound error + /// + func testConfirmSignInWithUserNotFoundException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + throw RespondToAuthChallengeOutputError.userNotFoundException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .userNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be userNotFound \(error)") + return + } + } + } +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithSetUpMFATaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithSetUpMFATaskTests.swift new file mode 100644 index 0000000000..67dfc8ef6c --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithSetUpMFATaskTests.swift @@ -0,0 +1,521 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +import XCTest +import Amplify +@testable import AWSCognitoAuthPlugin +import AWSCognitoIdentityProvider + +// swiftlint:disable type_body_length +// swiftlint:disable file_length +class ConfirmSignInWithSetUpMFATaskTests: BasePluginTest { + + override var initialState: AuthState { + AuthState.configured( + AuthenticationState.signingIn( + .resolvingTOTPSetup( + .waitingForAnswer(.init( + secretCode: "sharedSecret", + session: "session", + username: "username")), + .testData)), + AuthorizationState.sessionEstablished(.testData)) + } + + /// Test a successful confirmSignIn call with .done as next step + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a successful result with .done as the next step + /// + func testSuccessfulTOTPMFASetupStep() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { request in + XCTAssertEqual(request.session, "verifiedSession") + return .testData() + }, + mockVerifySoftwareTokenResponse: { request in + XCTAssertEqual(request.session, "session") + XCTAssertEqual(request.userCode, "123456") + XCTAssertEqual(request.friendlyDeviceName, "device") + return .init(session: "verifiedSession", status: .success) + } + ) + + do { + let pluginOptions = AWSAuthConfirmSignInOptions(friendlyDeviceName: "device") + let confirmSignInResult = try await plugin.confirmSignIn( + challengeResponse: "123456", + options: .init(pluginOptions: pluginOptions)) + guard case .done = confirmSignInResult.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(confirmSignInResult.isSignedIn, "Signin result should NOT be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + + + /// Test a confirmSignIn call with an empty confirmation code + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response + /// - When: + /// - I invoke confirmSignIn with an empty confirmation code + /// - Then: + /// - I should get an .validation error + /// + func testConfirmSignInWithEmptyResponse() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { _ in + XCTFail("Cognito service should not be called") + return .testData() + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "") + XCTFail("Should not succeed") + } catch { + guard case AuthError.validation = error else { + XCTFail("Should produce validation error instead of \(error)") + return + } + } + } + + /// Test a confirmSignIn call with an empty confirmation code followed by a second valid confirmSignIn call + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a successful response + /// - When: + /// - I invoke second confirmSignIn after confirmSignIn with an empty confirmation code + /// - Then: + /// - I should get a successful result with .done as the next step + /// + func testSuccessfullyConfirmSignInAfterAFailedConfirmSignIn() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { request in + XCTAssertEqual(request.session, "verifiedSession") + return .testData() + }, + mockVerifySoftwareTokenResponse: { request in + XCTAssertEqual(request.session, "session") + XCTAssertEqual(request.userCode, "123456") + return .init(session: "verifiedSession", status: .success) + } + ) + do { + _ = try await plugin.confirmSignIn(challengeResponse: "") + XCTFail("Should not succeed") + } catch { + guard case AuthError.validation = error else { + XCTFail("Should produce validation error instead of \(error)") + return + } + + do { + let confirmSignInResult = try await plugin.confirmSignIn(challengeResponse: "123456") + guard case .done = confirmSignInResult.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(confirmSignInResult.isSignedIn, "Signin result should be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + } + + // MARK: Service error handling test + + /// Test a confirmSignIn call with CodeMismatchException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// CodeMismatchException response + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .codeMismatch as underlyingError + /// + func testConfirmSignInWithCodeMismatchException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError.codeMismatchException( + .init(message: "Exception")) + } + ) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "12345") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .codeMismatch = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be codeMismatch \(error)") + return + } + } + } + + /// Test a confirmSignIn call with CodeMismatchException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// CodeMismatchException response + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .codeMismatch as underlyingError + /// Then: + /// - RETRY SHOULD ALSO SUCCEED + /// + func testConfirmSignInRetryWithCodeMismatchException() async { + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError.codeMismatchException( + .init(message: "Exception")) + } + ) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "123456") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .codeMismatch = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be codeMismatch \(error)") + return + } + + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { request in + XCTAssertEqual(request.session, "verifiedSession") + return .testData() + }, + mockVerifySoftwareTokenResponse: { request in + XCTAssertEqual(request.session, "session") + XCTAssertEqual(request.userCode, "123456") + return .init(session: "verifiedSession", status: .success) + } + ) + do { + let confirmSignInResult = try await plugin.confirmSignIn(challengeResponse: "123456") + XCTAssertTrue(confirmSignInResult.isSignedIn, "Signin result should be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + + } + } + + /// Test a confirmSignIn call with InternalErrorException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a InternalErrorException response + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get an .unknown error + /// + func testConfirmSignInWithInternalErrorException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError.internalErrorException( + .init(message: "Exception")) + } + ) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "123456") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.unknown = error else { + XCTFail("Should produce an unknown error instead of \(error)") + return + } + } + } + + /// Test a confirmSignIn call with InvalidParameterException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// InvalidParameterException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .invalidParameter as underlyingError + /// + func testConfirmSignInWithInvalidParameterException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError.invalidParameterException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: MFAType.totp.challengeResponse) + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .invalidParameter = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be invalidParameter \(error)") + return + } + } + } + + /// Test a confirmSignIn with User pool configuration from service + /// + /// - Given: an auth plugin with mocked service with no User Pool configuration + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .configuration error + /// + func testConfirmSignInWithInvalidUserPoolConfigurationException() async { + let identityPoolConfigData = Defaults.makeIdentityConfigData() + let authorizationEnvironment = BasicAuthorizationEnvironment( + identityPoolConfiguration: identityPoolConfigData, + cognitoIdentityFactory: Defaults.makeIdentity) + let environment = AuthEnvironment( + configuration: .identityPools(identityPoolConfigData), + userPoolConfigData: nil, + identityPoolConfigData: identityPoolConfigData, + authenticationEnvironment: nil, + authorizationEnvironment: authorizationEnvironment, + credentialsClient: Defaults.makeCredentialStoreOperationBehavior(), + logger: Amplify.Logging.logger(forCategory: "awsCognitoAuthPluginTest") + ) + let stateMachine = Defaults.authStateMachineWith(environment: environment, + initialState: .notConfigured) + let plugin = AWSCognitoAuthPlugin() + plugin.configure( + authConfiguration: .identityPools(identityPoolConfigData), + authEnvironment: environment, + authStateMachine: stateMachine, + credentialStoreStateMachine: Defaults.makeDefaultCredentialStateMachine(), + hubEventHandler: MockAuthHubEventBehavior(), + analyticsHandler: MockAnalyticsHandler()) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.configuration(_, _, _) = error else { + XCTFail("Should produce configuration instead produced \(error)") + return + } + } + + } + + /// Test a confirmSignIn call with NotAuthorizedException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// NotAuthorizedException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .notAuthorized error + /// + func testConfirmSignInWithNotAuthorizedException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError.notAuthorizedException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "123456") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.notAuthorized = error else { + XCTFail("Should produce notAuthorized error instead of \(error)") + return + } + } + } + + /// Test a confirmSignIn with PasswordResetRequiredException from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// PasswordResetRequiredException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .resetPassword as next step + /// + func testConfirmSignInWithPasswordResetRequiredException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError.passwordResetRequiredException( + .init(message: "Exception")) + }) + + do { + let confirmSignInResult = try await plugin.confirmSignIn(challengeResponse: "123456") + guard case .resetPassword = confirmSignInResult.nextStep else { + XCTFail("Result should be .resetPassword for next step") + return + } + } catch { + XCTFail("Should not return error \(error)") + } + } + + + /// Test a confirmSignIn call with SoftwareTokenMFANotFoundException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// SoftwareTokenMFANotFoundException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .softwareTokenMFANotEnabled as underlyingError + /// + func testConfirmSignInWithSoftwareTokenMFANotFoundException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError.softwareTokenMFANotFoundException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "1") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .mfaMethodNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be softwareTokenMFANotEnabled \(error)") + return + } + } + } + + /// Test a confirmSignIn call with TooManyRequestsException response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// TooManyRequestsException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .service error with .requestLimitExceeded as underlyingError + /// + func testConfirmSignInWithTooManyRequestsException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError.tooManyRequestsException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "1") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .requestLimitExceeded = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be requestLimitExceeded \(error)") + return + } + } + } + + /// Test a confirmSignIn call with UserNotConfirmedException response from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// UserNotConfirmedException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get .confirmSignUp as next step + /// + func testConfirmSignInWithUserNotConfirmedException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError.userNotConfirmedException( + .init(message: "Exception")) + }) + + do { + let confirmSignInResult = try await plugin.confirmSignIn(challengeResponse: "1") + guard case .confirmSignUp = confirmSignInResult.nextStep else { + XCTFail("Result should be .confirmSignUp for next step") + return + } + } catch { + XCTFail("Should not return error \(error)") + } + } + + /// Test a confirmSignIn call with UserNotFound response from service + /// + /// - Given: an auth plugin with mocked service. Mocked service should mock a + /// UserNotFoundException response + /// + /// - When: + /// - I invoke confirmSignIn with a valid confirmation code + /// - Then: + /// - I should get a .userNotFound error + /// + func testConfirmSignInWithUserNotFoundException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockVerifySoftwareTokenResponse: { request in + throw VerifySoftwareTokenOutputError.userNotFoundException( + .init(message: "Exception")) + }) + + do { + _ = try await plugin.confirmSignIn(challengeResponse: "1") + XCTFail("Should return an error if the result from service is invalid") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error else { + XCTFail("Should produce service error instead of \(error)") + return + } + guard case .userNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Underlying error should be userNotFound \(error)") + return + } + } + } +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/SignInSetUpTOTPTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/SignInSetUpTOTPTests.swift new file mode 100644 index 0000000000..bcf1cb0e02 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/SignInSetUpTOTPTests.swift @@ -0,0 +1,540 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import AWSCognitoIdentity +@testable import Amplify +@testable import AWSCognitoAuthPlugin +import AWSCognitoIdentityProvider +import ClientRuntime + +class SignInSetUpTOTPTests: BasePluginTest { + + override var initialState: AuthState { + AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + } + + /// Test a signIn with valid inputs getting continueSignInWithTOTPSetup challenge + /// + /// - Given: Given an auth plugin with mocked service. + /// + /// - When: + /// - I invoke signIn with valid values + /// - Then: + /// - I should get a .continueSignInWithTOTPSetup response + /// + func testSuccessfulTOTPSetupChallenge() async { + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\"]"]) + }, mockAssociateSoftwareTokenResponse: { input in + return .init(secretCode: "sharedSecret", session: "newSession") + }) + let options = AuthSignInRequest.Options() + + do { + let result = try await plugin.signIn( + username: "username", + password: "password", + options: options) + guard case .continueSignInWithTOTPSetup(let totpDetails) = result.nextStep else { + XCTFail("Result should be .continueSignInWithTOTPSetup for next step") + return + } + XCTAssertEqual(totpDetails.sharedSecret, "sharedSecret") + XCTAssertEqual(totpDetails.username, "username") + XCTAssertFalse(result.isSignedIn, "Signin result should be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + + func testSignInWithNextStepSetupMFAWithUnavailableMFAType() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { _ in + RespondToAuthChallengeOutputResponse( + authenticationResult: .none, + challengeName: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\"]"], + session: "session") + }, mockAssociateSoftwareTokenResponse: { _ in + return .init(secretCode: "123456", session: "session") + } ) + + let options = AuthSignInRequest.Options() + do { + _ = try await plugin.signIn(username: "username", password: "password", options: options) + XCTFail("Should not continue as MFA type is not available for setup") + } catch { + guard case AuthError.service = error else { + XCTFail("Should produce as service error") + return + } + } + } + + + /// Test a signIn with valid inputs getting continueSignInWithTOTPSetup challenge + /// + /// - Given: Given an auth plugin with mocked service. + /// + /// - When: + /// - I invoke signIn with valid values + /// - Then: + /// - I should get a .continueSignInWithTOTPSetup response + /// + func testSuccessfulTOTPSetupChallengeWithEmptyMFASCanSetup() async { + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: [:]) + }, mockAssociateSoftwareTokenResponse: { input in + return .init(secretCode: "sharedSecret", session: "newSession") + }) + let options = AuthSignInRequest.Options() + + do { + _ = try await plugin.signIn( + username: "username", + password: "password", + options: options) + XCTFail("Should throw an error") + } catch { + guard case AuthError.service(_, _, _) = error else { + XCTFail("Should receive service error instead got \(error)") + return + } + } + } + + + /// Test a signIn with nil as reponse from service + /// + /// - Given: Given an auth plugin with mocked service. Mock nil response from service + /// + /// - When: + /// - I invoke signIn + /// - Then: + /// - I should get a .unknown error + /// + func testSignInWithInvalidResult() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: [:]) + }, mockAssociateSoftwareTokenResponse: { input in + return .init() + }) + let options = AuthSignInRequest.Options() + + do { + _ = try await plugin.signIn( + username: "username", + password: "password", + options: options) + XCTFail("Should throw an error") + } catch { + guard case AuthError.service(_, _, _) = error else { + XCTFail("Should receive service error instead got \(error)") + return + } + } + } + + /// Test a signIn with nil as reponse from service followed by a second signIn with a valid response + /// + /// - Given: Given an auth plugin with mocked service. Mock nil response from service followed by a valid response + /// + /// - When: + /// - I invoke signIn a second time + /// - Then: + /// - I should get signed in + /// + func testSecondSignInAfterSignInWithInvalidResult() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\"]"]) + }, mockAssociateSoftwareTokenResponse: { input in + return .init() + }) + let options = AuthSignInRequest.Options() + do { + let result = try await plugin.signIn(username: "username", password: "password", options: options) + XCTFail("Should not receive a success response \(result)") + } catch { + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\"]"]) + }, mockAssociateSoftwareTokenResponse: { input in + return .init(secretCode: "sharedSecret", session: "newSession") + }) + + do { + let result2 = try await plugin.signIn(username: "username", password: "password", options: options) + guard case .continueSignInWithTOTPSetup(let totpDetails) = result2.nextStep else { + XCTFail("Result should be .continueSignInWithTOTPSetup for next step") + return + } + XCTAssertEqual(totpDetails.sharedSecret, "sharedSecret") + XCTAssertEqual(totpDetails.username, "username") + XCTAssertFalse(result2.isSignedIn, "Signin result should be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + } + + // MARK: - Service error for initiateAuth + + /// Test a signIn with `InternalErrorException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// InternalErrorException response for signIn + /// + /// - When: + /// - I invoke signIn + /// - Then: + /// - I should get a .unknown error + /// + func testSignInWithInternalErrorException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\"]"]) + }, mockAssociateSoftwareTokenResponse: { input in + throw AssociateSoftwareTokenOutputError.internalErrorException(.init()) + }) + + let options = AuthSignInRequest.Options() + do { + let result = try await plugin.signIn(username: "username", password: "password", options: options) + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.unknown = error else { + XCTFail("Should produce unknown error") + return + } + } + } + + /// Test a signIn with `InvalidParameterException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// InvalidParameterException response for signIn + /// + /// - When: + /// - I invoke signIn + /// - Then: + /// - I should get a .service error with .invalidParameter error + /// + func testSignInWithInvalidParameterException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\"]"]) + }, mockAssociateSoftwareTokenResponse: { input in + throw AssociateSoftwareTokenOutputError.invalidParameterException(.init()) + }) + + let options = AuthSignInRequest.Options() + do { + let result = try await plugin.signIn(username: "username", password: "password", options: options) + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error, + case .invalidParameter = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Should produce invalidParameter error but instead produced \(error)") + return + } + } + } + + /// Test a signIn with `NotAuthorizedException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// NotAuthorizedException response for signIn + /// + /// - When: + /// - I invoke signIn + /// - Then: + /// - I should get a .notAuthorized error + /// + func testSignInWithNotAuthorizedException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\"]"]) + }, mockAssociateSoftwareTokenResponse: { input in + throw AssociateSoftwareTokenOutputError.notAuthorizedException(.init()) + }) + + let options = AuthSignInRequest.Options() + do { + let result = try await plugin.signIn(username: "username", password: "password", options: options) + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.notAuthorized = error else { + XCTFail("Should produce notAuthorized error but instead produced \(error)") + return + } + } + } + + /// Test a signIn with `ResourceNotFoundException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// ResourceNotFoundException response for signIn + /// + /// - When: + /// - I invoke signIn + /// - Then: + /// - I should get a .service error with .resourceNotFound error + /// + func testSignInWithResourceNotFoundException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\"]"]) + }, mockAssociateSoftwareTokenResponse: { input in + throw AssociateSoftwareTokenOutputError.resourceNotFoundException(.init()) + }) + + let options = AuthSignInRequest.Options() + do { + let result = try await plugin.signIn(username: "username", password: "password", options: options) + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error, + case .resourceNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Should produce resourceNotFound error but instead produced \(error)") + return + } + } + } + + /// Test a signIn with `ResourceNotFoundException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// ConcurrentModificationException response for signIn + /// + /// - When: + /// - I invoke signIn + /// - Then: + /// - I should get a .service error + /// + func testSignInWithConcurrentModificationException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\"]"]) + }, mockAssociateSoftwareTokenResponse: { input in + throw AssociateSoftwareTokenOutputError.concurrentModificationException(.init()) + }) + + let options = AuthSignInRequest.Options() + do { + let result = try await plugin.signIn(username: "username", password: "password", options: options) + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.service(_, _, _) = error else { + XCTFail("Should produce service error but instead produced \(error)") + return + } + } + } + + /// Test a signIn with `ForbiddenException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// ForbiddenException response for signIn + /// + /// - When: + /// - I invoke signIn + /// - Then: + /// - I should get a .service error + /// + func testSignInWithForbiddenException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\"]"]) + }, mockAssociateSoftwareTokenResponse: { input in + throw AssociateSoftwareTokenOutputError.forbiddenException(.init()) + }) + + let options = AuthSignInRequest.Options() + do { + let result = try await plugin.signIn(username: "username", password: "password", options: options) + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.service(_, _, _) = error else { + XCTFail("Should produce service error but instead produced \(error)") + return + } + } + } + + /// Test a signIn with `SoftwareTokenMFANotFoundException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// SoftwareTokenMFANotFoundException response for signIn + /// + /// - When: + /// - I invoke signIn + /// - Then: + /// - I should get a .service error + /// + func testSignInWithSoftwareTokenMFANotFoundException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\"]"]) + }, mockAssociateSoftwareTokenResponse: { input in + throw AssociateSoftwareTokenOutputError.softwareTokenMFANotFoundException(.init()) + }) + + let options = AuthSignInRequest.Options() + do { + let result = try await plugin.signIn(username: "username", password: "password", options: options) + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error, + case .mfaMethodNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Should produce resourceNotFound error but instead produced \(error)") + return + } + } + } + + /// Test a signIn with `UnknownAWSHttpServiceError` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// UnknownAWSHttpServiceError response for signIn + /// + /// - When: + /// - I invoke signIn + /// - Then: + /// - I should get a .service error + /// + func testSignInWithUnknownAWSHttpServiceError() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutputResponse( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutputResponse.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\"]"]) + }, mockAssociateSoftwareTokenResponse: { input in + throw AssociateSoftwareTokenOutputError.unknown(.init(httpResponse: .init(body: .empty, statusCode: .accepted))) + }) + + let options = AuthSignInRequest.Options() + do { + let result = try await plugin.signIn(username: "username", password: "password", options: options) + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.unknown = error else { + XCTFail("Should produce resourceNotFound error but instead produced \(error)") + return + } + } + } +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthConfirmSignUpAPITests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpAPITests.swift similarity index 100% rename from AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthConfirmSignUpAPITests.swift rename to AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpAPITests.swift diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthConfirmSignUpTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpTaskTests.swift similarity index 100% rename from AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthConfirmSignUpTaskTests.swift rename to AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpTaskTests.swift diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthResendSignUpCodeAPITests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthResendSignUpCodeAPITests.swift similarity index 99% rename from AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthResendSignUpCodeAPITests.swift rename to AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthResendSignUpCodeAPITests.swift index 9c12f3e22d..d8cf555e19 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthResendSignUpCodeAPITests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthResendSignUpCodeAPITests.swift @@ -53,7 +53,7 @@ class AWSAuthResendSignUpCodeAPITests: AWSCognitoAuthClientBehaviorTests { /// Test a successful resendSignUpCode call with .email as the destination of AuthCodeDeliveryDetails /// - /// - Given: Given an auth plugin with mocked service. Mocked service calls should mock a successul response + /// - Given: Given an auth plugin with mocked service. Mocked service calls should mock a successful response /// - When: /// - I invoke resendSignUpCode with username /// - Then: diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthSignUpAPITests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpAPITests.swift similarity index 100% rename from AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthSignUpAPITests.swift rename to AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpAPITests.swift diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthSignUpTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpTaskTests.swift similarity index 100% rename from AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthSignUpTaskTests.swift rename to AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpTaskTests.swift diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/DeviceBehaviorTests/DeviceBehaviorFetchDevicesTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/DeviceBehaviorTests/DeviceBehaviorFetchDevicesTests.swift index 8663a39fcd..f9893f0f7a 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/DeviceBehaviorTests/DeviceBehaviorFetchDevicesTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/DeviceBehaviorTests/DeviceBehaviorFetchDevicesTests.swift @@ -62,7 +62,7 @@ class DeviceBehaviorFetchDevicesTests: BasePluginTest { /// Test a successful fetchDevices call /// - /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response /// - When: /// - I invoke fetchDevices /// - Then: diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/DeviceBehaviorTests/DeviceBehaviorForgetDeviceTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/DeviceBehaviorTests/DeviceBehaviorForgetDeviceTests.swift index 47fa02216d..7b2d57ed4d 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/DeviceBehaviorTests/DeviceBehaviorForgetDeviceTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/DeviceBehaviorTests/DeviceBehaviorForgetDeviceTests.swift @@ -27,7 +27,7 @@ class DeviceBehaviorForgetDeviceTests: BasePluginTest { /// Test a successful forgetDevice call /// - /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successull response + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response /// - When: /// - I invoke forgetDevice /// - Then: @@ -39,7 +39,7 @@ class DeviceBehaviorForgetDeviceTests: BasePluginTest { /// Test a successful forgetDevice call /// - /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response /// - When: /// - I invoke forgetDevice with an AWSAuthDevice /// - Then: diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/DeviceBehaviorTests/DeviceBehaviorRememberDeviceTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/DeviceBehaviorTests/DeviceBehaviorRememberDeviceTests.swift index d5ed704feb..6d1126ab79 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/DeviceBehaviorTests/DeviceBehaviorRememberDeviceTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/DeviceBehaviorTests/DeviceBehaviorRememberDeviceTests.swift @@ -52,7 +52,7 @@ class DeviceBehaviorRememberDeviceTests: BasePluginTest { /// Test a successful rememberDevice call /// - /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response /// - When: /// - I invoke rememberDevice /// - Then: diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorChangePasswordTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorChangePasswordTests.swift index cd654710c8..3bd2c488f8 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorChangePasswordTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorChangePasswordTests.swift @@ -17,7 +17,7 @@ class UserBehaviorChangePasswordTests: BasePluginTest { /// Test a successful changePassword call /// - /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response /// - When: /// - I invoke changePassword with old password and new password /// - Then: diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorConfirmAttributeTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorConfirmAttributeTests.swift index c723811cf8..9d202f27c2 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorConfirmAttributeTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorConfirmAttributeTests.swift @@ -15,7 +15,7 @@ class UserBehaviorConfirmAttributeTests: BasePluginTest { /// Test a successful confirmUpdateUserAttributes call /// - /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response /// - When: /// - I invoke confirmUpdateUserAttributes with confirmation code /// - Then: diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorFetchAttributeTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorFetchAttributeTests.swift index b360f8c8bd..89ed57376e 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorFetchAttributeTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorFetchAttributeTests.swift @@ -15,7 +15,7 @@ class UserBehaviorFetchAttributesTests: BasePluginTest { /// Test a successful fetchUserAttributes call with .done as next step /// - /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response /// - When: /// - I invoke fetchUserAttributes /// - Then: diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorResendCodeTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorResendCodeTests.swift index cbf605cf31..53952ac4c6 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorResendCodeTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorResendCodeTests.swift @@ -17,7 +17,7 @@ class UserBehaviorResendCodeTests: BasePluginTest { /// Test a successful resendConfirmationCode call with .done as next step /// - /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response /// - When: /// - I invoke resendConfirmationCode /// - Then: diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorUpdateAttributeTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorUpdateAttributeTests.swift index 6dbc1f60aa..e2aac23bdf 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorUpdateAttributeTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/UserBehaviorUpdateAttributeTests.swift @@ -15,7 +15,7 @@ class UserBehaviorUpdateAttributesTests: BasePluginTest { /// Test a successful updateUserAttributes call with .done as next step /// - /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successul response + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response /// - When: /// - I invoke updateUserAttributes with AuthUserAttribute /// - Then: @@ -205,7 +205,7 @@ class UserBehaviorUpdateAttributesTests: BasePluginTest { /// - When: /// - I invoke updateUserAttributes with AuthUserAttribute /// - Then: - /// - I should get a -- + /// - I should get a .service error with .emailRole as underlyingError /// func testUpdateUserAttributesWithInvalidEmailRoleAccessPolicyException() async throws { mockIdentityProvider = MockIdentityProvider(mockUpdateUserAttributeResponse: { _ in @@ -291,7 +291,7 @@ class UserBehaviorUpdateAttributesTests: BasePluginTest { /// - When: /// - I invoke updateUserAttributes with AuthUserAttribute /// - Then: - /// - I should get a -- + /// - I should get a .service error with .smsRole as underlyingError /// func testUpdateUserAttributesWithinvalidSmsRoleAccessPolicyException() async throws { mockIdentityProvider = MockIdentityProvider(mockUpdateUserAttributeResponse: { _ in @@ -319,7 +319,7 @@ class UserBehaviorUpdateAttributesTests: BasePluginTest { /// - When: /// - I invoke updateUserAttributes with AuthUserAttribute /// - Then: - /// - I should get a -- + /// - I should get a .service error with .smsRole as underlyingError /// func testUpdateUserAttributesCodeWithInvalidSmsRoleTrustRelationshipException() async throws { mockIdentityProvider = MockIdentityProvider(mockUpdateUserAttributeResponse: { _ in diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/project.pbxproj b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/project.pbxproj index 44dc529162..5b11c93bf4 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/project.pbxproj +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/project.pbxproj @@ -10,10 +10,12 @@ 4821B2F2286B5F74000EC1D7 /* AuthDeleteUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4821B2F1286B5F74000EC1D7 /* AuthDeleteUserTests.swift */; }; 4821B2F428737130000EC1D7 /* AuthCustomSignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4821B2F328737130000EC1D7 /* AuthCustomSignInTests.swift */; }; 4834D7C128B0770800DD564B /* FederatedSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4834D7C028B0770800DD564B /* FederatedSessionTests.swift */; }; + 483B0D342A42BB1400A1196B /* TOTPSetupWhenUnauthenticatedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 483B0D332A42BB1400A1196B /* TOTPSetupWhenUnauthenticatedTests.swift */; }; 484834BC27B6ED8800649D11 /* CredentialStoreConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 484834BB27B6ED8700649D11 /* CredentialStoreConfigurationTests.swift */; }; 484834BE27B6FD9B00649D11 /* AuthEnvironmentHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 484834BD27B6FD9B00649D11 /* AuthEnvironmentHelper.swift */; }; 484EDEB227F4FFBE000284B4 /* AuthEventIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 484EDEB127F4FFBE000284B4 /* AuthEventIntegrationTests.swift */; }; 485105AA2840513C002D6FC8 /* AuthUserAttributesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485105A92840513C002D6FC8 /* AuthUserAttributesTests.swift */; }; + 48599D4A2A429893009DE21C /* MFASignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48599D492A429893009DE21C /* MFASignInTests.swift */; }; 485CB53E27B614CE006CCEC7 /* AuthHostAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485CB53D27B614CE006CCEC7 /* AuthHostAppApp.swift */; }; 485CB54027B614CE006CCEC7 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485CB53F27B614CE006CCEC7 /* ContentView.swift */; }; 485CB54227B614CF006CCEC7 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 485CB54127B614CF006CCEC7 /* Assets.xcassets */; }; @@ -26,6 +28,14 @@ 485CB5C027B61F1E006CCEC7 /* SignedOutAuthSessionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485CB5BC27B61F1D006CCEC7 /* SignedOutAuthSessionTests.swift */; }; 485CB5C127B61F1E006CCEC7 /* AuthSignOutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485CB5BD27B61F1D006CCEC7 /* AuthSignOutTests.swift */; }; 485CB5C227B61F1E006CCEC7 /* AuthSRPSignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485CB5BE27B61F1D006CCEC7 /* AuthSRPSignInTests.swift */; }; + 48916F382A412B2800E3E1B1 /* TOTPSetupWhenAuthenticatedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48916F372A412B2800E3E1B1 /* TOTPSetupWhenAuthenticatedTests.swift */; }; + 48916F3A2A412CEE00E3E1B1 /* TOTPHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48916F392A412CEE00E3E1B1 /* TOTPHelper.swift */; }; + 48916F3C2A42333E00E3E1B1 /* MFAPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48916F3B2A42333E00E3E1B1 /* MFAPreferenceTests.swift */; }; + 48BCE8922A5456460012C3CD /* TOTPSetupWhenAuthenticatedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48916F372A412B2800E3E1B1 /* TOTPSetupWhenAuthenticatedTests.swift */; }; + 48BCE8932A54564C0012C3CD /* TOTPSetupWhenUnauthenticatedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 483B0D332A42BB1400A1196B /* TOTPSetupWhenUnauthenticatedTests.swift */; }; + 48BCE8942A54564C0012C3CD /* MFASignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48599D492A429893009DE21C /* MFASignInTests.swift */; }; + 48BCE8952A54564C0012C3CD /* MFAPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48916F3B2A42333E00E3E1B1 /* MFAPreferenceTests.swift */; }; + 48BCE8962A5456600012C3CD /* TOTPHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48916F392A412CEE00E3E1B1 /* TOTPHelper.swift */; }; 48E3AB3128E52590004EE395 /* GetCurrentUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E3AB3028E52590004EE395 /* GetCurrentUserTests.swift */; }; 681B76952A3CB8DA004B59D9 /* AuthHostAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485CB53D27B614CE006CCEC7 /* AuthHostAppApp.swift */; }; 681B76962A3CB8DD004B59D9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485CB53F27B614CE006CCEC7 /* ContentView.swift */; }; @@ -118,10 +128,12 @@ 4821B2F1286B5F74000EC1D7 /* AuthDeleteUserTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthDeleteUserTests.swift; sourceTree = ""; }; 4821B2F328737130000EC1D7 /* AuthCustomSignInTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthCustomSignInTests.swift; sourceTree = ""; }; 4834D7C028B0770800DD564B /* FederatedSessionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FederatedSessionTests.swift; sourceTree = ""; }; + 483B0D332A42BB1400A1196B /* TOTPSetupWhenUnauthenticatedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPSetupWhenUnauthenticatedTests.swift; sourceTree = ""; }; 484834BB27B6ED8700649D11 /* CredentialStoreConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CredentialStoreConfigurationTests.swift; sourceTree = ""; }; 484834BD27B6FD9B00649D11 /* AuthEnvironmentHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthEnvironmentHelper.swift; sourceTree = ""; }; 484EDEB127F4FFBE000284B4 /* AuthEventIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthEventIntegrationTests.swift; sourceTree = ""; }; 485105A92840513C002D6FC8 /* AuthUserAttributesTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthUserAttributesTests.swift; sourceTree = ""; }; + 48599D492A429893009DE21C /* MFASignInTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MFASignInTests.swift; sourceTree = ""; }; 485CB53A27B614CE006CCEC7 /* AuthHostApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AuthHostApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 485CB53D27B614CE006CCEC7 /* AuthHostAppApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthHostAppApp.swift; sourceTree = ""; }; 485CB53F27B614CE006CCEC7 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -136,6 +148,9 @@ 485CB5BC27B61F1D006CCEC7 /* SignedOutAuthSessionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SignedOutAuthSessionTests.swift; sourceTree = ""; }; 485CB5BD27B61F1D006CCEC7 /* AuthSignOutTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthSignOutTests.swift; sourceTree = ""; }; 485CB5BE27B61F1D006CCEC7 /* AuthSRPSignInTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthSRPSignInTests.swift; sourceTree = ""; }; + 48916F372A412B2800E3E1B1 /* TOTPSetupWhenAuthenticatedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPSetupWhenAuthenticatedTests.swift; sourceTree = ""; }; + 48916F392A412CEE00E3E1B1 /* TOTPHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TOTPHelper.swift; sourceTree = ""; }; + 48916F3B2A42333E00E3E1B1 /* MFAPreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MFAPreferenceTests.swift; sourceTree = ""; }; 48E3AB3028E52590004EE395 /* GetCurrentUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetCurrentUserTests.swift; sourceTree = ""; }; 681B76802A3CB86B004B59D9 /* AuthWatchApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AuthWatchApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 681B76C42A3CBBAE004B59D9 /* AuthIntegrationTestsWatch.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AuthIntegrationTestsWatch.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -288,6 +303,7 @@ 485CB5A027B61E04006CCEC7 /* AuthIntegrationTests */ = { isa = PBXGroup; children = ( + 48916F362A412AF800E3E1B1 /* MFATests */, 97B370C32878DA3500F1C088 /* DeviceTests */, 4821B2F0286B5F74000EC1D7 /* AuthDeleteUserTests */, 485105A82840510B002D6FC8 /* AuthUserAttributesTests */, @@ -311,6 +327,7 @@ 485CB5B727B61F0F006CCEC7 /* AuthSessionHelper.swift */, 485CB5B827B61F0F006CCEC7 /* AuthSignInHelper.swift */, 484834BD27B6FD9B00649D11 /* AuthEnvironmentHelper.swift */, + 48916F392A412CEE00E3E1B1 /* TOTPHelper.swift */, ); path = Helpers; sourceTree = ""; @@ -351,6 +368,17 @@ name = Packages; sourceTree = ""; }; + 48916F362A412AF800E3E1B1 /* MFATests */ = { + isa = PBXGroup; + children = ( + 48916F372A412B2800E3E1B1 /* TOTPSetupWhenAuthenticatedTests.swift */, + 48916F3B2A42333E00E3E1B1 /* MFAPreferenceTests.swift */, + 48599D492A429893009DE21C /* MFASignInTests.swift */, + 483B0D332A42BB1400A1196B /* TOTPSetupWhenUnauthenticatedTests.swift */, + ); + path = MFATests; + sourceTree = ""; + }; 681DFEA728E747B80000C36A /* AsyncTesting */ = { isa = PBXGroup; children = ( @@ -678,10 +706,13 @@ 485CB5C227B61F1E006CCEC7 /* AuthSRPSignInTests.swift in Sources */, 9737C7502880BFD600DA0D2B /* AuthForgetDeviceTests.swift in Sources */, B43C26CB27BC9D54003F3BF7 /* AuthConfirmSignUpTests.swift in Sources */, + 48916F3C2A42333E00E3E1B1 /* MFAPreferenceTests.swift in Sources */, 485CB5C127B61F1E006CCEC7 /* AuthSignOutTests.swift in Sources */, 97B370C52878DA5A00F1C088 /* AuthFetchDeviceTests.swift in Sources */, + 483B0D342A42BB1400A1196B /* TOTPSetupWhenUnauthenticatedTests.swift in Sources */, 681DFEAC28E747B80000C36A /* AsyncExpectation.swift in Sources */, 48E3AB3128E52590004EE395 /* GetCurrentUserTests.swift in Sources */, + 48916F3A2A412CEE00E3E1B1 /* TOTPHelper.swift in Sources */, 485CB5B127B61EAC006CCEC7 /* AWSAuthBaseTest.swift in Sources */, 485CB5C027B61F1E006CCEC7 /* SignedOutAuthSessionTests.swift in Sources */, 485CB5BA27B61F10006CCEC7 /* AuthSignInHelper.swift in Sources */, @@ -689,12 +720,14 @@ 4821B2F428737130000EC1D7 /* AuthCustomSignInTests.swift in Sources */, 484EDEB227F4FFBE000284B4 /* AuthEventIntegrationTests.swift in Sources */, 484834BE27B6FD9B00649D11 /* AuthEnvironmentHelper.swift in Sources */, + 48916F382A412B2800E3E1B1 /* TOTPSetupWhenAuthenticatedTests.swift in Sources */, 484834BC27B6ED8800649D11 /* CredentialStoreConfigurationTests.swift in Sources */, 9737C74E287E208400DA0D2B /* AuthRememberDeviceTests.swift in Sources */, 681DFEAD28E747B80000C36A /* XCTestCase+AsyncTesting.swift in Sources */, B43C26CC27BC9D54003F3BF7 /* AuthResendSignUpCodeTests.swift in Sources */, 97829201286B802E000DE190 /* AuthResetPasswordTests.swift in Sources */, 485105AA2840513C002D6FC8 /* AuthUserAttributesTests.swift in Sources */, + 48599D4A2A429893009DE21C /* MFASignInTests.swift in Sources */, 485CB5BF27B61F1E006CCEC7 /* SignedInAuthSessionTests.swift in Sources */, B43C26CA27BC9D54003F3BF7 /* AuthSignUpTests.swift in Sources */, 97829203286E41FA000DE190 /* AuthConfirmResetPasswordTests.swift in Sources */, @@ -722,8 +755,12 @@ 681B76A72A3CBBAE004B59D9 /* AuthConfirmSignUpTests.swift in Sources */, 681B76A82A3CBBAE004B59D9 /* AuthSignOutTests.swift in Sources */, 681B76A92A3CBBAE004B59D9 /* AuthFetchDeviceTests.swift in Sources */, + 48BCE8922A5456460012C3CD /* TOTPSetupWhenAuthenticatedTests.swift in Sources */, + 48BCE8962A5456600012C3CD /* TOTPHelper.swift in Sources */, 681B76AA2A3CBBAE004B59D9 /* AsyncExpectation.swift in Sources */, + 48BCE8932A54564C0012C3CD /* TOTPSetupWhenUnauthenticatedTests.swift in Sources */, 681B76AB2A3CBBAE004B59D9 /* GetCurrentUserTests.swift in Sources */, + 48BCE8952A54564C0012C3CD /* MFAPreferenceTests.swift in Sources */, 681B76AC2A3CBBAE004B59D9 /* AWSAuthBaseTest.swift in Sources */, 681B76AD2A3CBBAE004B59D9 /* SignedOutAuthSessionTests.swift in Sources */, 681B76AE2A3CBBAE004B59D9 /* AuthSignInHelper.swift in Sources */, @@ -740,6 +777,7 @@ 681B76B92A3CBBAE004B59D9 /* SignedInAuthSessionTests.swift in Sources */, 681B76BA2A3CBBAE004B59D9 /* AuthSignUpTests.swift in Sources */, 681B76BB2A3CBBAE004B59D9 /* AuthConfirmResetPasswordTests.swift in Sources */, + 48BCE8942A54564C0012C3CD /* MFASignInTests.swift in Sources */, 681B76BC2A3CBBAE004B59D9 /* AuthDeleteUserTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthHostApp.xcscheme b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthHostApp.xcscheme index 68ca8e9755..1b31043b6d 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthHostApp.xcscheme +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthHostApp.xcscheme @@ -26,7 +26,18 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> + shouldUseLaunchSchemeArgsEnv = "YES" + codeCoverageEnabled = "YES" + onlyGenerateCoverageForSpecifiedTargets = "YES"> + + + + @@ -37,27 +48,13 @@ BlueprintName = "AuthIntegrationTests" ReferencedContainer = "container:AuthHostApp.xcodeproj"> - - - - - - - - - - diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthIntegrationTests.xcscheme b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthIntegrationTests.xcscheme index 2dc31b5e62..8eab2c02d5 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthIntegrationTests.xcscheme +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthIntegrationTests.xcscheme @@ -11,7 +11,17 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" - enableThreadSanitizer = "YES"> + enableThreadSanitizer = "YES" + codeCoverageEnabled = "YES"> + + + + @@ -22,20 +32,6 @@ BlueprintName = "AuthIntegrationTests" ReferencedContainer = "container:AuthHostApp.xcodeproj"> - - - - - - - - - - diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthIntegrationTestsWatch.xcscheme b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthIntegrationTestsWatch.xcscheme index dd27cdb7b8..429ffa9878 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthIntegrationTestsWatch.xcscheme +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthIntegrationTestsWatch.xcscheme @@ -11,7 +11,8 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES" - enableThreadSanitizer = "YES"> + enableThreadSanitizer = "YES" + codeCoverageEnabled = "YES"> @@ -22,20 +23,6 @@ BlueprintName = "AuthIntegrationTestsWatch" ReferencedContainer = "container:AuthHostApp.xcodeproj"> - - - - - - - - - - diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthWatchApp.xcscheme b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthWatchApp.xcscheme new file mode 100644 index 0000000000..515e863b96 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/xcshareddata/xcschemes/AuthWatchApp.xcscheme @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/AWSAuthBaseTest.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/AWSAuthBaseTest.swift index 6e5c45cde2..a27ffc9fbf 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/AWSAuthBaseTest.swift +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/AWSAuthBaseTest.swift @@ -16,7 +16,7 @@ class AWSAuthBaseTest: XCTestCase { var defaultTestEmail = "test-\(UUID().uuidString)@amazon.com" var defaultTestPassword = UUID().uuidString - let amplifyConfigurationFile = "testconfiguration/AWSCognitoAuthPluginIntegrationTests-amplifyconfiguration" + var amplifyConfigurationFile = "testconfiguration/AWSCognitoAuthPluginIntegrationTests-amplifyconfiguration" let credentialsFile = "testconfiguration/AWSCognitoAuthPluginIntegrationTests-credentials" var amplifyConfiguration: AmplifyConfiguration! diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/AuthSignInHelper.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/AuthSignInHelper.swift index 66b2d71141..d27dd5fbdb 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/AuthSignInHelper.swift +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/AuthSignInHelper.swift @@ -17,22 +17,43 @@ enum AuthSignInHelper { } } - static func signUpUser(username: String, password: String, email: String) async throws -> Bool { - let options = AuthSignUpRequest.Options(userAttributes: [AuthUserAttribute(.email, value: email)]) - let result = try await Amplify.Auth.signUp(username: username, password: password, options: options) - return result.isSignUpComplete - } + static func signUpUser( + username: String, + password: String, + email: String, + phoneNumber: String? = nil) async throws -> Bool { + + var userAttributes = [ + AuthUserAttribute(.email, value: email) + ] + if let phoneNumber = phoneNumber { + userAttributes.append(AuthUserAttribute(.phoneNumber, value: phoneNumber)) + } + + let options = AuthSignUpRequest.Options( + userAttributes: userAttributes) + let result = try await Amplify.Auth.signUp(username: username, password: password, options: options) + return result.isSignUpComplete + } static func signInUser(username: String, password: String) async throws -> AuthSignInResult { return try await Amplify.Auth.signIn(username: username, password: password, options: nil) } - static func registerAndSignInUser(username: String, password: String, email: String) async throws -> Bool { - let signedUp = try await AuthSignInHelper.signUpUser(username: username, password: password, email: email) - guard signedUp else { - throw AuthError.invalidState("Auth sign up failed", "", nil) + static func registerAndSignInUser( + username: String, + password: String, + email: String, + phoneNumber: String? = nil) async throws -> Bool { + let signedUp = try await AuthSignInHelper.signUpUser( + username: username, + password: password, + email: email, + phoneNumber: phoneNumber) + guard signedUp else { + throw AuthError.invalidState("Auth sign up failed", "", nil) + } + let result = try await AuthSignInHelper.signInUser(username: username, password: password) + return result.isSignedIn } - let result = try await AuthSignInHelper.signInUser(username: username, password: password) - return result.isSignedIn - } } diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/TOTPHelper.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/TOTPHelper.swift new file mode 100644 index 0000000000..f7c2203689 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/TOTPHelper.swift @@ -0,0 +1,190 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import CryptoKit +import XCTest + +struct TOTPHelper { + + static func generateTOTPCode(sharedSecret: String) -> String { + + // TOTP Code generation configuration for Cognito + let date = Date() + let period = TimeInterval(30) + let digits = 6 + guard let secretKeyData = base32DecodeToData(sharedSecret) else { + fatalError("unable to decode to base 32") + } + + return otpCode(secret: secretKeyData, date: date, period: period, digits: digits) + } + + private static func otpCode(secret: Data, date: Date, period: TimeInterval, digits: Int) -> String { + let counter = UInt64(date.timeIntervalSince1970 / period) + let counterBytes = (0..<8).reversed().map { UInt8(counter >> (8 * $0) & 0xff) } + let hash = HMAC.authenticationCode( + for: counterBytes, + using: SymmetricKey(data: secret)) + let offset = Int(hash.suffix(1)[0] & 0x0f) + let hash32 = hash + .dropFirst(offset) + .prefix(4) + .reduce(0, { ($0 << 8) | UInt32($1) }) + let hash31 = hash32 & 0x7FFF_FFFF + let pad = String(repeating: "0", count: digits) + return String((pad + String(hash31)).suffix(digits)) + } + + //MARK: Helper methods to create base32 encoded value for shared secret + + private static let __: UInt8 = 255 + private static let alphabetDecodeTable: [UInt8] = [ + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x00 - 0x0F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x10 - 0x1F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x20 - 0x2F + __,__,26,27, 28,29,30,31, __,__,__,__, __,__,__,__, // 0x30 - 0x3F + __, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11,12,13,14, // 0x40 - 0x4F + 15,16,17,18, 19,20,21,22, 23,24,25,__, __,__,__,__, // 0x50 - 0x5F + __, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11,12,13,14, // 0x60 - 0x6F + 15,16,17,18, 19,20,21,22, 23,24,25,__, __,__,__,__, // 0x70 - 0x7F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x80 - 0x8F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0x90 - 0x9F + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xA0 - 0xAF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xB0 - 0xBF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xC0 - 0xCF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xD0 - 0xDF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xE0 - 0xEF + __,__,__,__, __,__,__,__, __,__,__,__, __,__,__,__, // 0xF0 - 0xFF + ] + + private static func base32decode(_ string: String, _ table: [UInt8]) -> [UInt8]? { + let length = string.unicodeScalars.count + if length == 0 { + return [] + } + + // calc padding length + func getLeastPaddingLength(_ string: String) -> Int { + if string.hasSuffix("======") { + return 6 + } else if string.hasSuffix("====") { + return 4 + } else if string.hasSuffix("===") { + return 3 + } else if string.hasSuffix("=") { + return 1 + } else { + return 0 + } + } + + // validate string + let leastPaddingLength = getLeastPaddingLength(string) + if let index = string.unicodeScalars.firstIndex(where: {$0.value > 0xff || table[Int($0.value)] > 31}) { + // index points padding "=" or invalid character that table does not contain. + let pos = string.unicodeScalars.distance(from: string.unicodeScalars.startIndex, to: index) + // if pos points padding "=", it's valid. + if pos != length - leastPaddingLength { + print("string contains some invalid characters.") + return nil + } + } + + var remainEncodedLength = length - leastPaddingLength + var additionalBytes = 0 + switch remainEncodedLength % 8 { + // valid + case 0: break + case 2: additionalBytes = 1 + case 4: additionalBytes = 2 + case 5: additionalBytes = 3 + case 7: additionalBytes = 4 + default: + print("string length is invalid.") + return nil + } + + // validated + let dataSize = remainEncodedLength / 8 * 5 + additionalBytes + + // Use UnsafePointer + return string.utf8CString.withUnsafeBufferPointer { + (data: UnsafeBufferPointer) -> [UInt8] in + var encoded = data.baseAddress! + + var result = Array(repeating: 0, count: dataSize) + var decodedOffset = 0 + + // decode regular blocks + var value0, value1, value2, value3, value4, value5, value6, value7: UInt8 + (value0, value1, value2, value3, value4, value5, value6, value7) = (0,0,0,0,0,0,0,0) + while remainEncodedLength >= 8 { + value0 = table[Int(encoded[0])] + value1 = table[Int(encoded[1])] + value2 = table[Int(encoded[2])] + value3 = table[Int(encoded[3])] + value4 = table[Int(encoded[4])] + value5 = table[Int(encoded[5])] + value6 = table[Int(encoded[6])] + value7 = table[Int(encoded[7])] + + result[decodedOffset] = value0 << 3 | value1 >> 2 + result[decodedOffset + 1] = value1 << 6 | value2 << 1 | value3 >> 4 + result[decodedOffset + 2] = value3 << 4 | value4 >> 1 + result[decodedOffset + 3] = value4 << 7 | value5 << 2 | value6 >> 3 + result[decodedOffset + 4] = value6 << 5 | value7 + + remainEncodedLength -= 8 + decodedOffset += 5 + encoded = encoded.advanced(by: 8) + } + + // decode last block + (value0, value1, value2, value3, value4, value5, value6, value7) = (0,0,0,0,0,0,0,0) + switch remainEncodedLength { + case 7: + value6 = table[Int(encoded[6])] + value5 = table[Int(encoded[5])] + fallthrough + case 5: + value4 = table[Int(encoded[4])] + fallthrough + case 4: + value3 = table[Int(encoded[3])] + value2 = table[Int(encoded[2])] + fallthrough + case 2: + value1 = table[Int(encoded[1])] + value0 = table[Int(encoded[0])] + default: break + } + switch remainEncodedLength { + case 7: + result[decodedOffset + 3] = value4 << 7 | value5 << 2 | value6 >> 3 + fallthrough + case 5: + result[decodedOffset + 2] = value3 << 4 | value4 >> 1 + fallthrough + case 4: + result[decodedOffset + 1] = value1 << 6 | value2 << 1 | value3 >> 4 + fallthrough + case 2: + result[decodedOffset] = value0 << 3 | value1 >> 2 + default: break + } + + return result + } + } + + private static func base32DecodeToData(_ string: String) -> Data? { + return base32decode(string, alphabetDecodeTable).flatMap { + $0.withUnsafeBufferPointer(Data.init(buffer:)) + } + } + +} diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/MFAPreferenceTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/MFAPreferenceTests.swift new file mode 100644 index 0000000000..abcb9a12ad --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/MFAPreferenceTests.swift @@ -0,0 +1,311 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import Amplify +import AWSCognitoAuthPlugin + +class MFAPreferenceTests: AWSAuthBaseTest { + + override func setUp() async throws { + try await super.setUp() + AuthSessionHelper.clearSession() + } + + override func tearDown() async throws { + // Clean up user + try await Amplify.Auth.deleteUser() + try await super.tearDown() + AuthSessionHelper.clearSession() + } + + func signUpAndSignIn(phoneNumber: String? = nil) async throws { + let username = "integTest\(UUID().uuidString)" + let password = "P123@\(UUID().uuidString)" + + let didSucceed = try await AuthSignInHelper.registerAndSignInUser( + username: username, + password: password, + email: defaultTestEmail, + phoneNumber: phoneNumber) + + XCTAssertTrue(didSucceed, "Signup and sign in should succeed") + } + + /// Test successful call to fetchMFAPreference API + /// + /// - Given: A newly signed up user in Cognito user pool + /// - When: + /// - I invoke fetchMFAPreference API + /// - Then: + /// - I should get empty preference results + /// + func testFetchEmptyMFAPreference() async throws { + do { + try await signUpAndSignIn() + + let authCognitoPlugin = try Amplify.Auth.getPlugin( + for: "awsCognitoAuthPlugin") as! AWSCognitoAuthPlugin + + let fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNil(fetchMFAResult.enabled) + XCTAssertNil(fetchMFAResult.preferred) + } catch { + XCTFail("API should succeed without any errors instead failed with \(error)") + } + } + + /// Test successful call to fetchMFAPreference and updateMFAPreference API for TOTP + /// + /// - Given: A newly signed up user in Cognito user pool + /// - When: + /// - I invoke fetchMFAPreference and updateMFAPreference API under various conditions + /// - Then: + /// - I should get valid fetchMFAPreference results corresponding to the updateMFAPreference + /// + func testFetchAndUpdateMFAPreferenceForTOTP() async throws { + do { + try await signUpAndSignIn() + + let authCognitoPlugin = try Amplify.Auth.getPlugin( + for: "awsCognitoAuthPlugin") as! AWSCognitoAuthPlugin + + var fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNil(fetchMFAResult.enabled) + XCTAssertNil(fetchMFAResult.preferred) + + let totpSetupDetails = try await Amplify.Auth.setUpTOTP() + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpSetupDetails.sharedSecret) + try await Amplify.Auth.verifyTOTPSetup(code: totpCode) + + // Test enabled + try await authCognitoPlugin.updateMFAPreference( + sms: nil, + totp: .enabled) + + fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNotNil(fetchMFAResult.enabled) + XCTAssertEqual(fetchMFAResult.enabled, [.totp]) + XCTAssertNil(fetchMFAResult.preferred) + + // Test preferred + try await authCognitoPlugin.updateMFAPreference( + sms: nil, + totp: .preferred) + + fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNotNil(fetchMFAResult.enabled) + XCTAssertEqual(fetchMFAResult.enabled, [.totp]) + XCTAssertNotNil(fetchMFAResult.preferred) + XCTAssertEqual(fetchMFAResult.preferred, .totp) + + // Test notPreferred + try await authCognitoPlugin.updateMFAPreference( + sms: nil, + totp: .notPreferred) + + fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNotNil(fetchMFAResult.enabled) + XCTAssertEqual(fetchMFAResult.enabled, [.totp]) + XCTAssertNil(fetchMFAResult.preferred) + + // Test disabled + try await authCognitoPlugin.updateMFAPreference( + sms: nil, + totp: .disabled) + + fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNil(fetchMFAResult.enabled) + XCTAssertNil(fetchMFAResult.preferred) + } catch { + XCTFail("API should succeed without any errors instead failed with \(error)") + } + } + + /// Test successful call to fetchMFAPreference and updateMFAPreference API for SMS + /// + /// - Given: A newly signed up user in Cognito user pool + /// - When: + /// - I invoke fetchMFAPreference and updateMFAPreference API under various conditions + /// - Then: + /// - I should get valid fetchMFAPreference results corresponding to the updateMFAPreference + /// + func testFetchAndUpdateMFAPreferenceForSMS() async throws { + do { + try await signUpAndSignIn(phoneNumber: "+16135550116") // Fake number for testing + + let authCognitoPlugin = try Amplify.Auth.getPlugin( + for: "awsCognitoAuthPlugin") as! AWSCognitoAuthPlugin + + var fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNil(fetchMFAResult.enabled) + XCTAssertNil(fetchMFAResult.preferred) + + // Test enabled + try await authCognitoPlugin.updateMFAPreference( + sms: .enabled, + totp: nil) + + fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNotNil(fetchMFAResult.enabled) + XCTAssertEqual(fetchMFAResult.enabled, [.sms]) + XCTAssertNil(fetchMFAResult.preferred) + + // Test preferred + try await authCognitoPlugin.updateMFAPreference( + sms: .preferred, + totp: nil) + + fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNotNil(fetchMFAResult.enabled) + XCTAssertEqual(fetchMFAResult.enabled, [.sms]) + XCTAssertNotNil(fetchMFAResult.preferred) + XCTAssertEqual(fetchMFAResult.preferred, .sms) + + // Test notPreferred + try await authCognitoPlugin.updateMFAPreference( + sms: .notPreferred, + totp: nil) + + fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNotNil(fetchMFAResult.enabled) + XCTAssertEqual(fetchMFAResult.enabled, [.sms]) + XCTAssertNil(fetchMFAResult.preferred) + + // Test disabled + try await authCognitoPlugin.updateMFAPreference( + sms: .disabled, + totp: nil) + + fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNil(fetchMFAResult.enabled) + XCTAssertNil(fetchMFAResult.preferred) + } catch { + XCTFail("API should succeed without any errors instead failed with \(error)") + } + } + + /// Test successful call to fetchMFAPreference and updateMFAPreference API for SMS and TOTP + /// + /// - Given: A newly signed up user in Cognito user pool + /// - When: + /// - I invoke fetchMFAPreference and updateMFAPreference API under various conditions + /// - Then: + /// - I should get valid fetchMFAPreference results corresponding to the updateMFAPreference + /// + func testFetchAndUpdateMFAPreferenceForSMSAndTOTP() async throws { + do { + try await signUpAndSignIn(phoneNumber: "+16135550116") // Fake number for testing + + let authCognitoPlugin = try Amplify.Auth.getPlugin( + for: "awsCognitoAuthPlugin") as! AWSCognitoAuthPlugin + + var fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNil(fetchMFAResult.enabled) + XCTAssertNil(fetchMFAResult.preferred) + + let totpSetupDetails = try await Amplify.Auth.setUpTOTP() + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpSetupDetails.sharedSecret) + try await Amplify.Auth.verifyTOTPSetup(code: totpCode) + + // Test both MFA types as enabled + try await authCognitoPlugin.updateMFAPreference( + sms: .enabled, + totp: .enabled) + + fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNotNil(fetchMFAResult.enabled) + XCTAssertEqual(fetchMFAResult.enabled, [.sms, .totp]) + XCTAssertNil(fetchMFAResult.preferred) + + // Test SMS as preferred, TOTP as enabled + try await authCognitoPlugin.updateMFAPreference( + sms: .preferred, + totp: .enabled) + + fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNotNil(fetchMFAResult.enabled) + XCTAssertEqual(fetchMFAResult.enabled, [.sms, .totp]) + XCTAssertNotNil(fetchMFAResult.preferred) + XCTAssertEqual(fetchMFAResult.preferred, .sms) + + // Test SMS as notPreferred, TOTP as preferred + try await authCognitoPlugin.updateMFAPreference( + sms: .notPreferred, + totp: .preferred) + + fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNotNil(fetchMFAResult.enabled) + XCTAssertEqual(fetchMFAResult.enabled, [.sms, .totp]) + XCTAssertNotNil(fetchMFAResult.preferred) + XCTAssertEqual(fetchMFAResult.preferred, .totp) + + // Test SMS as disabled, no change to TOTP + try await authCognitoPlugin.updateMFAPreference( + sms: .disabled, + totp: nil) + + fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNotNil(fetchMFAResult.enabled) + XCTAssertEqual(fetchMFAResult.enabled, [.totp]) + XCTAssertNotNil(fetchMFAResult.preferred) + XCTAssertEqual(fetchMFAResult.preferred, .totp) + + // Test SMS as preferred, no change to TOTP (which should remove TOTP from preferred list) + try await authCognitoPlugin.updateMFAPreference( + sms: .preferred, + totp: nil) + + fetchMFAResult = try await authCognitoPlugin.fetchMFAPreference() + XCTAssertNotNil(fetchMFAResult.enabled) + XCTAssertEqual(fetchMFAResult.enabled, [.sms, .totp]) + XCTAssertNotNil(fetchMFAResult.preferred) + XCTAssertEqual(fetchMFAResult.preferred, .sms) + } catch { + XCTFail("API should succeed without any errors instead failed with \(error)") + } + } + + /// Test invalidParameter exception in updateMFAPreference API + /// + /// - Given: A newly signed up user in Cognito user pool + /// - When: + /// - I invoke updateMFAPreference API with both MFA types as preferred + /// - Then: + /// - I should get an invalid parameter exception as only one MFA method can be set to preferred + /// + func testSMSAndTOTPMarkedAsPreferred() async throws { + do { + try await signUpAndSignIn(phoneNumber: "+16135550116") // Fake number for testing + + let authCognitoPlugin = try Amplify.Auth.getPlugin( + for: "awsCognitoAuthPlugin") as! AWSCognitoAuthPlugin + + let totpSetupDetails = try await Amplify.Auth.setUpTOTP() + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpSetupDetails.sharedSecret) + try await Amplify.Auth.verifyTOTPSetup(code: totpCode) + + // Test both MFA types as enabled + try await authCognitoPlugin.updateMFAPreference( + sms: .preferred, + totp: .preferred) + + XCTFail("Should not proceed, because MFA types cannot be marked as preferred") + } catch { + guard let authError = error as? AuthError, + case .service(_, _, let underlyingError) = authError else { + XCTFail("Should throw service error") + return + } + guard case .invalidParameter = underlyingError as? AWSCognitoAuthError else { + XCTFail("Should throw invalidParameter error.") + return + } + } + } + +} diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/MFASignInTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/MFASignInTests.swift new file mode 100644 index 0000000000..9f1a9db1b2 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/MFASignInTests.swift @@ -0,0 +1,305 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import Amplify +import AWSCognitoAuthPlugin + +class MFASignInTests: AWSAuthBaseTest { + + override func setUp() async throws { + try await super.setUp() + AuthSessionHelper.clearSession() + } + + override func tearDown() async throws { + try await super.tearDown() + AuthSessionHelper.clearSession() + } + + /// Test successful successful signIn with confirmSignInWithTOTPCode Step + /// + /// - Given: A newly signed up user in Cognito user pool + /// Following are the preconditions to set up the test + /// - Sign Up and Sign In + /// - Set Up TOTP + /// - Set TOTP as preferred + /// - Sign out + /// + /// - When: + /// - I invoke signIn and confirmSignIn API + /// - Then: + /// - I should get confirmSignInWithTOTPCode Step for signIn call and can be successfully confirmed + /// + func testSignInWithTOTPMFA() async throws { + + // GIVEN + + let username = "integTest\(UUID().uuidString)" + let password = "P123@\(UUID().uuidString)" + + let didSucceed = try await AuthSignInHelper.registerAndSignInUser( + username: username, + password: password, + email: defaultTestEmail) + + XCTAssertTrue(didSucceed, "Signup and sign in should succeed") + + let authCognitoPlugin = try Amplify.Auth.getPlugin( + for: "awsCognitoAuthPlugin") as! AWSCognitoAuthPlugin + + let totpSetupDetails = try await Amplify.Auth.setUpTOTP() + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpSetupDetails.sharedSecret) + try await Amplify.Auth.verifyTOTPSetup(code: totpCode) + try await authCognitoPlugin.updateMFAPreference( + sms: nil, + totp: .enabled) + await AuthSignInHelper.signOut() + + /// Sleep for 30 secs so that TOTP code can be regenerated for use during sign in otherwise will get + /// RespondToAuthChallengeOutputError.expiredCodeException + /// - "Your software token has already been used once." + /// + Amplify.Logging.info("Sleeping for 30 seconds to avoid RespondToAuthChallengeOutputError.expiredCodeException") + try await Task.sleep(seconds: 30) + + // WHEN + + // Once all preconditions are satisfied, try signing in + do { + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init()) + guard case .confirmSignInWithTOTPCode = result.nextStep else { + XCTFail("Next step should be confirmSignInWithTOTPCode") + return + } + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpSetupDetails.sharedSecret) + let confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: totpCode) + XCTAssertTrue(confirmSignInResult.isSignedIn) + + } catch { + XCTFail("SignIn should successfully complete. \(error)") + } + + // Clean up user + try await Amplify.Auth.deleteUser() + } + + /// Test successful successful signIn with confirmSignInWithSMSMFACode Step + /// + /// - Given: A newly signed up user in Cognito user pool + /// Following are the preconditions to set up the test + /// - Sign Up and Sign In + /// - Set SMS as preferred + /// - Sign out + /// + /// - When: + /// - I invoke signIn and confirmSignIn API + /// - Then: + /// - I should get confirmSignInWithSMSMFACode Step for signIn call and can be successfully confirmed + /// + func testSignInWithSMSMFA() async throws { + + // GIVEN + + let username = "integTest\(UUID().uuidString)" + let password = "P123@\(UUID().uuidString)" + + let didSucceed = try await AuthSignInHelper.registerAndSignInUser( + username: username, + password: password, + email: defaultTestEmail, + phoneNumber: "+16135550116") + + XCTAssertTrue(didSucceed, "Signup and sign in should succeed") + + let authCognitoPlugin = try Amplify.Auth.getPlugin( + for: "awsCognitoAuthPlugin") as! AWSCognitoAuthPlugin + try await authCognitoPlugin.updateMFAPreference( + sms: .enabled, + totp: nil) + await AuthSignInHelper.signOut() + + + // WHEN + + // Once all preconditions are satisfied, try signing in + do { + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init()) + guard case .confirmSignInWithSMSMFACode(let codeDeliveryDetails, _) = result.nextStep else { + XCTFail("Next step should be confirmSignInWithSMSMFACode") + return + } + guard case .sms(let destination) = codeDeliveryDetails.destination else { + XCTFail("Destination should be phone") + return + } + XCTAssertNotNil(destination) + + Amplify.Logging.info("Cannot use confirmSignIn, because don't have access to SMS") + + } catch { + XCTFail("SignIn should successfully complete. \(error)") + } + + } + + /// Test successful successful signIn with continueSignInWithMFASelection Step + /// + /// - Given: A newly signed up user in Cognito user pool + /// Following are the preconditions to set up the test + /// - Sign Up and Sign In + /// - Set Up TOTP + /// - Set TOTP as preferred + /// - Sign out + /// + /// - When: + /// - I invoke signIn and confirmSignIn API + /// - Then: + /// - I should get continueSignInWithMFASelection Step for signIn call and can be successfully confirmed + /// + func testSelectMFATypeWithTOTPWhileSigningIn() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "P123@\(UUID().uuidString)" + + let didSucceed = try await AuthSignInHelper.registerAndSignInUser( + username: username, + password: password, + email: defaultTestEmail, + phoneNumber: "+16135550116") + + XCTAssertTrue(didSucceed, "Signup and sign in should succeed") + + let authCognitoPlugin = try Amplify.Auth.getPlugin( + for: "awsCognitoAuthPlugin") as! AWSCognitoAuthPlugin + + let totpSetupDetails = try await Amplify.Auth.setUpTOTP() + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpSetupDetails.sharedSecret) + try await Amplify.Auth.verifyTOTPSetup(code: totpCode) + try await authCognitoPlugin.updateMFAPreference( + sms: .enabled, + totp: .enabled) + await AuthSignInHelper.signOut() + + /// Sleep for 30 secs so that TOTP code can be regenerated for use during sign in otherwise will get + /// RespondToAuthChallengeOutputError.expiredCodeException + /// - "Your software token has already been used once." + /// + Amplify.Logging.info("Sleeping for 30 seconds to avoid RespondToAuthChallengeOutputError.expiredCodeException") + try await Task.sleep(seconds: 30) + + // Once all preconditions are satisfied, try signing in + + do { + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init()) + guard case .continueSignInWithMFASelection(let allowedMFATypes) = result.nextStep else { + XCTFail("Next step should be continueSignInWithMFASelection") + return + } + XCTAssertEqual(allowedMFATypes, [.sms, .totp]) + + var confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: MFAType.totp.challengeResponse) + + guard case .confirmSignInWithTOTPCode = confirmSignInResult.nextStep else { + XCTFail("Next step should be confirmSignInWithTOTPCode") + return + } + + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpSetupDetails.sharedSecret) + confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: totpCode) + XCTAssertTrue(confirmSignInResult.isSignedIn) + + } catch { + XCTFail("SignIn should successfully complete. \(error)") + } + + // Clean up user + try await Amplify.Auth.deleteUser() + } + + /// Test successful successful signIn with continueSignInWithMFASelection Step + /// + /// - Given: A newly signed up user in Cognito user pool + /// Following are the preconditions to set up the test + /// - Sign Up and Sign In + /// - Set Up TOTP + /// - Set TOTP as preferred + /// - Sign out + /// + /// - When: + /// - I invoke signIn and confirmSignIn API + /// - Then: + /// - I should get continueSignInWithMFASelection Step for signIn call and can be successfully confirmed + /// + func testSelectMFATypeWithSMSWhileSigningIn() async throws { + + let username = "integTest\(UUID().uuidString)" + let password = "P123@\(UUID().uuidString)" + + let didSucceed = try await AuthSignInHelper.registerAndSignInUser( + username: username, + password: password, + email: defaultTestEmail, + phoneNumber: "+16135550116") + + XCTAssertTrue(didSucceed, "Signup and sign in should succeed") + + let authCognitoPlugin = try Amplify.Auth.getPlugin( + for: "awsCognitoAuthPlugin") as! AWSCognitoAuthPlugin + + let totpSetupDetails = try await Amplify.Auth.setUpTOTP() + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpSetupDetails.sharedSecret) + try await Amplify.Auth.verifyTOTPSetup(code: totpCode) + try await authCognitoPlugin.updateMFAPreference( + sms: .enabled, + totp: .enabled) + await AuthSignInHelper.signOut() + + // Once all preconditions are satisfied, try signing in + do { + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init()) + guard case .continueSignInWithMFASelection(let allowedMFATypes) = result.nextStep else { + XCTFail("Next step should be continueSignInWithMFASelection") + return + } + XCTAssertEqual(allowedMFATypes, [.sms, .totp]) + + let confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: MFAType.sms.challengeResponse) + + guard case .confirmSignInWithSMSMFACode(let codeDeliveryDetails, _) = confirmSignInResult.nextStep else { + XCTFail("Next step should be confirmSignInWithSMSMFACode") + return + } + guard case .sms(let destination) = codeDeliveryDetails.destination else { + XCTFail("Destination should be phone") + return + } + XCTAssertNotNil(destination) + + Amplify.Logging.info("Cannot use confirmSignIn, because don't have access to SMS") + + } catch { + XCTFail("SignIn should successfully complete. \(error)") + } + } + +} diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/TOTPSetupWhenAuthenticatedTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/TOTPSetupWhenAuthenticatedTests.swift new file mode 100644 index 0000000000..47c76629eb --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/TOTPSetupWhenAuthenticatedTests.swift @@ -0,0 +1,98 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import Amplify +import AWSCognitoAuthPlugin + +class TOTPSetupWhenAuthenticatedTests: AWSAuthBaseTest { + + override func setUp() async throws { + try await super.setUp() + AuthSessionHelper.clearSession() + + let username = "integTest\(UUID().uuidString)" + let password = "P123@\(UUID().uuidString)" + + let didSucceed = try await AuthSignInHelper.registerAndSignInUser( + username: username, + password: password, + email: defaultTestEmail) + + XCTAssertTrue(didSucceed, "Signup and sign in should succeed") + } + + override func tearDown() async throws { + // Clean up user + try await Amplify.Auth.deleteUser() + try await super.tearDown() + AuthSessionHelper.clearSession() + } + + /// Test successful signIn of a valid user + /// + /// - Given: A signed in user in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.setUpTOTP and verifyTOTPSetup API's + /// - Then: + /// - I should not get any errors and all the API's should be a success + /// + func testSuccessfulTOTPSetupWhileAuthenticated() async throws { + + do { + let totpSetupDetails = try await Amplify.Auth.setUpTOTP() + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpSetupDetails.sharedSecret) + try await Amplify.Auth.verifyTOTPSetup(code: totpCode) + } catch { + XCTFail("API should succeed without any errors instead failed with \(error)") + } + } + + /// Test successful signIn of a valid user + /// + /// - Given: A signed in user in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.setUpTOTP + /// - And verifyTOTPSetup API with incorrect code + /// Then: + /// - I should get an exception about the code mismatch + /// Then: + /// - I verifyTOTPSetup API again with the correct code + /// - Then: + /// - I should not get any errors and all the API's should be a success + /// + func testSuccessfulTOTPSetupWithInitialError() async throws { + + var totpSetupDetails: TOTPSetupDetails? + do { + totpSetupDetails = try await Amplify.Auth.setUpTOTP() + + try await Amplify.Auth.verifyTOTPSetup(code: "123456") + XCTFail("Should not succeed with incorrect TOTP Code") + } catch { + + guard let authError = error as? AuthError, + case .service(_, _, let underlyingError) = authError else { + XCTFail("Should throw service error") + return + } + + guard case .softwareTokenMFANotEnabled = underlyingError as? AWSCognitoAuthError else { + XCTFail("Should throw softwareTokenMFANotEnabled error.") + return + } + + do { + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpSetupDetails!.sharedSecret) + try await Amplify.Auth.verifyTOTPSetup(code: totpCode) + } catch { + XCTFail("API should succeed without any errors instead failed with \(error)") + } + } + } + +} diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/TOTPSetupWhenUnauthenticatedTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/TOTPSetupWhenUnauthenticatedTests.swift new file mode 100644 index 0000000000..99cf2c3ae7 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/TOTPSetupWhenUnauthenticatedTests.swift @@ -0,0 +1,296 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import Amplify +import AWSCognitoAuthPlugin + +class TOTPSetupWhenUnauthenticatedTests: AWSAuthBaseTest { + + override func setUp() async throws { + // Use a custom configuration these tests + amplifyConfigurationFile = "testconfiguration/AWSCognitoAuthPluginMFARequiredIntegrationTests-amplifyconfiguration" + try await super.setUp() + AuthSessionHelper.clearSession() + } + + override func tearDown() async throws { + try await super.tearDown() + AuthSessionHelper.clearSession() + } + + /// Test successful next step continueSignInWithTOTPSetup + /// + /// - Given: A newly signed up user in Cognito user pool with REQUIRED MFA, No Phone Number Added + /// + /// - When: + /// - I invoke signIn API + /// - Then: + /// - I should get continueSignInWithTOTPSetup Step for signIn call and can be successfully confirmed + /// + func testSetupMFANextStepDuringSignIn() async throws { + + // GIVEN + + let username = "integTest\(UUID().uuidString)" + let password = "P123@\(UUID().uuidString)" + + let didSucceed = try await AuthSignInHelper.signUpUser( + username: username, + password: password, + email: defaultTestEmail) + + XCTAssertTrue(didSucceed, "Signup should succeed") + + // WHEN + + do { + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init()) + guard case .continueSignInWithTOTPSetup(let totpSetupDetails) = result.nextStep else { + XCTFail("Next step should be continueSignInWithTOTPSetup") + return + } + XCTAssertNotNil(totpSetupDetails.sharedSecret) + } catch { + XCTFail("Should get valid next step. \(error)") + } + } + + /// Test successful next step confirmSignInWithSMSMFACode + /// + /// - Given: A newly signed up user in Cognito user pool with REQUIRED MFA, Phone Number ADDED + /// + /// - When: + /// - I invoke signIn API + /// - Then: + /// - I should get confirmSignInWithSMSMFACode Step for signIn call and can be successfully confirmed + /// + func testSMSMFANextStepDuringSignIn() async throws { + + // GIVEN + + let username = "integTest\(UUID().uuidString)" + let password = "P123@\(UUID().uuidString)" + + let didSucceed = try await AuthSignInHelper.signUpUser( + username: username, + password: password, + email: defaultTestEmail, + phoneNumber: "+16135550116") + + XCTAssertTrue(didSucceed, "Signup should succeed") + + // WHEN + + do { + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init()) + guard case .confirmSignInWithSMSMFACode(let codeDeliveryDetails, _) = result.nextStep else { + XCTFail("Next step should be confirmSignInWithSMSMFACode") + return + } + guard case .sms(let destination) = codeDeliveryDetails.destination else { + XCTFail("Destination should be phone") + return + } + XCTAssertNotNil(destination) + + Amplify.Logging.info("Cannot use confirmSignIn, because don't have access to SMS") + + } catch { + XCTFail("Should get valid next step. \(error)") + } + } + + + /// Test successful successful sign in after continueSignInWithTOTPSetup next step + /// + /// - Given: A newly signed up user in Cognito user pool with REQUIRED MFA, No Phone Number Added + /// + /// - When: + /// - I invoke signIn API + /// - Then: + /// - I should get continueSignInWithTOTPSetup Step for signIn call and can be successfully confirmed + /// with TOTP setup confirmation + /// + func testSuccessfulSignForSetupMFANextStep() async throws { + + // GIVEN + + let username = "integTest\(UUID().uuidString)" + let password = "P123@\(UUID().uuidString)" + + let didSucceed = try await AuthSignInHelper.signUpUser( + username: username, + password: password, + email: defaultTestEmail) + + XCTAssertTrue(didSucceed, "Signup should succeed") + + // WHEN + + do { + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init()) + guard case .continueSignInWithTOTPSetup(let totpSetupDetails) = result.nextStep else { + XCTFail("Next step should be continueSignInWithTOTPSetup") + return + } + XCTAssertNotNil(totpSetupDetails.sharedSecret) + + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpSetupDetails.sharedSecret) + let confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: totpCode) + XCTAssertTrue(confirmSignInResult.isSignedIn) + + } catch { + XCTFail("SignIn should successfully complete. \(error)") + } + } + + /// Test successful successful sign in after continueSignInWithTOTPSetup next step + /// + /// - Given: A newly signed up user in Cognito user pool with REQUIRED MFA, No Phone Number Added + /// + /// - When: + /// - I invoke signIn API and enter invalid alphabetical TOTP setup code to initiate softwareTokenMFANotEnabled + /// - Then: + /// - I should get continueSignInWithTOTPSetup Step for signIn call and can be successfully confirmed + /// with TOTP setup confirmation + /// + func testSuccessfulSignInForSetupMFANextStepAfterInvalidInitialEntry() async throws { + + // GIVEN + + let username = "integTest\(UUID().uuidString)" + let password = "P123@\(UUID().uuidString)" + + let didSucceed = try await AuthSignInHelper.signUpUser( + username: username, + password: password, + email: defaultTestEmail) + + XCTAssertTrue(didSucceed, "Signup should succeed") + + // WHEN + + var totpSetupDetails: TOTPSetupDetails? + do { + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init()) + guard case .continueSignInWithTOTPSetup(let details) = result.nextStep else { + XCTFail("Next step should be continueSignInWithTOTPSetup") + return + } + totpSetupDetails = details + XCTAssertNotNil(totpSetupDetails?.sharedSecret) + + _ = try await Amplify.Auth.confirmSignIn( + challengeResponse: "123456") + + } catch { + + guard let authError = error as? AuthError, + case .service(_, _, let underlyingError) = authError else { + XCTFail("Should throw service error") + return + } + + guard case .softwareTokenMFANotEnabled = underlyingError as? AWSCognitoAuthError else { + XCTFail("Should throw softwareTokenMFANotEnabled error.") + return + } + + do { + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpSetupDetails!.sharedSecret) + let confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: totpCode) + XCTAssertTrue(confirmSignInResult.isSignedIn) + } catch { + XCTFail("SignIn should successfully complete. \(error)") + } + + } + } + + /// Test successful successful sign in after continueSignInWithTOTPSetup next step + /// + /// - Given: A newly signed up user in Cognito user pool with REQUIRED MFA, No Phone Number Added + /// + /// - When: + /// - I invoke signIn API and enter invalid alphabetical TOTP setup code to initiate invalidParameterException + /// - Then: + /// - I should get continueSignInWithTOTPSetup Step for signIn call and can be successfully confirmed + /// with TOTP setup confirmation + /// + func testSuccessfulSignInForSetupMFANextStepAfterInvalidParameterException() async throws { + + // GIVEN + + let username = "integTest\(UUID().uuidString)" + let password = "P123@\(UUID().uuidString)" + + let didSucceed = try await AuthSignInHelper.signUpUser( + username: username, + password: password, + email: defaultTestEmail) + + XCTAssertTrue(didSucceed, "Signup should succeed") + + // WHEN + + var totpSetupDetails: TOTPSetupDetails? + do { + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init()) + guard case .continueSignInWithTOTPSetup(let details) = result.nextStep else { + XCTFail("Next step should be continueSignInWithTOTPSetup") + return + } + totpSetupDetails = details + XCTAssertNotNil(totpSetupDetails?.sharedSecret) + + _ = try await Amplify.Auth.confirmSignIn( + challengeResponse: "userCode") + + } catch { + + guard let authError = error as? AuthError, + case .service(_, _, let underlyingError) = authError else { + XCTFail("Should throw service error") + return + } + + guard case .invalidParameter = underlyingError as? AWSCognitoAuthError else { + XCTFail("Should throw invalidParameter error.") + return + } + + do { + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpSetupDetails!.sharedSecret) + let confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: totpCode) + XCTAssertTrue(confirmSignInResult.isSignedIn) + } catch { + XCTFail("SignIn should successfully complete. \(error)") + } + + } + } + +} diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/SignInTests/AuthCustomSignInTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/SignInTests/AuthCustomSignInTests.swift index 0eb47ea210..685ea89498 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/SignInTests/AuthCustomSignInTests.swift +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/SignInTests/AuthCustomSignInTests.swift @@ -53,6 +53,7 @@ class AuthCustomSignInTests: AWSAuthBaseTest { /// } /// func testSuccessfulSignInWithCustomAuthSRP() async throws { + throw XCTSkip("TODO: fix this test. Need custom resource") let username = "integTest\(UUID().uuidString)" let password = "P123@\(UUID().uuidString)" @@ -114,6 +115,7 @@ class AuthCustomSignInTests: AWSAuthBaseTest { /// } /// func testRuntimeAuthFlowSwitch() async throws { + throw XCTSkip("TODO: fix this test. Need custom resource") let username = "integTest\(UUID().uuidString)" let password = "P123@\(UUID().uuidString)" @@ -180,6 +182,7 @@ class AuthCustomSignInTests: AWSAuthBaseTest { /// return event; /// }; func testSuccessfulSignInWithCustomAuth() async throws { + throw XCTSkip("TODO: fix this test. Need custom resource") let username = "integTest\(UUID().uuidString)" let password = "P123@\(UUID().uuidString)" diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/SignInTests/AuthSRPSignInTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/SignInTests/AuthSRPSignInTests.swift index 0246e2f76c..0c35eca521 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/SignInTests/AuthSRPSignInTests.swift +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/SignInTests/AuthSRPSignInTests.swift @@ -233,7 +233,8 @@ class AuthSRPSignInTests: AWSAuthBaseTest { /// Make sure that you do not enter email and phone number, so that adding a new attribute could also be tested /// /// DISABLED TEST, because it needs special setup - func testNewPasswordRequired() async { + func testNewPasswordRequired() async throws { + throw XCTSkip("TODO: fix this test. Need custom resource") let username = "YOUR USERNAME CREATED IN COGNITO FOR TESTING TEMP PASSWORD FLOW" let tempPassword = "YOUR TEMP PASSWORD THAT WAS SET" diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/SignOutTests/AuthSignOutTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/SignOutTests/AuthSignOutTests.swift index 1a964aa16a..22a376a54f 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/SignOutTests/AuthSignOutTests.swift +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/SignOutTests/AuthSignOutTests.swift @@ -75,7 +75,7 @@ class AuthSignOutTests: AWSAuthBaseTest { /// - When: /// - I invoke signOut /// - Then: - /// - I should get a successul result + /// - I should get a successful result /// func testSignedOutWithUnAuthState() async throws { _ = await Amplify.Auth.signOut() diff --git a/AmplifyTestCommon/Mocks/MockAuthCategoryPlugin.swift b/AmplifyTestCommon/Mocks/MockAuthCategoryPlugin.swift index a97c71620c..2a7bd7f1fd 100644 --- a/AmplifyTestCommon/Mocks/MockAuthCategoryPlugin.swift +++ b/AmplifyTestCommon/Mocks/MockAuthCategoryPlugin.swift @@ -99,6 +99,17 @@ class MockAuthCategoryPlugin: MessageReporter, AuthCategoryPlugin { } + public func setUpTOTP() async throws -> TOTPSetupDetails { + fatalError() + } + + public func verifyTOTPSetup( + code: String, + options: VerifyTOTPSetupRequest.Options? + ) async throws { + fatalError() + } + public func confirm(userAttribute: AuthUserAttributeKey, confirmationCode: String, options: AuthConfirmUserAttributeRequest.Options? = nil) async throws {