diff --git a/Amplify/Categories/Auth/Models/AuthSignInStep.swift b/Amplify/Categories/Auth/Models/AuthSignInStep.swift index e99fc9adf4..26837b627b 100644 --- a/Amplify/Categories/Auth/Models/AuthSignInStep.swift +++ b/Amplify/Categories/Auth/Models/AuthSignInStep.swift @@ -39,6 +39,19 @@ public enum AuthSignInStep { /// case continueSignInWithMFASelection(AllowedMFATypes) + /// Auth step is for continuing sign in by setting up EMAIL multi factor authentication. + /// + case continueSignInWithEmailMFASetup + + /// Auth step is for continuing sign in by selecting multi factor authentication type to setup + /// + case continueSignInWithMFASetupSelection(AllowedMFATypes) + + /// Auth step is for confirming sign in with OTP + /// + /// OTP for the factor will be sent to the delivery medium. + case confirmSignInWithOTP(AuthCodeDeliveryDetails) + /// Auth step required the user to change their password. /// case resetPassword(AdditionalInfo?) @@ -51,3 +64,5 @@ public enum AuthSignInStep { /// case done } + +extension AuthSignInStep: Equatable { } diff --git a/Amplify/Categories/Auth/Models/MFAType.swift b/Amplify/Categories/Auth/Models/MFAType.swift index 2726503aa1..4fa23c8a38 100644 --- a/Amplify/Categories/Auth/Models/MFAType.swift +++ b/Amplify/Categories/Auth/Models/MFAType.swift @@ -12,4 +12,7 @@ public enum MFAType: String { /// Time-based One Time Password linked with an authenticator app case totp + + /// Email Service linked with an email + case email } diff --git a/Amplify/Categories/Auth/Models/TOTPSetupDetails.swift b/Amplify/Categories/Auth/Models/TOTPSetupDetails.swift index 608ddcab77..7f84610180 100644 --- a/Amplify/Categories/Auth/Models/TOTPSetupDetails.swift +++ b/Amplify/Categories/Auth/Models/TOTPSetupDetails.swift @@ -40,3 +40,5 @@ public struct TOTPSetupDetails { } } + +extension TOTPSetupDetails: Equatable { } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+PluginSpecificAPI.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+PluginSpecificAPI.swift index 6155f93d27..bc562772da 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+PluginSpecificAPI.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPlugin+PluginSpecificAPI.swift @@ -41,12 +41,14 @@ public extension AWSCognitoAuthPlugin { } func updateMFAPreference( - sms: MFAPreference?, - totp: MFAPreference? + sms: MFAPreference? = nil, + totp: MFAPreference? = nil, + email: MFAPreference? = nil ) async throws { let task = UpdateMFAPreferenceTask( smsPreference: sms, totpPreference: totp, + emailPreference: email, 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 ffc07ce349..feb18a7a24 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPluginBehavior.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/AWSCognitoAuthPluginBehavior.swift @@ -49,6 +49,7 @@ protocol AWSCognitoAuthPluginBehavior: AuthCategoryPlugin { /// - totp: The preference that needs to be updated for TOTP func updateMFAPreference( sms: MFAPreference?, - totp: MFAPreference? + totp: MFAPreference?, + email: MFAPreference? ) async throws } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/InitializeResolveChallenge.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/InitializeResolveChallenge.swift index f253d2a09b..f631e133d3 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/InitializeResolveChallenge.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/InitializeResolveChallenge.swift @@ -18,10 +18,54 @@ struct InitializeResolveChallenge: Action { func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async { logVerbose("\(#fileID) Starting execution", environment: environment) + do { + let nextStep = try resolveNextSignInStep(for: challenge) + let event = SignInChallengeEvent(eventType: .waitForAnswer(challenge, signInMethod, nextStep)) + logVerbose("\(#fileID) Sending event \(event.type)", environment: environment) + await dispatcher.send(event) + } catch let error as SignInError { + let errorEvent = SignInEvent(eventType: .throwAuthError(error)) + logVerbose("\(#fileID) Sending event \(errorEvent)", + environment: environment) + await dispatcher.send(errorEvent) + } catch { + let error = SignInError.service(error: error) + let errorEvent = SignInEvent(eventType: .throwAuthError(error)) + logVerbose("\(#fileID) Sending event \(errorEvent)", + environment: environment) + await dispatcher.send(errorEvent) + } + } - let event = SignInChallengeEvent(eventType: .waitForAnswer(challenge, signInMethod)) - logVerbose("\(#fileID) Sending event \(event.type)", environment: environment) - await dispatcher.send(event) + private func resolveNextSignInStep(for challenge: RespondToAuthChallenge) throws -> AuthSignInStep { + switch challenge.challenge.authChallengeType { + case .smsMfa: + let delivery = challenge.codeDeliveryDetails + return .confirmSignInWithSMSMFACode(delivery, challenge.parameters) + case .totpMFA: + return .confirmSignInWithTOTPCode + case .customChallenge: + return .confirmSignInWithCustomChallenge(challenge.parameters) + case .newPasswordRequired: + return .confirmSignInWithNewPassword(challenge.parameters) + case .selectMFAType: + return .continueSignInWithMFASelection(challenge.getAllowedMFATypesForSelection) + case .emailMFA: + return .confirmSignInWithOTP(challenge.codeDeliveryDetails) + case .setUpMFA: + var allowedMFATypesForSetup = challenge.getAllowedMFATypesForSetup + // remove SMS, as it is not supported and should not be sent back to the customer, since it could be misleading + allowedMFATypesForSetup.remove(.sms) + if allowedMFATypesForSetup.count > 1 { + return .continueSignInWithMFASetupSelection(allowedMFATypesForSetup) + } else if let mfaType = allowedMFATypesForSetup.first, + mfaType == .email { + return .continueSignInWithEmailMFASetup + } + throw SignInError.unknown(message: "Unable to determine next step from challenge:\n\(challenge)") + case .unknown(let cognitoChallengeType): + throw SignInError.unknown(message: "Challenge not supported\(cognitoChallengeType)") + } } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/InitializeTOTPSetup.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/InitializeTOTPSetup.swift index 8fd033dae4..eca0f5fa92 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/InitializeTOTPSetup.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/InitializeTOTPSetup.swift @@ -10,7 +10,7 @@ import Foundation struct InitializeTOTPSetup: Action { var identifier: String = "InitializeTOTPSetup" - let authResponse: SignInResponseBehavior + let authResponse: RespondToAuthChallenge func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async { logVerbose("\(#fileID) Start execution", environment: environment) @@ -26,9 +26,9 @@ extension InitializeTOTPSetup: CustomDebugDictionaryConvertible { var debugDictionary: [String: Any] { [ "identifier": identifier, - "challengeName": authResponse.challengeName?.rawValue ?? "", + "challengeName": authResponse.challenge.rawValue, "session": authResponse.session?.masked() ?? "", - "challengeParameters": authResponse.challengeParameters ?? [:] + "challengeParameters": authResponse.parameters ?? [:] ] } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/SetUpTOTP.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/SetUpTOTP.swift index ff9f8633ac..9de32b8fb1 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/SetUpTOTP.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SoftwareTokenSetup/SetUpTOTP.swift @@ -12,7 +12,7 @@ import AWSCognitoIdentityProvider struct SetUpTOTP: Action { var identifier: String = "SetUpTOTP" - let authResponse: SignInResponseBehavior + let authResponse: RespondToAuthChallenge let signInEventData: SignInEventData func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async { @@ -65,9 +65,9 @@ extension SetUpTOTP: CustomDebugDictionaryConvertible { var debugDictionary: [String: Any] { [ "identifier": identifier, - "challengeName": authResponse.challengeName?.rawValue ?? "", + "challengeName": authResponse.challenge.rawValue, "session": authResponse.session?.masked() ?? "", - "challengeParameters": authResponse.challengeParameters ?? [:], + "challengeParameters": authResponse.parameters ?? [:], "signInEventData": signInEventData.debugDictionary ] } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift index 45c6556ce2..0beead2f68 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift @@ -19,12 +19,41 @@ struct VerifySignInChallenge: Action { let signInMethod: SignInMethod + let currentSignInStep: AuthSignInStep + func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async { logVerbose("\(#fileID) Starting execution", environment: environment) let username = challenge.username var deviceMetadata = DeviceMetadata.noData do { + + if case .continueSignInWithMFASetupSelection = currentSignInStep { + let newChallenge = RespondToAuthChallenge( + challenge: .mfaSetup, + username: challenge.username, + session: challenge.session, + parameters: ["MFAS_CAN_SETUP": "[\"\(confirmSignEventData.answer)\"]"]) + + let event: SignInEvent + guard let mfaType = MFAType(rawValue: confirmSignEventData.answer) else { + throw SignInError.inputValidation(field: "Unknown MFA type") + } + + switch mfaType { + case .email: + event = SignInEvent(eventType: .receivedChallenge(newChallenge)) + case .totp: + event = SignInEvent(eventType: .initiateTOTPSetup(username, newChallenge)) + default: + throw SignInError.unknown(message: "MFA Type not supported for setup") + } + + logVerbose("\(#fileID) Sending event \(event)", environment: environment) + await dispatcher.send(event) + return + } + let userpoolEnv = try environment.userPoolEnvironment() let username = challenge.username let session = challenge.session @@ -64,7 +93,7 @@ struct VerifySignInChallenge: Action { // Remove the saved device details and retry verify challenge await DeviceMetadataHelper.removeDeviceMetaData(for: username, with: environment) let event = SignInChallengeEvent( - eventType: .retryVerifyChallengeAnswer(confirmSignEventData) + eventType: .retryVerifyChallengeAnswer(confirmSignEventData, currentSignInStep) ) logVerbose("\(#fileID) Sending event \(event)", environment: environment) await dispatcher.send(event) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthChallengeType.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthChallengeType.swift index 272ecc8702..b0002f27f0 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthChallengeType.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthChallengeType.swift @@ -22,6 +22,8 @@ enum AuthChallengeType { case setUpMFA + case emailMFA + case unknown(CognitoIdentityProviderClientTypes.ChallengeNameType) } @@ -41,6 +43,8 @@ extension CognitoIdentityProviderClientTypes.ChallengeNameType: Codable { return .selectMFAType case .mfaSetup: return .setUpMFA + case .emailOtp: + return .emailMFA default: return .unknown(self) } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFAPreference.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFAPreference.swift index 0b94b34d77..d2edb58d0f 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFAPreference.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFAPreference.swift @@ -52,4 +52,17 @@ extension MFAPreference { return .init(enabled: false) } } + + func emailSetting(isCurrentlyPreferred: Bool = false) -> CognitoIdentityProviderClientTypes.EmailMfaSettingsType { + switch self { + case .enabled: + return .init(enabled: true, preferredMfa: isCurrentlyPreferred) + 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 index afeedeb0c3..3a11701d6d 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFATypeExtension.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFATypeExtension.swift @@ -15,6 +15,8 @@ extension MFAType: DefaultLogger { self = .sms } else if rawValue.caseInsensitiveCompare("SOFTWARE_TOKEN_MFA") == .orderedSame { self = .totp + } else if rawValue.caseInsensitiveCompare("EMAIL_OTP") == .orderedSame { + self = .email } else { Self.log.error("Tried to initialize an unsupported MFA type with value: \(rawValue) ") return nil @@ -33,6 +35,8 @@ extension MFAType: DefaultLogger { return "SMS_MFA" case .totp: return "SOFTWARE_TOKEN_MFA" + case .email: + return "EMAIL_OTP" } } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/RespondToAuthChallenge.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/RespondToAuthChallenge.swift index c8a5297f86..70018df3a5 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/RespondToAuthChallenge.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/RespondToAuthChallenge.swift @@ -34,6 +34,8 @@ extension RespondToAuthChallenge { let destination = parameters["CODE_DELIVERY_DESTINATION"] if medium == "SMS" { deliveryDestination = .sms(destination) + } else if medium == "EMAIL" { + deliveryDestination = .email(destination) } return AuthCodeDeliveryDetails(destination: deliveryDestination, attributeKey: nil) @@ -71,6 +73,10 @@ extension RespondToAuthChallenge { case .smsMfa: return "SMS_MFA_CODE" case .softwareTokenMfa: return "SOFTWARE_TOKEN_MFA_CODE" case .newPasswordRequired: return "NEW_PASSWORD" + case .emailOtp: return "EMAIL_OTP_CODE" + // At the moment of writing this code, `mfaSetup` only supports EMAIL. + // TOTP is not part of it because, it follows a completely different setup path + case .mfaSetup: return "EMAIL" default: let message = "Unsupported challenge type for response key generation \(challenge)" let error = SignInError.unknown(message: message) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SetUpTOTPEvent.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SetUpTOTPEvent.swift index 387262e785..b100757cef 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SetUpTOTPEvent.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SetUpTOTPEvent.swift @@ -14,7 +14,7 @@ struct SetUpTOTPEvent: StateMachineEvent { enum EventType { - case setUpTOTP(SignInResponseBehavior) + case setUpTOTP(RespondToAuthChallenge) case waitForAnswer(SignInTOTPSetupData) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInChallengeEvent.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInChallengeEvent.swift index c85b03bf22..daba71bc9d 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInChallengeEvent.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInChallengeEvent.swift @@ -6,16 +6,17 @@ // import Foundation +import Amplify struct SignInChallengeEvent: StateMachineEvent { enum EventType: Equatable { - case waitForAnswer(RespondToAuthChallenge, SignInMethod) + case waitForAnswer(RespondToAuthChallenge, SignInMethod, AuthSignInStep) case verifyChallengeAnswer(ConfirmSignInEventData) - case retryVerifyChallengeAnswer(ConfirmSignInEventData) + case retryVerifyChallengeAnswer(ConfirmSignInEventData, AuthSignInStep) case verified diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInEvent.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInEvent.swift index 6733421a1f..35bce207a6 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInEvent.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInEvent.swift @@ -38,7 +38,7 @@ struct SignInEvent: StateMachineEvent { case respondDevicePasswordVerifier(SRPStateData, SignInResponseBehavior) - case initiateTOTPSetup(Username, SignInResponseBehavior) + case initiateTOTPSetup(Username, RespondToAuthChallenge) case throwPasswordVerifierError(SignInError) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInChallengeState+Debug.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInChallengeState+Debug.swift index 7ded00a585..4bef1e337c 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInChallengeState+Debug.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInChallengeState+Debug.swift @@ -13,10 +13,10 @@ extension SignInChallengeState: CustomDebugDictionaryConvertible { let additionalMetadataDictionary: [String: Any] switch self { - case .waitingForAnswer(let respondAuthChallenge, _), - .verifying(let respondAuthChallenge, _, _): + case .waitingForAnswer(let respondAuthChallenge, _, _), + .verifying(let respondAuthChallenge, _, _, _): additionalMetadataDictionary = respondAuthChallenge.debugDictionary - case .error(let respondAuthChallenge, _, let error): + case .error(let respondAuthChallenge, _, let error, _): additionalMetadataDictionary = respondAuthChallenge.debugDictionary.merging( [ "error": error diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInChallengeState.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInChallengeState.swift index 1ad45652be..ad0651140f 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInChallengeState.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInChallengeState.swift @@ -6,18 +6,19 @@ // import Foundation +import Amplify enum SignInChallengeState: State { case notStarted - case waitingForAnswer(RespondToAuthChallenge, SignInMethod) + case waitingForAnswer(RespondToAuthChallenge, SignInMethod, AuthSignInStep) - case verifying(RespondToAuthChallenge, SignInMethod, String) + case verifying(RespondToAuthChallenge, SignInMethod, String, AuthSignInStep) case verified - case error(RespondToAuthChallenge, SignInMethod, SignInError) + case error(RespondToAuthChallenge, SignInMethod, SignInError, AuthSignInStep) } extension SignInChallengeState { diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/SignInChallengeState+Resolver.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/SignInChallengeState+Resolver.swift index bff26db8a3..f14f374fc7 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/SignInChallengeState+Resolver.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/SignInChallengeState+Resolver.swift @@ -21,34 +21,36 @@ extension SignInChallengeState { switch oldState { case .notStarted: - if case .waitForAnswer(let challenge, let signInMethod) = event.isChallengeEvent { - return .init(newState: .waitingForAnswer(challenge, signInMethod)) + if case .waitForAnswer(let challenge, let signInMethod, let signInStep) = event.isChallengeEvent { + return .init(newState: .waitingForAnswer(challenge, signInMethod, signInStep)) } return .from(oldState) - case .waitingForAnswer(let challenge, let signInMethod): + case .waitingForAnswer(let challenge, let signInMethod, let signInStep): if case .verifyChallengeAnswer(let answerEventData) = event.isChallengeEvent { let action = VerifySignInChallenge( challenge: challenge, confirmSignEventData: answerEventData, - signInMethod: signInMethod) + signInMethod: signInMethod, + currentSignInStep: signInStep) return .init( - newState: .verifying(challenge, signInMethod, answerEventData.answer), + newState: .verifying(challenge, signInMethod, answerEventData.answer, signInStep), actions: [action] ) } return .from(oldState) - case .verifying(let challenge, let signInMethod, _): + case .verifying(let challenge, let signInMethod, _, let signInStep): - if case .retryVerifyChallengeAnswer(let answerEventData) = event.isChallengeEvent { + if case .retryVerifyChallengeAnswer(let answerEventData, let signInStep) = event.isChallengeEvent { let action = VerifySignInChallenge( challenge: challenge, confirmSignEventData: answerEventData, - signInMethod: signInMethod) + signInMethod: signInMethod, + currentSignInStep: signInStep) return .init( - newState: .verifying(challenge, signInMethod, answerEventData.answer), + newState: .verifying(challenge, signInMethod, answerEventData.answer, signInStep), actions: [action] ) } @@ -59,20 +61,21 @@ extension SignInChallengeState { } if case .throwAuthError(let error) = event.isSignInEvent { - return .init(newState: .error(challenge, signInMethod, error)) + return .init(newState: .error(challenge, signInMethod, error, signInStep)) } return .from(oldState) - case .error(let challenge, let signInMethod, _): + case .error(let challenge, let signInMethod, _, let signInStep): // If a verifyChallengeAnswer is received on error state we allow // to retry the challenge. if case .verifyChallengeAnswer(let answerEventData) = event.isChallengeEvent { let action = VerifySignInChallenge( challenge: challenge, confirmSignEventData: answerEventData, - signInMethod: signInMethod) + signInMethod: signInMethod, + currentSignInStep: signInStep) return .init( - newState: .verifying(challenge, signInMethod, answerEventData.answer), + newState: .verifying(challenge, signInMethod, answerEventData.answer, signInStep), actions: [action] ) } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift index 02a2d74f62..05097b818c 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift @@ -11,9 +11,9 @@ import AWSCognitoIdentityProvider struct UserPoolSignInHelper: DefaultLogger { - static func checkNextStep(_ signInState: SignInState) - throws -> AuthSignInResult? { - + static func checkNextStep( + _ signInState: SignInState + ) throws -> AuthSignInResult? { log.verbose("Checking next step for: \(signInState)") if case .signingInWithSRP(let srpState, _) = signInState, @@ -37,13 +37,13 @@ struct UserPoolSignInHelper: DefaultLogger { return try validateError(signInError: hostedUIError) } else if case .resolvingChallenge(let challengeState, _, _) = signInState, - case .error(_, _, let signInError) = challengeState { + case .error(_, _, let signInError, _) = challengeState { return try validateError(signInError: signInError) - } else if case .resolvingChallenge(let challengeState, let challengeType, _) = signInState, - case .waitingForAnswer(let challenge, _) = challengeState { - return try validateResult(for: challengeType, with: challenge) - + } else if case .resolvingChallenge(let challengeState, _, _) = signInState, + case .waitingForAnswer(_, _, let signInStep) = challengeState { + return .init(nextStep: signInStep) + } else if case .resolvingTOTPSetup(let totpSetupState, _) = signInState, case .error(_, let signInError) = totpSetupState { return try validateError(signInError: signInError) @@ -56,28 +56,6 @@ struct UserPoolSignInHelper: DefaultLogger { return nil } - private static func validateResult(for challengeType: AuthChallengeType, - with challenge: RespondToAuthChallenge) - throws -> AuthSignInResult { - switch challengeType { - 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 .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) - } - } - private static func validateError(signInError: SignInError) throws -> AuthSignInResult { if signInError.isUserNotConfirmed { return AuthSignInResult(nextStep: .confirmSignUp(nil)) @@ -136,14 +114,18 @@ struct UserPoolSignInHelper: DefaultLogger { parameters: parameters) switch challengeName { - case .smsMfa, .customChallenge, .newPasswordRequired, .softwareTokenMfa, .selectMfaType: + case .smsMfa, .customChallenge, .newPasswordRequired, .softwareTokenMfa, .selectMfaType, .emailOtp: 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)) + if allowedMFATypesForSetup.contains(.totp) && allowedMFATypesForSetup.contains(.email) { + return SignInEvent(eventType: .receivedChallenge(respondToAuthChallenge)) + } else if allowedMFATypesForSetup.contains(.totp) { + return SignInEvent(eventType: .initiateTOTPSetup(username, respondToAuthChallenge)) + } else if allowedMFATypesForSetup.contains(.email) { + return SignInEvent(eventType: .receivedChallenge(respondToAuthChallenge)) } else { let message = "Cannot initiate MFA setup from available Types: \(allowedMFATypesForSetup)" let error = SignInError.invalidServiceResponse(message: message) @@ -160,12 +142,4 @@ struct UserPoolSignInHelper: DefaultLogger { return SignInEvent(eventType: .throwAuthError(error)) } } - - public static var log: Logger { - Amplify.Logging.logger(forCategory: CategoryType.auth.displayName, forNamespace: String(describing: self)) - } - - public var log: Logger { - Self.log - } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInTask.swift index cc824c6f25..9b6ed906e3 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInTask.swift @@ -55,7 +55,7 @@ class AWSAuthConfirmSignInTask: AuthConfirmSignInTask, DefaultLogger { if case .resolvingChallenge(let challengeState, let challengeType, _) = signInState { // Validate if request valid MFA selection - if case .selectMFAType = challengeType { + if challengeType == .selectMFAType { try validateRequestForMFASelection() } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/UpdateMFAPreferenceTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/UpdateMFAPreferenceTask.swift index b9bedf4fe8..3bfdadb427 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/UpdateMFAPreferenceTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/UpdateMFAPreferenceTask.swift @@ -26,6 +26,7 @@ class UpdateMFAPreferenceTask: AuthUpdateMFAPreferenceTask, DefaultLogger { private let smsPreference: MFAPreference? private let totpPreference: MFAPreference? + private let emailPreference: MFAPreference? private let authStateMachine: AuthStateMachine private let userPoolFactory: CognitoUserPoolFactory private let taskHelper: AWSAuthTaskHelper @@ -36,10 +37,12 @@ class UpdateMFAPreferenceTask: AuthUpdateMFAPreferenceTask, DefaultLogger { init(smsPreference: MFAPreference?, totpPreference: MFAPreference?, + emailPreference: MFAPreference?, authStateMachine: AuthStateMachine, userPoolFactory: @escaping CognitoUserPoolFactory) { self.smsPreference = smsPreference self.totpPreference = totpPreference + self.emailPreference = emailPreference self.authStateMachine = authStateMachine self.userPoolFactory = userPoolFactory self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) @@ -63,6 +66,7 @@ class UpdateMFAPreferenceTask: AuthUpdateMFAPreferenceTask, DefaultLogger { let preferredMFAType = currentPreference.preferredMfaSetting.map(MFAType.init(rawValue:)) let input = SetUserMFAPreferenceInput( accessToken: accessToken, + emailMfaSettings: emailPreference?.emailSetting(isCurrentlyPreferred: preferredMFAType == .email), smsMfaSettings: smsPreference?.smsSetting(isCurrentlyPreferred: preferredMFAType == .sms), softwareTokenMfaSettings: totpPreference?.softwareTokenSetting(isCurrentlyPreferred: preferredMFAType == .totp)) _ = try await userPoolService.setUserMFAPreference(input: input) diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/VerifySignInChallenge/VerifySignInChallengeTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/VerifySignInChallenge/VerifySignInChallengeTests.swift index ba635f3463..7b05cdddab 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/VerifySignInChallenge/VerifySignInChallengeTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/VerifySignInChallenge/VerifySignInChallengeTests.swift @@ -49,7 +49,8 @@ class VerifySignInChallengeTests: XCTestCase { userPoolFactory: identityProviderFactory) let action = VerifySignInChallenge(challenge: mockRespondAuthChallenge, confirmSignEventData: mockConfirmEvent, - signInMethod: .apiBased(.userSRP)) + signInMethod: .apiBased(.userSRP), + currentSignInStep: .confirmSignInWithTOTPCode) await action.execute( withDispatcher: MockDispatcher { _ in }, @@ -84,7 +85,8 @@ class VerifySignInChallengeTests: XCTestCase { let action = VerifySignInChallenge(challenge: mockRespondAuthChallenge, confirmSignEventData: mockConfirmEvent, - signInMethod: .apiBased(.userSRP)) + signInMethod: .apiBased(.userSRP), + currentSignInStep: .confirmSignInWithTOTPCode) let passwordVerifierError = expectation(description: "passwordVerifierError") @@ -133,7 +135,8 @@ class VerifySignInChallengeTests: XCTestCase { let action = VerifySignInChallenge(challenge: mockRespondAuthChallenge, confirmSignEventData: mockConfirmEvent, - signInMethod: .apiBased(.userSRP)) + signInMethod: .apiBased(.userSRP), + currentSignInStep: .confirmSignInWithTOTPCode) let verifyChallengeComplete = expectation(description: "verifyChallengeComplete") @@ -183,7 +186,8 @@ class VerifySignInChallengeTests: XCTestCase { let action = VerifySignInChallenge(challenge: mockRespondAuthChallenge, confirmSignEventData: mockConfirmEvent, - signInMethod: .apiBased(.userSRP)) + signInMethod: .apiBased(.userSRP), + currentSignInStep: .confirmSignInWithTOTPCode) let passwordVerifierError = expectation( description: "passwordVerifierError") @@ -233,7 +237,8 @@ class VerifySignInChallengeTests: XCTestCase { let action = VerifySignInChallenge(challenge: mockRespondAuthChallenge, confirmSignEventData: mockConfirmEvent, - signInMethod: .apiBased(.userSRP)) + signInMethod: .apiBased(.userSRP), + currentSignInStep: .confirmSignInWithTOTPCode) let passwordVerifierError = expectation(description: "passwordVerifierError") let dispatcher = MockDispatcher { event in @@ -279,7 +284,8 @@ class VerifySignInChallengeTests: XCTestCase { let action = VerifySignInChallenge(challenge: mockRespondAuthChallenge, confirmSignEventData: mockConfirmEvent, - signInMethod: .apiBased(.userSRP)) + signInMethod: .apiBased(.userSRP), + currentSignInStep: .confirmSignInWithTOTPCode) let verifyChallengeComplete = expectation(description: "verifyChallengeComplete") @@ -323,7 +329,8 @@ class VerifySignInChallengeTests: XCTestCase { let action = VerifySignInChallenge(challenge: mockRespondAuthChallenge, confirmSignEventData: mockConfirmEvent, - signInMethod: .apiBased(.userSRP)) + signInMethod: .apiBased(.userSRP), + currentSignInStep: .confirmSignInWithTOTPCode) let verifyChallengeComplete = expectation(description: "verifyChallengeComplete") diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/HubEventTests/AuthHubEventHandlerTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/HubEventTests/AuthHubEventHandlerTests.swift index 5dc7ee016a..2cb4c64dd9 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/HubEventTests/AuthHubEventHandlerTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/HubEventTests/AuthHubEventHandlerTests.swift @@ -335,7 +335,7 @@ class AuthHubEventHandlerTests: XCTestCase { private func configurePluginForConfirmSignInEvent() { let initialState = AuthState.configured( AuthenticationState.signingIn(.resolvingChallenge( - .waitingForAnswer(.testData(), .apiBased(.userSRP)), + .waitingForAnswer(.testData(), .apiBased(.userSRP), .confirmSignInWithTOTPCode), .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 4ecd257a5d..c34a53b83d 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SRPSignInState/SRPTestData.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SRPSignInState/SRPTestData.swift @@ -110,12 +110,13 @@ extension RespondToAuthChallengeOutput { static func testData( challenge: CognitoIdentityProviderClientTypes.ChallengeNameType = .smsMfa, - challengeParameters: [String: String] = [:]) -> RespondToAuthChallengeOutput { + challengeParameters: [String: String] = [:], + session: String = "session") -> RespondToAuthChallengeOutput { return RespondToAuthChallengeOutput( authenticationResult: nil, challengeName: challenge, challengeParameters: challengeParameters, - session: "session") + session: session) } } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFetchSignInSessionOperationTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFetchSignInSessionOperationTests.swift index 0afa434b9c..6106a4717b 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFetchSignInSessionOperationTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFetchSignInSessionOperationTests.swift @@ -751,7 +751,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, .confirmSignInWithTOTPCode) let initialState = AuthState.configured( AuthenticationState.signingIn( .resolvingChallenge(challenge, .smsMfa, signInMethod)), diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInTaskTests.swift index da1a27739a..7e1a3ee6d1 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInTaskTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInTaskTests.swift @@ -19,7 +19,7 @@ class AuthenticationProviderConfirmSigninTests: BasePluginTest { override var initialState: AuthState { AuthState.configured( AuthenticationState.signingIn( - .resolvingChallenge(.waitingForAnswer(.testData(), .apiBased(.userSRP)), + .resolvingChallenge(.waitingForAnswer(.testData(), .apiBased(.userSRP), .confirmSignInWithTOTPCode), .smsMfa, .apiBased(.userSRP))), AuthorizationState.sessionEstablished(.testData)) } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInTOTPTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInTOTPTaskTests.swift index 98849dce07..473d673a4a 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInTOTPTaskTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInTOTPTaskTests.swift @@ -22,7 +22,8 @@ class ConfirmSignInTOTPTaskTests: BasePluginTest { .resolvingChallenge( .waitingForAnswer( .testData(challenge: .softwareTokenMfa), - .apiBased(.userSRP) + .apiBased(.userSRP), + .confirmSignInWithTOTPCode ), .totpMFA, .apiBased(.userSRP))), diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithMFASelectionTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithMFASelectionTaskTests.swift index da3e22fa6c..edb5b8687e 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithMFASelectionTaskTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithMFASelectionTaskTests.swift @@ -22,7 +22,8 @@ class ConfirmSignInWithMFASelectionTaskTests: BasePluginTest { .resolvingChallenge( .waitingForAnswer( .testData(challenge: .selectMfaType), - .apiBased(.userSRP) + .apiBased(.userSRP), + .confirmSignInWithTOTPCode ), .selectMFAType, .apiBased(.userSRP))), diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/EmailMFATests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/EmailMFATests.swift new file mode 100644 index 0000000000..92e34f6898 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/EmailMFATests.swift @@ -0,0 +1,342 @@ +// +// 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 AWSClientRuntime + +class EmailMFATests: BasePluginTest { + + override var initialState: AuthState { + AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + } + + /// Test a signIn with valid inputs getting continueSignInWithMFASetupSelection challenge + /// + /// - Given: Given an auth plugin with mocked service. + /// + /// - When: + /// - I invoke signIn with valid values + /// - Then: + /// - I should get a .continueSignInWithMFASetupSelection response + /// + func testSuccessfulMFASetupSelectionStep() async { + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutput( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutput.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\",\"EMAIL_OTP\"]"]) + }) + let options = AuthSignInRequest.Options() + + do { + let result = try await plugin.signIn( + username: "username", + password: "password", + options: options) + guard case .continueSignInWithMFASetupSelection(let mfaTypes) = result.nextStep else { + XCTFail("Result should be .continueSignInWithMFASetupSelection for next step") + return + } + XCTAssertTrue(mfaTypes.contains(.totp)) + XCTAssertTrue(mfaTypes.contains(.email)) + XCTAssertFalse(mfaTypes.contains(.sms)) + XCTAssertFalse(result.isSignedIn, "Signin result should be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + + /// Test a signIn with valid inputs getting continueSignInWithEmailMFASetup challenge + /// + /// - Given: Given an auth plugin with mocked service. + /// + /// - When: + /// - I invoke signIn with valid values + /// - Then: + /// - I should get a .continueSignInWithEmailMFASetup response + /// + func testSuccessfulEmailMFASetupStep() async { + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutput( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutput.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"EMAIL_OTP\"]"]) + }) + let options = AuthSignInRequest.Options() + + do { + let result = try await plugin.signIn( + username: "username", + password: "password", + options: options) + guard case .continueSignInWithEmailMFASetup = result.nextStep else { + XCTFail("Result should be .continueSignInWithEmailMFASetup for next step, instead got: \(result.nextStep)") + return + } + XCTAssertFalse(result.isSignedIn, "Signin result should be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + + /// Test a signIn with valid inputs getting confirmSignInWithOTP challenge + /// + /// - Given: Given an auth plugin with mocked service. + /// + /// - When: + /// - I invoke signIn with valid values + /// - Then: + /// - I should get a .confirmSignInWithOTP response + /// + func testSuccessfulEmailMFACodeStep() async { + var signInStepIterator = 0 + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutput( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutput.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + if signInStepIterator == 0 { + return .testData( + challenge: .emailOtp, + challengeParameters: [ + "CODE_DELIVERY_DELIVERY_MEDIUM": "EMAIL", + "CODE_DELIVERY_DESTINATION": "test@test.com"]) + } else if signInStepIterator == 1 { + XCTAssertEqual(input.challengeResponses?["EMAIL_OTP_CODE"], "123456") + XCTAssertEqual(input.session, "session") + return .testData() + } + fatalError("not supported code path") + }) + + do { + let result = try await plugin.signIn( + username: "username", + password: "password", + options: AuthSignInRequest.Options()) + guard case .confirmSignInWithOTP(let codeDetails) = result.nextStep else { + XCTFail("Result should be .confirmSignInWithOTP for next step, instead got: \(result.nextStep)") + return + } + if case .email(let destination) = codeDetails.destination { + XCTAssertEqual(destination, "test@test.com") + } else { + XCTFail("Destination should be email") + } + XCTAssertFalse(result.isSignedIn, "Signin result should be complete") + + // step 2: confirm sign in + signInStepIterator = 1 + let confirmSignInResult = try await plugin.confirmSignIn( + challengeResponse: "123456", + options: .init()) + 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 signIn with valid inputs getting continueSignInWithMFASetupSelection challenge + /// + /// - Given: Given an auth plugin with mocked service. + /// + /// - When: + /// - I invoke signIn with valid values + /// - Then: + /// - I should get a .continueSignInWithMFASetupSelection response + /// + func testConfirmSignInForEmailMFASetupSelectionStep() async { + var signInStepIterator = 0 + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutput( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutput.validChalengeParams, + session: "session0") + }, mockRespondToAuthChallengeResponse: { input in + switch signInStepIterator { + case 0: + XCTAssertEqual(input.session, "session0") + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\",\"EMAIL_OTP\"]"], + session: "session1") + case 1: + XCTAssertEqual(input.challengeResponses?["EMAIL"], "test@test.com") + XCTAssertEqual(input.session, "session1") + return .testData( + challenge: .emailOtp, + challengeParameters: [ + "CODE_DELIVERY_DELIVERY_MEDIUM": "EMAIL", + "CODE_DELIVERY_DESTINATION": "test@test.com"], + session: "session2") + case 2: + XCTAssertEqual(input.challengeResponses?["EMAIL_OTP_CODE"], "123456") + XCTAssertEqual(input.session, "session2") + return .testData() + default: fatalError("unsupported path") + } + + }) + + do { + // Step 1: initiate sign in + let result = try await plugin.signIn( + username: "username", + password: "password", + options: AuthSignInRequest.Options()) + guard case .continueSignInWithMFASetupSelection(let mfaTypes) = result.nextStep else { + XCTFail("Result should be .continueSignInWithMFASetupSelection for next step") + return + } + XCTAssertTrue(mfaTypes.contains(.totp)) + XCTAssertTrue(mfaTypes.contains(.email)) + XCTAssertFalse(mfaTypes.contains(.sms)) + XCTAssertFalse(result.isSignedIn, "Signin result should be complete") + + // Step 2: select email to continue setting up + var confirmSignInResult = try await plugin.confirmSignIn( + challengeResponse: MFAType.email.challengeResponse) + guard case .continueSignInWithEmailMFASetup = confirmSignInResult.nextStep else { + XCTFail("Result should be .continueSignInWithEmailMFASetup but got: \(confirmSignInResult.nextStep)") + return + } + + // Step 3: pass an email to setup + signInStepIterator = 1 + confirmSignInResult = try await plugin.confirmSignIn( + challengeResponse: "test@test.com") + guard case .confirmSignInWithOTP(let deliveryDetails) = confirmSignInResult.nextStep else { + XCTFail("Result should be .continueSignInWithEmailMFASetup but got: \(confirmSignInResult.nextStep)") + return + } + if case .email(let destination) = deliveryDetails.destination { + XCTAssertEqual(destination, "test@test.com") + } else { + XCTFail("Destination should be email") + } + + XCTAssertFalse(result.isSignedIn, "Signin result should be complete") + + // step 4: confirm sign in + signInStepIterator = 2 + confirmSignInResult = try await plugin.confirmSignIn( + challengeResponse: "123456", + options: .init()) + 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 signIn with valid inputs getting continueSignInWithMFASetupSelection challenge + /// + /// - Given: Given an auth plugin with mocked service. + /// + /// - When: + /// - I invoke signIn with valid values + /// - Then: + /// - I should get a .continueSignInWithMFASetupSelection response + /// + func testConfirmSignInForTOTPMFASetupSelectionStep() async { + var completeSignIn = false + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutput( + authenticationResult: .none, + challengeName: .passwordVerifier, + challengeParameters: InitiateAuthOutput.validChalengeParams, + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + if completeSignIn { + XCTAssertEqual(input.session, "verifiedSession") + return .testData() + } + + return .testData( + challenge: .mfaSetup, + challengeParameters: ["MFAS_CAN_SETUP": "[\"SMS_MFA\",\"SOFTWARE_TOKEN_MFA\",\"EMAIL_OTP\"]"]) + + + }, mockAssociateSoftwareTokenResponse: { input in + return .init(secretCode: "sharedSecret", session: "newSession") + }, mockVerifySoftwareTokenResponse: { request in + XCTAssertEqual(request.session, "newSession") + XCTAssertEqual(request.userCode, "123456") + XCTAssertEqual(request.friendlyDeviceName, "device") + return .init(session: "verifiedSession", status: .success) + }) + + do { + // Step 1: initiate sign in + let result = try await plugin.signIn( + username: "username", + password: "password", + options: AuthSignInRequest.Options()) + guard case .continueSignInWithMFASetupSelection(let mfaTypes) = result.nextStep else { + XCTFail("Result should be .continueSignInWithMFASetupSelection for next step") + return + } + XCTAssertTrue(mfaTypes.contains(.totp)) + XCTAssertTrue(mfaTypes.contains(.email)) + XCTAssertFalse(mfaTypes.contains(.sms)) + XCTAssertFalse(result.isSignedIn, "Signin result should be complete") + + // Step 2: continue sign in by selecting TOTP for set up + var confirmSignInResult = try await plugin.confirmSignIn( + challengeResponse: MFAType.totp.challengeResponse) + guard case .continueSignInWithTOTPSetup(let totpDetails) = confirmSignInResult.nextStep else { + XCTFail("Result should be .continueSignInWithEmailMFASetup but got: \(confirmSignInResult.nextStep)") + return + } + XCTAssertEqual(totpDetails.sharedSecret, "sharedSecret") + XCTAssertEqual(totpDetails.username, "royji2") + XCTAssertFalse(result.isSignedIn, "Signin result should be complete") + + // Step 3: complete sign in by verifying TOTP set up + completeSignIn = true + let pluginOptions = AWSAuthConfirmSignInOptions(friendlyDeviceName: "device") + 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)") + } + } +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/SignInSetUpTOTPTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/SignInSetUpTOTPTests.swift index 65afb80f8a..e834f3b2ee 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/SignInSetUpTOTPTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/SignInSetUpTOTPTests.swift @@ -76,7 +76,7 @@ class SignInSetUpTOTPTests: BasePluginTest { session: "session") }, mockAssociateSoftwareTokenResponse: { _ in return .init(secretCode: "123456", session: "session") - } ) + }) let options = AuthSignInRequest.Options() do { diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignInChallengeState+Codable.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignInChallengeState+Codable.swift index 467535a3c9..f2d200309f 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignInChallengeState+Codable.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignInChallengeState+Codable.swift @@ -31,7 +31,7 @@ extension SignInChallengeState: Codable { username: try nestedContainerValue.decode(String.self, forKey: .username), session: try nestedContainerValue.decode(String.self, forKey: .session), parameters: try nestedContainerValue.decode([String: String].self, forKey: .parameters)), - .apiBased(.userSRP)) + .apiBased(.userSRP), .confirmSignInWithTOTPCode) } else { fatalError("Decoding not supported") } diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/project.pbxproj b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/project.pbxproj index bc7fdbcec5..288dc10c20 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/project.pbxproj +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/project.pbxproj @@ -61,6 +61,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 */; }; + 487C40232CACF303009CF221 /* EmailMFAWithAllMFATypesRequiredTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 487C40222CACF2FD009CF221 /* EmailMFAWithAllMFATypesRequiredTests.swift */; }; + 487C40242CACF303009CF221 /* EmailMFAWithAllMFATypesRequiredTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 487C40222CACF2FD009CF221 /* EmailMFAWithAllMFATypesRequiredTests.swift */; }; + 487C40252CACF303009CF221 /* EmailMFAWithAllMFATypesRequiredTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 487C40222CACF2FD009CF221 /* EmailMFAWithAllMFATypesRequiredTests.swift */; }; + 487C40382CACFD50009CF221 /* AWSAPIPlugin in Frameworks */ = {isa = PBXBuildFile; productRef = 487C40372CACFD50009CF221 /* AWSAPIPlugin */; }; + 487C403F2CADE8DA009CF221 /* EmailMFAOnlyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 487C403E2CADE88F009CF221 /* EmailMFAOnlyTests.swift */; }; + 487C40402CADE8DA009CF221 /* EmailMFAOnlyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 487C403E2CADE88F009CF221 /* EmailMFAOnlyTests.swift */; }; + 487C40412CADE8DA009CF221 /* EmailMFAOnlyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 487C403E2CADE88F009CF221 /* EmailMFAOnlyTests.swift */; }; + 487C40432CAE2905009CF221 /* AWSAPIPlugin in Frameworks */ = {isa = PBXBuildFile; productRef = 487C40422CAE2905009CF221 /* AWSAPIPlugin */; }; 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 */; }; @@ -196,6 +204,8 @@ 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 = ""; }; + 487C40222CACF2FD009CF221 /* EmailMFAWithAllMFATypesRequiredTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailMFAWithAllMFATypesRequiredTests.swift; sourceTree = ""; }; + 487C403E2CADE88F009CF221 /* EmailMFAOnlyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailMFAOnlyTests.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 = ""; }; @@ -233,6 +243,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 487C40382CACFD50009CF221 /* AWSAPIPlugin in Frameworks */, B4B9F45828F47C0A004F346F /* Amplify in Frameworks */, B4B9F45A28F47C0A004F346F /* AWSCognitoAuthPlugin in Frameworks */, ); @@ -249,6 +260,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 487C40432CAE2905009CF221 /* AWSAPIPlugin in Frameworks */, 681B769A2A3CBA97004B59D9 /* Amplify in Frameworks */, 681B769C2A3CBA97004B59D9 /* AWSCognitoAuthPlugin in Frameworks */, ); @@ -434,9 +446,19 @@ name = Packages; sourceTree = ""; }; + 487C403D2CADBC37009CF221 /* EmailMFATests */ = { + isa = PBXGroup; + children = ( + 487C403E2CADE88F009CF221 /* EmailMFAOnlyTests.swift */, + 487C40222CACF2FD009CF221 /* EmailMFAWithAllMFATypesRequiredTests.swift */, + ); + path = EmailMFATests; + sourceTree = ""; + }; 48916F362A412AF800E3E1B1 /* MFATests */ = { isa = PBXGroup; children = ( + 487C403D2CADBC37009CF221 /* EmailMFATests */, 48916F372A412B2800E3E1B1 /* TOTPSetupWhenAuthenticatedTests.swift */, 48916F3B2A42333E00E3E1B1 /* MFAPreferenceTests.swift */, 48599D492A429893009DE21C /* MFASignInTests.swift */, @@ -536,6 +558,7 @@ packageProductDependencies = ( B4B9F45728F47C0A004F346F /* Amplify */, B4B9F45928F47C0A004F346F /* AWSCognitoAuthPlugin */, + 487C40372CACFD50009CF221 /* AWSAPIPlugin */, ); productName = AuthHostApp; productReference = 485CB53A27B614CE006CCEC7 /* AuthHostApp.app */; @@ -579,6 +602,7 @@ packageProductDependencies = ( 681B76992A3CBA97004B59D9 /* Amplify */, 681B769B2A3CBA97004B59D9 /* AWSCognitoAuthPlugin */, + 487C40422CAE2905009CF221 /* AWSAPIPlugin */, ); productName = "AuthWatchApp Watch App"; productReference = 681B76802A3CB86B004B59D9 /* AuthWatchApp.app */; @@ -821,6 +845,7 @@ 21F762B22BD6B1AA0048845A /* SignedOutAuthSessionTests.swift in Sources */, 21F762B32BD6B1AA0048845A /* AuthSignInHelper.swift in Sources */, 21F762B42BD6B1AA0048845A /* FederatedSessionTests.swift in Sources */, + 487C40402CADE8DA009CF221 /* EmailMFAOnlyTests.swift in Sources */, 21F762B52BD6B1AA0048845A /* AuthCustomSignInTests.swift in Sources */, 21F762B62BD6B1AA0048845A /* AuthEventIntegrationTests.swift in Sources */, 21F762B72BD6B1AA0048845A /* AuthEnvironmentHelper.swift in Sources */, @@ -834,6 +859,7 @@ 21F762BF2BD6B1AA0048845A /* MFASignInTests.swift in Sources */, 21F762C02BD6B1AA0048845A /* SignedInAuthSessionTests.swift in Sources */, 21F762C12BD6B1AA0048845A /* AuthSignUpTests.swift in Sources */, + 487C40252CACF303009CF221 /* EmailMFAWithAllMFATypesRequiredTests.swift in Sources */, 21F762C22BD6B1AA0048845A /* AuthConfirmResetPasswordTests.swift in Sources */, 21F762C32BD6B1AA0048845A /* AuthDeleteUserTests.swift in Sources */, ); @@ -853,6 +879,7 @@ buildActionMask = 2147483647; files = ( 485CB5B927B61F10006CCEC7 /* AuthSessionHelper.swift in Sources */, + 487C403F2CADE8DA009CF221 /* EmailMFAOnlyTests.swift in Sources */, 681DFEAB28E747B80000C36A /* AsyncTesting.swift in Sources */, 485CB5C227B61F1E006CCEC7 /* AuthSRPSignInTests.swift in Sources */, 9737C7502880BFD600DA0D2B /* AuthForgetDeviceTests.swift in Sources */, @@ -865,6 +892,7 @@ 48E3AB3128E52590004EE395 /* GetCurrentUserTests.swift in Sources */, 48916F3A2A412CEE00E3E1B1 /* TOTPHelper.swift in Sources */, 21CFD7C62C7524570071C70F /* AppSyncSignerTests.swift in Sources */, + 487C40232CACF303009CF221 /* EmailMFAWithAllMFATypesRequiredTests.swift in Sources */, 485CB5B127B61EAC006CCEC7 /* AWSAuthBaseTest.swift in Sources */, 485CB5C027B61F1E006CCEC7 /* SignedOutAuthSessionTests.swift in Sources */, 485CB5BA27B61F10006CCEC7 /* AuthSignInHelper.swift in Sources */, @@ -916,6 +944,7 @@ 681B76AC2A3CBBAE004B59D9 /* AWSAuthBaseTest.swift in Sources */, 681B76AD2A3CBBAE004B59D9 /* SignedOutAuthSessionTests.swift in Sources */, 681B76AE2A3CBBAE004B59D9 /* AuthSignInHelper.swift in Sources */, + 487C40412CADE8DA009CF221 /* EmailMFAOnlyTests.swift in Sources */, 681B76AF2A3CBBAE004B59D9 /* FederatedSessionTests.swift in Sources */, 681B76B02A3CBBAE004B59D9 /* AuthCustomSignInTests.swift in Sources */, 681B76B12A3CBBAE004B59D9 /* AuthEventIntegrationTests.swift in Sources */, @@ -929,6 +958,7 @@ 681B76B92A3CBBAE004B59D9 /* SignedInAuthSessionTests.swift in Sources */, 681B76BA2A3CBBAE004B59D9 /* AuthSignUpTests.swift in Sources */, 681B76BB2A3CBBAE004B59D9 /* AuthConfirmResetPasswordTests.swift in Sources */, + 487C40242CACF303009CF221 /* EmailMFAWithAllMFATypesRequiredTests.swift in Sources */, 48BCE8942A54564C0012C3CD /* MFASignInTests.swift in Sources */, 681B76BC2A3CBBAE004B59D9 /* AuthDeleteUserTests.swift in Sources */, ); @@ -1489,6 +1519,14 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ + 487C40372CACFD50009CF221 /* AWSAPIPlugin */ = { + isa = XCSwiftPackageProductDependency; + productName = AWSAPIPlugin; + }; + 487C40422CAE2905009CF221 /* AWSAPIPlugin */ = { + isa = XCSwiftPackageProductDependency; + productName = AWSAPIPlugin; + }; 681B76992A3CBA97004B59D9 /* Amplify */ = { isa = XCSwiftPackageProductDependency; productName = Amplify; diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/AWSAuthBaseTest.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/AWSAuthBaseTest.swift index 69668eee0d..fa4a455bde 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/AWSAuthBaseTest.swift +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/AWSAuthBaseTest.swift @@ -9,15 +9,17 @@ import XCTest @_spi(InternalAmplifyConfiguration) @testable import Amplify import AWSCognitoAuthPlugin +fileprivate let internalTestDomain = "@amplify-swift-gamma.awsapps.com" + class AWSAuthBaseTest: XCTestCase { let networkTimeout = TimeInterval(5) - var defaultTestEmail = "test-\(UUID().uuidString)@amazon.com" + var defaultTestEmail = "test-\(UUID().uuidString)\(internalTestDomain)" var defaultTestPassword = UUID().uuidString var randomEmail: String { - "test-\(UUID().uuidString)@amazon.com" + "test-\(UUID().uuidString)\(internalTestDomain)" } var randomPhoneNumber: String { @@ -34,8 +36,10 @@ class AWSAuthBaseTest: XCTestCase { var amplifyConfiguration: AmplifyConfiguration! var amplifyOutputs: AmplifyOutputsData! + var onlyUseGen2Configuration = false + var useGen2Configuration: Bool { - ProcessInfo.processInfo.arguments.contains("GEN2") + ProcessInfo.processInfo.arguments.contains("GEN2") || onlyUseGen2Configuration } override func setUp() async throws { @@ -46,6 +50,7 @@ class AWSAuthBaseTest: XCTestCase { override func tearDown() async throws { try await super.tearDown() + subscription?.cancel() await Amplify.reset() } @@ -113,6 +118,84 @@ class AWSAuthBaseTest: XCTestCase { XCTFail("Amplify configuration failed") } } + + // Dictionary to store MFA codes with usernames as keys + var mfaCodeDictionary: [String: String] = [:] + var subscription: AmplifyAsyncThrowingSequence>? = nil + + let document: String = """ + subscription OnCreateMfaInfo { + onCreateMfaInfo { + username + code + expirationTime + } + } + """ + + /// Function to create a subscription and store MFA codes in a dictionary + func createMFASubscription() { + subscription = Amplify.API.subscribe(request: .init(document: document, responseType: [String: JSONValue].self)) + + // Create the subscription and listen for MFA code events + Task { + do { + guard let subscription = subscription else { return } + for try await subscriptionEvent in subscription { + switch subscriptionEvent { + case .connection(let subscriptionConnectionState): + print("Subscription connect state is \(subscriptionConnectionState)") + case .data(let result): + switch result { + case .success(let mfaCodeResult): + print("Successfully got MFA code from subscription: \(mfaCodeResult)") + if let eventUsername = mfaCodeResult["onCreateMfaInfo"]?.asObject?["username"]?.stringValue, + let code = mfaCodeResult["onCreateMfaInfo"]?.asObject?["code"]?.stringValue { + // Store the code in the dictionary for the given username + mfaCodeDictionary[eventUsername] = code + } + case .failure(let error): + print("Got failed result with \(error.errorDescription)") + } + } + } + } catch { + print("Subscription terminated with error: \(error)") + } + } + } + + /// Test that waits for the MFA code using XCTestExpectation + func waitForMFACode(for username: String) async throws -> String? { + let expectation = XCTestExpectation(description: "Wait for MFA code") + expectation.expectedFulfillmentCount = 1 + + let task = Task { () -> String? in + var code: String? + for _ in 0..<30 { // Poll for the code, max 30 times (once per second) + if let mfaCode = mfaCodeDictionary[username] { + code = mfaCode + expectation.fulfill() // Fulfill the expectation when the value is found + break + } + try await Task.sleep(nanoseconds: 1_000_000_000) // Sleep for 1 second + } + return code + } + + // Wait for expectation or timeout after 30 seconds + let result = await XCTWaiter.fulfillment(of: [expectation], timeout: 30) + + if result == .timedOut { + // Task cancels if timed out + task.cancel() + subscription?.cancel() + return nil + } + + subscription?.cancel() + return try await task.value + } } class TestConfigHelper { diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/AuthSignInHelper.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/AuthSignInHelper.swift index 1dc43decc6..625f72c58a 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/AuthSignInHelper.swift +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/Helpers/AuthSignInHelper.swift @@ -22,10 +22,20 @@ enum AuthSignInHelper { password: String, email: String, phoneNumber: String? = nil) async throws -> Bool { + return try await signUpUserReturningResult(username: username, password: password, email: email, phoneNumber: phoneNumber).isSignUpComplete + } + + static func signUpUserReturningResult( + username: String, + password: String, + email: String? = nil, + phoneNumber: String? = nil) async throws -> AuthSignUpResult { + + var userAttributes: [AuthUserAttribute] = [] - var userAttributes = [ - AuthUserAttribute(.email, value: email) - ] + if let email = email { + userAttributes.append(AuthUserAttribute(.email, value: email)) + } if let phoneNumber = phoneNumber { userAttributes.append(AuthUserAttribute(.phoneNumber, value: phoneNumber)) @@ -34,7 +44,7 @@ enum AuthSignInHelper { let options = AuthSignUpRequest.Options( userAttributes: userAttributes) let result = try await Amplify.Auth.signUp(username: username, password: password, options: options) - return result.isSignUpComplete + return result } static func signInUser(username: String, password: String) async throws -> AuthSignInResult { @@ -46,7 +56,7 @@ enum AuthSignInHelper { password: String, email: String, phoneNumber: String? = nil) async throws -> Bool { - let signedUp = try await AuthSignInHelper.signUpUser( + let signedUp: Bool = try await AuthSignInHelper.signUpUser( username: username, password: password, email: email, diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAOnlyTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAOnlyTests.swift new file mode 100644 index 0000000000..06c899c2b5 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAOnlyTests.swift @@ -0,0 +1,219 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import Amplify +import AWSCognitoAuthPlugin +import AWSAPIPlugin + +// Follow MFATests/EmailMFAOnlyTests/Readme.md for test setup locally +// Test class for scenarios where only Email MFA is required. +// - This test suite verifies the sign-in process when only Email MFA is enabled. +// loginWith: { +// email: true, +// }, +// multifactor: { +// mode: "REQUIRED", +// sms: true, +// email: true, (email has not been added to backend at the time of writing this test) +// }, +class EmailMFARequiredTests: AWSAuthBaseTest { + + // Sets up the test environment with a custom configuration and adds required plugins + override func setUp() async throws { + // Only run these tests with Gen2 configuration + onlyUseGen2Configuration = true + + // Specify a custom test configuration for these tests + amplifyOutputsFile = "testconfiguration/AWSCognitoEmailMFARequiredTests-amplify_outputs" + + // Add API plugin to Amplify + let awsApiPlugin = AWSAPIPlugin() + try Amplify.add(plugin: awsApiPlugin) + try await super.setUp() + + // Clear session to ensure a fresh state for each test + AuthSessionHelper.clearSession() + } + + // Tear down the test environment and clear the session + override func tearDown() async throws { + try await super.tearDown() + AuthSessionHelper.clearSession() + } + + /// Test the sign-in flow when Email MFA setup is required. + /// + /// - Given: A new user is created, and only Email MFA is required for the account. + /// - When: The user provides valid username and password, and then proceeds through the MFA setup flow. + /// - Then: The user should successfully complete the MFA setup and be able to sign in. + /// + /// - MFA Setup Flow: + /// - Step 1: User signs in and receives the `continueSignInWithEmailMFASetup` challenge. + /// - Step 2: User provides their email for MFA setup. + /// - Step 3: User receives and confirms the MFA code sent to their email. + /// - Step 4: Sign-in completes, and the email is associated with the user account. + func testSuccessfulEmailMFASetupStep() async { + do { + // Step 1: Set up a subscription to receive MFA codes + createMFASubscription() + + // Step 2: Sign up a new user + let uniqueId = UUID().uuidString + let username = "integTest\(uniqueId)" + let password = "Pp123@\(uniqueId)" + + _ = try await AuthSignInHelper.signUpUserReturningResult( + username: username, + password: password) + + let options = AuthSignInRequest.Options() + // Step 3: Initiate sign-in, expecting MFA setup to be required + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: options) + + // Step 4: Ensure that the next step is to set up Email MFA + guard case .continueSignInWithEmailMFASetup = result.nextStep else { + XCTFail("Expected .continueSignInWithEmailMFASetup step, got \(result.nextStep)") + return + } + + // Step 5: Provide the email address to complete MFA setup + var confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: defaultTestEmail) + + // Step 6: Ensure that the next step is to confirm the Email MFA code + guard case .confirmSignInWithOTP(let deliveryDetails) = confirmSignInResult.nextStep else { + XCTFail("Expected .confirmSignInWithOTP step, got \(confirmSignInResult.nextStep)") + return + } + if case .email(let destination) = deliveryDetails.destination { + XCTAssertNotNil(destination, "Email destination should be provided") + } else { + XCTFail("Expected the destination to be email") + } + + XCTAssertFalse(result.isSignedIn, "User should not be signed in at this stage") + + // Step 7: Retrieve the MFA code sent to the email and confirm the sign-in + guard let mfaCode = try await waitForMFACode(for: username.lowercased()) else { + XCTFail("Failed to retrieve the MFA code") + return + } + + confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: mfaCode, + options: .init()) + + // Step 8: Ensure that the sign-in process is complete + guard case .done = confirmSignInResult.nextStep else { + XCTFail("Expected .done step after confirming MFA") + return + } + XCTAssertTrue(confirmSignInResult.isSignedIn, "User should be signed in at this stage") + XCTAssertFalse(result.isSignedIn, "User should not be signed in at the initial stage") + + // Step 9: Verify that the email is associated with the user account + let attributes = try await Amplify.Auth.fetchUserAttributes() + XCTAssertEqual(attributes.first(where: { $0.key == .email })?.value, defaultTestEmail) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + + /// Test the sign-in flow when an incorrect MFA code is entered first, followed by the correct MFA code. + /// + /// - Given: A new user is created, and only Email MFA is required for the account. + /// - When: The user provides valid username and password, receives the MFA code via email, enters an incorrect code, + /// and then enters the correct MFA code. + /// - Then: The user should receive a `codeMismatch` error for the incorrect code, but after entering the correct MFA code, + /// they should successfully complete the MFA process and sign in. + /// + /// - MFA Setup Flow: + /// - Step 1: User signs in and receives the `confirmSignInWithOTP` challenge. + /// - Step 2: User enters an incorrect MFA code and receives a `codeMismatch` error. + /// - Step 3: User enters the correct MFA code. + /// - Step 4: Sign-in completes, and the email is associated with the user account. + func testSuccessfulEmailMFAWithIncorrectCodeFirstAndThenValidOne() async { + do { + // Step 1: Set up a subscription to receive MFA codes + createMFASubscription() + + // Step 2: Sign up a new user + let uniqueId = UUID().uuidString + let username = "integTest\(uniqueId)" + let password = "Pp123@\(uniqueId)" + + _ = try await AuthSignInHelper.signUpUserReturningResult( + username: username, + password: password, + email: defaultTestEmail) + + let options = AuthSignInRequest.Options() + + // Step 3: Initiate sign-in, expecting MFA setup to be required + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: options) + + // Step 6: Ensure that the next step is to confirm the Email MFA code + guard case .confirmSignInWithOTP(let deliveryDetails) = result.nextStep else { + XCTFail("Expected .confirmSignInWithOTP step, got \(result.nextStep)") + return + } + if case .email(let destination) = deliveryDetails.destination { + XCTAssertNotNil(destination, "Email destination should be provided") + } else { + XCTFail("Expected the destination to be email") + } + + XCTAssertFalse(result.isSignedIn, "User should not be signed in at this stage") + + // Step 7: Retrieve the MFA code sent to the email and confirm the sign-in + guard let mfaCode = try await waitForMFACode(for: username.lowercased()) else { + XCTFail("Failed to retrieve the MFA code") + return + } + + // Step 6: Enter an incorrect MFA code first + do { + _ = try await Amplify.Auth.confirmSignIn( + challengeResponse: "000000", + options: .init()) + } catch AuthError.service(_, _, let error) { + + guard let underlyingError = error as? AWSCognitoAuthError else { + XCTFail("Expected an AWS Cognito Auth error") + return + } + guard underlyingError == .codeMismatch else { + XCTFail("Expected .codeMismatch error") + return + } + + // Step 7: Enter the correct MFA code + let confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: mfaCode, + options: .init()) + + // Step 8: Ensure that the sign-in process is complete + guard case .done = confirmSignInResult.nextStep else { + XCTFail("Expected .done step after confirming MFA") + return + } + XCTAssertTrue(confirmSignInResult.isSignedIn, "User should be signed in at this stage") + XCTAssertFalse(result.isSignedIn, "User should not be signed in at the initial stage") + } + } catch { + XCTFail("Unexpected error: \(error)") + } + } +} diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAWithAllMFATypesRequiredTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAWithAllMFATypesRequiredTests.swift new file mode 100644 index 0000000000..fcbfe6d64d --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAWithAllMFATypesRequiredTests.swift @@ -0,0 +1,287 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import Amplify +import AWSCognitoAuthPlugin +import AWSAPIPlugin + +// Follow MFATests/EmailMFAOnlyTests/Readme.md for test setup locally +// Test class for MFA Required scenario with Email, TOTP, and SMS MFA enabled. +// - This test suite verifies various steps in the MFA sign-in process when multiple MFA types (Email, TOTP, SMS) are required. +// loginWith: { +// email: true, +// }, +// multifactor: { +// mode: "REQUIRED", +// sms: true, +// totp: true, +// email: true, (email has not been added to backend at the time of writing this test) +// }, +class EmailMFAWithAllMFATypesRequiredTests: AWSAuthBaseTest { + + // Sets up the test environment using Gen2 configuration and adds required plugins + override func setUp() async throws { + // Only run these tests with Gen2 configuration + onlyUseGen2Configuration = true + + // Specify a custom test configuration for these tests + amplifyOutputsFile = "testconfiguration/AWSCognitoAuthEmailMFAWithAllMFATypesRequired-amplify_outputs" + + // Add API plugin to Amplify + let awsApiPlugin = AWSAPIPlugin() + try Amplify.add(plugin: awsApiPlugin) + try await super.setUp() + + // Clear session to ensure a fresh state for each test + AuthSessionHelper.clearSession() + } + + // Tear down test environment and clear the session + override func tearDown() async throws { + try await super.tearDown() + AuthSessionHelper.clearSession() + } + + /// Test the sign-in flow when MFA setup is required with multiple MFA options (Email and TOTP). + /// + /// - Given: The user has successfully signed up and is trying to sign in. + /// - When: The user provides valid username and password. + /// - Then: The sign-in process should return a `.continueSignInWithMFASetupSelection` challenge to select the MFA type to set up. + func testSuccessfulMFASetupSelectionStep() async { + + let options = AuthSignInRequest.Options() + + do { + // Step 1: Sign up a new user + let uniqueId = UUID().uuidString + let username = "integTest\(uniqueId)" + let password = "Pp123@\(uniqueId)" + + _ = try await AuthSignInHelper.signUpUserReturningResult( + username: username, + password: password) + + // Step 2: Attempt to sign in with the newly created user + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: options) + + // Step 3: Ensure that MFA setup is required and TOTP and Email are available as options + guard case .continueSignInWithMFASetupSelection(let mfaTypes) = result.nextStep else { + XCTFail("Expected .continueSignInWithMFASetupSelection step") + return + } + XCTAssertTrue(mfaTypes.contains(.totp), "TOTP should be available as an MFA option") + XCTAssertTrue(mfaTypes.contains(.email), "Email should be available as an MFA option") + XCTAssertFalse(mfaTypes.contains(.sms), "SMS should not be available as an MFA option") + XCTAssertFalse(result.isSignedIn, "User should not be signed in at this stage") + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + /// Test the sign-in flow with Email MFA when the user is prompted to confirm the MFA code. + /// + /// - Given: The user is required to provide an Email MFA code to complete sign-in. + /// - When: The user provides valid username and password, and then submits the correct MFA code. + /// - Then: The sign-in should complete after confirming the MFA code. + func testSuccessfulEmailMFACodeStep() async { + do { + // Step 1: Set up a subscription to receive MFA codes + createMFASubscription() + let uniqueId = UUID().uuidString + let username = randomEmail + let password = "Pp123@\(uniqueId)" + + // Step 2: Sign up a new user with email + _ = try await AuthSignInHelper.signUpUserReturningResult( + username: username, + password: password, + email: username) + + // Step 3: Attempt to sign in, which should prompt for Email MFA + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: AuthSignInRequest.Options()) + + // Step 4: Verify that the next step is to confirm the Email MFA code + guard case .confirmSignInWithOTP(let codeDetails) = result.nextStep else { + XCTFail("Expected .confirmSignInWithOTP step, got \(result.nextStep)") + return + } + if case .email(let destination) = codeDetails.destination { + XCTAssertNotNil(destination, "Email destination should be provided") + } else { + XCTFail("Destination should be email") + } + XCTAssertFalse(result.isSignedIn, "User should not be signed in at this stage") + + // Step 5: Retrieve the MFA code and confirm the sign-in + guard let mfaCode = try await waitForMFACode(for: username.lowercased()) else { + XCTFail("Failed to retrieve the MFA code") + return + } + + let confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: mfaCode, + options: .init()) + + // Step 6: Ensure that the sign-in is complete + guard case .done = confirmSignInResult.nextStep else { + XCTFail("Expected .done step after confirming MFA") + return + } + XCTAssertTrue(confirmSignInResult.isSignedIn, "User should be signed in at this stage") + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + /// Test confirming sign-in for Email MFA setup after selecting it as an MFA option. + /// + /// - Given: The user is prompted to select Email as an MFA type. + /// - When: The user selects Email and submits their email address for setup. + /// - Then: The user should be prompted to confirm the Email MFA code and complete sign-in. + func testConfirmSignInForEmailMFASetupSelectionStep() async { + do { + // Step 1: Set up a subscription to receive MFA codes + createMFASubscription() + let uniqueId = UUID().uuidString + let username = "\(uniqueId)" + let password = "Pp123@\(uniqueId)" + + // Step 2: Sign up a new user + _ = try await AuthSignInHelper.signUpUserReturningResult( + username: username, + password: password) + + // Step 3: Initiate sign-in, expecting MFA setup selection + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: AuthSignInRequest.Options()) + + // Step 4: Verify that the next step is to select an MFA type + guard case .continueSignInWithMFASetupSelection(let mfaTypes) = result.nextStep else { + XCTFail("Expected .continueSignInWithMFASetupSelection step") + return + } + XCTAssertTrue(mfaTypes.contains(.totp), "TOTP should be available as an MFA option") + XCTAssertTrue(mfaTypes.contains(.email), "Email should be available as an MFA option") + XCTAssertFalse(mfaTypes.contains(.sms), "SMS should not be available as an MFA option") + XCTAssertFalse(result.isSignedIn, "User should not be signed in at this stage") + + // Step 5: Select Email as the MFA option to proceed + var confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: MFAType.email.challengeResponse) + + // Step 6: Verify that the next step is to set up Email MFA + guard case .continueSignInWithEmailMFASetup = confirmSignInResult.nextStep else { + XCTFail("Expected .continueSignInWithEmailMFASetup step") + return + } + + // Step 7: Provide the email address to complete the setup + confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: defaultTestEmail) + + // Step 8: Verify that the next step is to confirm the Email MFA code + guard case .confirmSignInWithOTP(let deliveryDetails) = confirmSignInResult.nextStep else { + XCTFail("Expected .confirmSignInWithOTP step") + return + } + if case .email(let destination) = deliveryDetails.destination { + XCTAssertNotNil(destination, "Email destination should be provided") + } + + XCTAssertFalse(result.isSignedIn, "User should not be signed in at this stage") + + // Step 9: Confirm the sign-in with the received MFA code + guard let mfaCode = try await waitForMFACode(for: username.lowercased()) else { + XCTFail("Failed to retrieve the MFA code") + return + } + confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: mfaCode, + options: .init()) + guard case .done = confirmSignInResult.nextStep else { + XCTFail("Expected .done step after confirming MFA") + return + } + XCTAssertTrue(confirmSignInResult.isSignedIn, "User should be signed in at this stage") + + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + /// Test a signIn with valid inputs getting continueSignInWithMFASetupSelection challenge + /// + /// - Given: Given an auth plugin with mocked service. + /// + /// - When: + /// - I invoke signIn with valid values + /// - Then: + /// - I should get a .continueSignInWithMFASetupSelection response + /// + func testConfirmSignInForTOTPMFASetupSelectionStep() async { + do { + + let uniqueId = UUID().uuidString + let username = "\(uniqueId)" + let password = "Pp123@\(uniqueId)" + + _ = try await AuthSignInHelper.signUpUserReturningResult( + username: username, + password: password) + + // Step 1: initiate sign in + let result = try await Amplify.Auth.signIn( + username: username, + password: password, + options: AuthSignInRequest.Options()) + guard case .continueSignInWithMFASetupSelection(let mfaTypes) = result.nextStep else { + XCTFail("Result should be .continueSignInWithMFASetupSelection for next step") + return + } + XCTAssertTrue(mfaTypes.contains(.totp)) + XCTAssertTrue(mfaTypes.contains(.email)) + XCTAssertFalse(mfaTypes.contains(.sms)) + XCTAssertFalse(result.isSignedIn, "Signin result should be complete") + + // Step 2: continue sign in by selecting TOTP for set up + var confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: MFAType.totp.challengeResponse) + guard case .continueSignInWithTOTPSetup(let totpDetails) = confirmSignInResult.nextStep else { + XCTFail("Result should be .continueSignInWithEmailMFASetup but got: \(confirmSignInResult.nextStep)") + return + } + XCTAssertNotNil(totpDetails.sharedSecret) + XCTAssertNotNil(totpDetails.username) + XCTAssertFalse(result.isSignedIn, "Signin result should be complete") + + // Step 3: complete sign in by verifying TOTP set up + let totpCode = TOTPHelper.generateTOTPCode(sharedSecret: totpDetails.sharedSecret) + let pluginOptions = AWSAuthConfirmSignInOptions(friendlyDeviceName: "device") + confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: totpCode, + options: .init(pluginOptions: pluginOptions)) + guard case .done = confirmSignInResult.nextStep else { + XCTFail("Expected .done step after confirming MFA") + return + } + XCTAssertTrue(confirmSignInResult.isSignedIn, "User should be signed in at this stage") + + } catch { + XCTFail("Unexpected error: \(error)") + } + } + +} diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/README.md b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/README.md new file mode 100644 index 0000000000..b2a4c2fa45 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/README.md @@ -0,0 +1,454 @@ +# Schema: AuthIntegrationTests - AWSCognitoAuthPlugin Integration tests + +The following steps demonstrate how to setup the integration tests for auth plugin where an OTP is sent to the user's email address or phone number. T + +## Schema: AuthGen2IntegrationTests + +The following steps demonstrate how to setup the integration tests for auth plugin using Amplify CLI (Gen2). + +### Set-up + +At the time this was written, it follows the steps from here https://docs.amplify.aws/gen2/deploy-and-host/fullstack-branching/mono-and-multi-repos/ + +1. From a new folder, run `npm create amplify@latest`. This uses the following versions of the Amplify CLI, see `package.json` file below. + +```json +{ + ... + "devDependencies": { + "@aws-amplify/backend": "^0.15.0", + "@aws-amplify/backend-cli": "^0.15.0", + "aws-cdk": "^2.139.0", + "aws-cdk-lib": "^2.139.0", + "constructs": "^10.3.0", + "esbuild": "^0.20.2", + "tsx": "^4.7.3", + "typescript": "^5.4.5" + }, + "dependencies": { + "aws-amplify": "^6.2.0" + }, +} +``` + +2. Update `amplify/auth/resource.ts`. The resulting file should look like this + +```ts +import { defineAuth, defineFunction } from "@aws-amplify/backend"; + +/** + * Define and configure your auth resource + * @see https://docs.amplify.aws/gen2/build-a-backend/auth + */ +export const auth = defineAuth({ + loginWith: { + email: true, + }, + multifactor: { + mode: "REQUIRED", + sms: true, + }, + userAttributes: { + email: { + required: false, + mutable: true, + }, + phoneNumber: { + required: false, + mutable: true, + }, + }, + accountRecovery: "NONE", + senders: { + email: { + fromEmail, + }, + }, + triggers: { + // configure a trigger to point to a function definition + preSignUp: defineFunction({ + entry: "./pre-sign-up-handler.ts", + }), + }, +}); +``` + +```ts +import type { PreSignUpTriggerHandler } from "aws-lambda"; + +export const handler: PreSignUpTriggerHandler = async (event) => { + event.response.autoConfirmUser = true; // Automatically confirm the user + + // Automatically mark the user's email as verified + if (event.request.userAttributes.hasOwnProperty("email")) { + event.response.autoVerifyEmail = true; // Automatically verify the email + } + + // Automatically mark the user's phone number as verified + if (event.request.userAttributes.hasOwnProperty("phone_number")) { + event.response.autoVerifyPhone = true; // Automatically verify the phone number + } + // Return to Amazon Cognito + return event; +}; +``` +Create a file `amplify/data/mfa/index.graphql` with the following content + +```graphql +# A Graphql Schema for creating Mfa info such as code and username. + +type Query { + listMfaInfo: [MfaInfo] @aws_api_key +} + +type Mutation { + createMfaInfo(input: CreateMfaInfoInput!): MfaInfo @aws_api_key +} + +type Subscription { + onCreateMfaInfo(username: String): MfaInfo + @aws_subscribe(mutations: ["createMfaInfo"]) +} + +input CreateMfaInfoInput { + username: String! + code: String! + expirationTime: AWSTimestamp! +} + +type MfaInfo { + username: String! + code: String! + expirationTime: AWSTimestamp! +} +``` + +Update `amplify/data/mfa/index.ts`. The resulting file should look like this + +```ts +import { Duration, Expiration, RemovalPolicy, Stack } from "aws-cdk-lib"; +import { + Assign, + AuthorizationType, + FieldLogLevel, + GraphqlApi, + MappingTemplate, + PrimaryKey, + SchemaFile, + Values, +} from "aws-cdk-lib/aws-appsync"; +import { Table, BillingMode, AttributeType } from "aws-cdk-lib/aws-dynamodb"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +/** + * It creates AppSync and Dynamo resources using CDK + * + * *Note: It was not possible to use gen2 to create data resources due to a circular dependency error while + * deploying resources.* + * + * A circular dependency is when, + * + * - a resource that is being deployed depends on another resource that is being deployed and vice-versa. + * - or a resource depends on its own resource. + * + * For instance, + * + * Auth resources -> Data resources -> Auth resources + * + * Reference: https://aws.amazon.com/blogs/infrastructure-and-automation/handling-circular-dependency-errors-in-aws-cloudformation/ + * + */ +export function createMfaInfoGraphqlApi(stack: Stack): GraphqlApi { + const authorizationType = AuthorizationType.API_KEY; + const resolvedPath = path.resolve(__dirname, "index.graphql"); + const graphqlapi = new GraphqlApi(stack, "MfaInfoGraphqlApi", { + name: "MfaInfoGraphql", + definition: { + schema: SchemaFile.fromAsset(resolvedPath), + }, + authorizationConfig: { + defaultAuthorization: { + authorizationType, + apiKeyConfig: { + expires: Expiration.after(Duration.days(365)), + }, + }, + }, + logConfig: { + fieldLogLevel: FieldLogLevel.ALL, + excludeVerboseContent: false, + }, + }); + + const mfaCodesTable = new Table(stack, `MfaInfoTable`, { + removalPolicy: RemovalPolicy.DESTROY, + billingMode: BillingMode.PAY_PER_REQUEST, + partitionKey: { + type: AttributeType.STRING, + name: "username", + }, + sortKey: { + type: AttributeType.STRING, + name: "code", + }, + timeToLiveAttribute: "expirationTime", + }); + + const mfaCodesSource = graphqlapi.addDynamoDbDataSource( + "GraphQLApiMFACodes", + mfaCodesTable + ); + // Mutation.createMfaInfo + mfaCodesSource.createResolver(`MutationCreateMFACodeResolver`, { + typeName: "Mutation", + fieldName: "createMfaInfo", + requestMappingTemplate: MappingTemplate.dynamoDbPutItem( + new PrimaryKey( + new Assign("username", "$input.username"), + new Assign("code", "$input.code") + ), + Values.projecting("input") + ), + responseMappingTemplate: MappingTemplate.dynamoDbResultItem(), + }); + + // Query.listMFACodes + mfaCodesSource.createResolver(`QueryListMfaInfoResolver`, { + typeName: "Query", + fieldName: "listMfaInfo", + requestMappingTemplate: MappingTemplate.dynamoDbScanTable(), + responseMappingTemplate: MappingTemplate.dynamoDbResultItem(), + }); + + return graphqlapi; +} +``` + +Update `backend.ts` + +```ts +import { defineBackend } from "@aws-amplify/backend"; +import { auth } from "./auth/resource"; +import { Key } from "aws-cdk-lib/aws-kms"; +import { RemovalPolicy } from "aws-cdk-lib"; +import { createMfaInfoGraphqlApi } from "./data/mfaInfo"; +import { senderFactory } from "./helpers"; + +enum LambdaEnvKeys { + GRAPHQL_API_ENDPOINT = "GRAPHQL_API_ENDPOINT", + GRAPHQL_API_KEY = "GRAPHQL_API_KEY", + KMS_KEY_ARN = "KMS_KEY_ARN", +} + +const backend = defineBackend({ + auth, +}); + +const { cfnResources, userPool } = backend.auth.resources; +const { stack } = userPool; +const { cfnUserPool } = cfnResources; + +// an empty array denotes "email" and "phone_number" cannot be used as a username +cfnUserPool.usernameAttributes = []; + +// Create data resources +const mfaInfoGraphqlApi = createMfaInfoGraphqlApi(userPool.stack); +// Create kms resources +const customSenderKmsKey = new Key(stack, "CustomSenderKmsKey", { + description: `Key for encrypting/decrypting messages`, + removalPolicy: RemovalPolicy.DESTROY, +}); +// Create Cognito senders +const environment = { + [LambdaEnvKeys.GRAPHQL_API_ENDPOINT]: mfaInfoGraphqlApi.graphqlUrl, + [LambdaEnvKeys.GRAPHQL_API_KEY]: mfaInfoGraphqlApi.apiKey ?? "", + [LambdaEnvKeys.KMS_KEY_ARN]: customSenderKmsKey.keyArn, +}; +const cognitoSender = senderFactory( + stack, + mfaInfoGraphqlApi, + customSenderKmsKey, + cfnUserPool +); +const customEmailSender = cognitoSender("email-sender", environment); +const customSmsSender = cognitoSender("sms-sender", environment); + +// Configure the user pool to use the custom senders +cfnUserPool.lambdaConfig = { + customEmailSender: { + lambdaArn: customEmailSender.functionArn, + lambdaVersion: "V1_0", + }, + customSmsSender: { + lambdaArn: customSmsSender.functionArn, + lambdaVersion: "V1_0", + }, + kmsKeyId: customSenderKmsKey.keyArn, +}; + +// Add data resources output. +// Gen2 won't be able to auto generate data output as data resources were generated by CDK. +backend.addOutput({ + data: { + aws_region: stack.region, + url: mfaInfoGraphqlApi.graphqlUrl, + api_key: mfaInfoGraphqlApi.apiKey, + default_authorization_type: "API_KEY", + authorization_types: [], + }, +}); + +// Enable Device Tracking +// https://docs.amplify.aws/react/build-a-backend/auth/concepts/multi-factor-authentication/#remember-a-device + +cfnUserPool.addPropertyOverride("DeviceConfiguration", { + ChallengeRequiredOnNewDevice: true, + DeviceOnlyRememberedOnUserPrompt: false, +}); +``` + +The triggers should look as follows: + +Common + +```ts +// Code adapted from: +// - https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-custom-sms-sender.html#code-examples +// - https://github.com/aws-samples/amazon-cognito-user-pool-development-and-testing-with-sms-redirected-to-email + +import { + buildClient, + CommitmentPolicy, + KmsKeyringNode, +} from "@aws-crypto/client-node"; + +const { decrypt } = buildClient(CommitmentPolicy.FORBID_ENCRYPT_ALLOW_DECRYPT); + +/** + * Decrypts `code` using the KMS keyring provided by the environment. + * @param code The encrypted code sent from Cognito. + * @returns The plaintext (decrypted) code. + */ +const decryptCode = async (code: string): Promise => { + const { KMS_KEY_ARN } = process.env; + const keyring = new KmsKeyringNode({ + keyIds: [KMS_KEY_ARN!], + }); + const { plaintext } = await decrypt(keyring, Buffer.from(code, "base64")); + return plaintext.toString("ascii"); +}; + +/** + * Decrypts and broadcasts `code` to the AppSync endpoint provided by the environment. + * @param code The encrypted code sent from Cognito. + */ +export const decryptAndBroadcastCode = async ( + username: string, + code: string +): Promise => { + const { GRAPHQL_API_ENDPOINT, GRAPHQL_API_KEY } = process.env; + const plaintextCode = await decryptCode(code); + console.log(`Got MFA code for username ${username}: ${plaintextCode}`); + const EXPIRATION_TIME_IN_SECONDS = 1 * 60 * 1000; // 1 minute; + try { + const resp = await fetch(GRAPHQL_API_ENDPOINT!, { + method: "POST", + headers: { + "x-api-key": GRAPHQL_API_KEY!, + }, + body: JSON.stringify({ + query: ` + mutation CreateMfaInfo($username: String!, $code: String! $expirationTime: AWSTimestamp!) { + createMfaInfo(input: { + username: $username + code: $code + expirationTime: $expirationTime + }) { + username + code + expirationTime + } + } + `, + variables: { + username, + code: plaintextCode, + expirationTime: + Math.floor(Date.now() / 1000) + EXPIRATION_TIME_IN_SECONDS, + }, + }), + }); + const json = await resp.json(); + console.log(`Got GraphQL response: ${JSON.stringify(json, null, 2)}`); + } catch (error) { + console.error("Could not POST to GraphQL endpoint: ", error); + } +}; +``` + +custom-email-sender + +```ts +import { CustomEmailSenderTriggerHandler } from "aws-lambda"; +import { decryptAndBroadcastCode } from "./common"; + +export const handler: CustomEmailSenderTriggerHandler = async (event) => { + console.log(`Got event: ${JSON.stringify(event, null, 2)}`); + + if ( + event.triggerSource === "CustomEmailSender_AdminCreateUser" || + event.triggerSource == "CustomEmailSender_AccountTakeOverNotification" + ) { + console.warn(`Not handling trigger source: ${event.triggerSource}`); + return event; + } + + const { userName } = event; + const { code } = event.request; + + await decryptAndBroadcastCode(userName, code!); + + return event; +}; +``` + +custom-sms-sender + +```ts +import { CustomSMSSenderTriggerHandler } from "aws-lambda"; +import { decryptAndBroadcastCode } from "./common"; + +export const handler: CustomSMSSenderTriggerHandler = async (event) => { + console.log(`Got event: ${JSON.stringify(event, null, 2)}`); + + if (event.triggerSource === "CustomSMSSender_AdminCreateUser") { + console.warn(`Not handling trigger source: ${event.triggerSource}`); + return event; + } + + const { userName } = event; + const { code } = event.request; + + await decryptAndBroadcastCode(userName, code!); + + return event; +}; +``` + +4. Deploy the backend with npx amplify sandbox + +For example, this deploys to a sandbox env and generates the amplify_outputs.json file. + +``` +npx ampx sandbox --identifier mfa-req-email --outputs-out-dir amplify_outputs/mfa-req-email +``` + +5. Copy the `amplify_outputs.json` file over to the test directory as `XYZ-amplify_outputs.json` (replace xyz with the name of the file your test is expecting). The tests will automatically pick this file up. Create the directories in this path first if it currently doesn't exist. + +``` +cp amplify_outputs.json ~/.aws-amplify/amplify-ios/testconfiguration/XYZ-amplify_outputs.json +``` \ No newline at end of file