diff --git a/Amplify/Categories/Auth/Models/AuthPasswordlessDeliveryDestination.swift b/Amplify/Categories/Auth/Models/AuthPasswordlessDeliveryDestination.swift index 09b2111d22..9a5284b311 100644 --- a/Amplify/Categories/Auth/Models/AuthPasswordlessDeliveryDestination.swift +++ b/Amplify/Categories/Auth/Models/AuthPasswordlessDeliveryDestination.swift @@ -9,7 +9,7 @@ import Foundation /// Delivery destination for the Auth Passwordless flows /// -public enum AuthPasswordlessDeliveryDestination { - case sms - case email +public enum AuthPasswordlessDeliveryDestination: String { + case sms = "SMS" + case email = "EMAIL" } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInOptions.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInOptions.swift index ce54b5a908..fed4400ba6 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInOptions.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInOptions.swift @@ -11,6 +11,7 @@ public struct AWSAuthConfirmSignInOptions { /// User attributes to be passed in when confirming a sign with NEW_PASSWORD_REQUIRED challenge public let userAttributes: [AuthUserAttribute]? + /// A map of custom key-value pairs that you can provide as input for any custom workflows that this action triggers. public let metadata: [String: String]? /// Device name that would be provided to Cognito when setting up TOTP diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInWithMagicLinkOptions.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInWithMagicLinkOptions.swift new file mode 100644 index 0000000000..77d875385e --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInWithMagicLinkOptions.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +import Amplify + +//[HS]: TODO: Make sure this is approved API for options +public struct AWSAuthConfirmSignInWithMagicLinkOptions { + + /// A map of custom key-value pairs that you can provide as input for any custom workflows that this action triggers. + public let metadata: [String: String]? + + public init(metadata: [String: String]? = nil) { + self.metadata = metadata + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInWithOTPOptions.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInWithOTPOptions.swift new file mode 100644 index 0000000000..977c84ef94 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Options/AWSAuthConfirmSignInWithOTPOptions.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +import Amplify + +//[HS]: TODO: Make sure this is approved API for options +public struct AWSAuthConfirmSignInWithOTPOptions { + + /// A map of custom key-value pairs that you can provide as input for any custom workflows that this action triggers. + public let metadata: [String: String]? + + public init(metadata: [String: String]? = nil) { + self.metadata = metadata + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/PasswordlessCustomAuthNextStep.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/PasswordlessCustomAuthNextStep.swift new file mode 100644 index 0000000000..cd2d9f4a9a --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/PasswordlessCustomAuthNextStep.swift @@ -0,0 +1,15 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +enum PasswordlessCustomAuthNextStep: String { + + case provideAuthParameters = "PROVIDE_AUTH_PARAMETERS" + + case provideChallengeResponse = "PROVIDE_CHALLENGE_RESPONSE" +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/PasswordlessCustomAuthRequest.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/PasswordlessCustomAuthRequest.swift new file mode 100644 index 0000000000..8c7218157b --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/PasswordlessCustomAuthRequest.swift @@ -0,0 +1,47 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import Foundation + +enum PasswordlessCustomAuthSignInMethod: String { + case otp = "OTP" + case magicLink = "MAGIC_LINK" +} + +enum PasswordlessCustomAuthRequestAction: String { + case request = "REQUEST" + case confirm = "CONFIRM" +} + +struct PasswordlessCustomAuthRequest { + + private let namespace = "Amplify.Passwordless" + + let signInMethod: PasswordlessCustomAuthSignInMethod + let action: PasswordlessCustomAuthRequestAction + let deliveryMedium: AuthPasswordlessDeliveryDestination? + + init(signInMethod: PasswordlessCustomAuthSignInMethod, + action: PasswordlessCustomAuthRequestAction, + deliveryMedium: AuthPasswordlessDeliveryDestination? = nil) { + self.signInMethod = signInMethod + self.action = action + self.deliveryMedium = deliveryMedium + } + + func toDictionary() -> [String: String] { + var dictionary = [ + namespace + ".signInMethod": signInMethod.rawValue, + namespace + ".action": action.rawValue + ] + if let deliveryMedium = deliveryMedium { + dictionary[namespace + ".deliveryMedium"] = deliveryMedium.rawValue + } + return dictionary + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignInEventData.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignInEventData.swift index dad365c91d..a6b3c6142f 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignInEventData.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignInEventData.swift @@ -16,7 +16,7 @@ struct SignInEventData { let signInMethod: SignInMethod init(username: String?, - password: String?, + password: String? = nil, clientMetadata: [String: String] = [:], signInMethod: SignInMethod) { self.username = username diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInWithOTPTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInWithOTPTask.swift index 07f4d6c388..ca425c6e85 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInWithOTPTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInWithOTPTask.swift @@ -14,6 +14,7 @@ class AWSAuthConfirmSignInWithOTPTask: AuthConfirmSignInWithOTPTask, DefaultLogg private let authStateMachine: AuthStateMachine private let taskHelper: AWSAuthTaskHelper private let authConfiguration: AuthConfiguration + private let confirmSignInRequestMetadata: PasswordlessCustomAuthRequest var eventName: HubPayloadEventName { HubPayload.EventName.Auth.confirmSignInWithOTPAPI @@ -26,21 +27,13 @@ class AWSAuthConfirmSignInWithOTPTask: AuthConfirmSignInWithOTPTask, DefaultLogg self.authStateMachine = stateMachine self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) self.authConfiguration = configuration + self.confirmSignInRequestMetadata = .init(signInMethod: .otp, action: .confirm) } func execute() async throws -> AuthSignInResult { log.verbose("Starting execution") await taskHelper.didStateMachineConfigured() - //Check if we have a user pool configuration - guard authConfiguration.getUserPoolConfiguration() != nil else { - let message = AuthPluginErrorConstants.configurationError - let authError = AuthError.configuration( - "Could not find user pool configuration", - message) - throw authError - } - if let validationError = request.hasError() { throw validationError } @@ -53,11 +46,6 @@ class AWSAuthConfirmSignInWithOTPTask: AuthConfirmSignInWithOTPTask, DefaultLogg throw invalidStateError } - // [HS] TODO: Following implementations need to be complete - // 1. Validate it is the correct state to confirm Sign In With OTP - // 2. Send event - // 3. Listent to events - // 4. Complete the task guard case .resolvingChallenge(let challengeState, _, _) = signInState else { throw invalidStateError } @@ -70,7 +58,6 @@ class AWSAuthConfirmSignInWithOTPTask: AuthConfirmSignInWithOTPTask, DefaultLogg throw invalidStateError } - let stateSequences = await authStateMachine.listen() log.verbose("Waiting for response") for await state in stateSequences { @@ -110,14 +97,18 @@ class AWSAuthConfirmSignInWithOTPTask: AuthConfirmSignInWithOTPTask, DefaultLogg } private func createConfirmSignInEventData() -> ConfirmSignInEventData { - - // [HS] TODO: Confirm if any metadata needs to be passed during confirm sign in - /* - * Attributes - * Metadata - * Device Name - */ + var passwordlessMetadata = confirmSignInRequestMetadata.toDictionary() + if let customerMetadata = (request.options.pluginOptions as? AWSAuthConfirmSignInWithOTPOptions)?.metadata { + passwordlessMetadata.merge(customerMetadata, uniquingKeysWith: { passwordlessMetadata, customerMetadata in + // Ideally key collision won't happen, because passwordless has been namespaced + // if for some reason collision still happens, + // prioritizing passwordlessFlow keys for flow to continue without any issues. + passwordlessMetadata + + }) + } return ConfirmSignInEventData( - answer: self.request.challengeResponse) + answer: self.request.challengeResponse, + metadata: passwordlessMetadata) } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignInWithOTPTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignInWithOTPTask.swift index 145b5fe9c9..7f61d4a690 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignInWithOTPTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignInWithOTPTask.swift @@ -14,6 +14,7 @@ class AWSAuthSignInWithOTPTask: AuthSignInWithOTPTask, DefaultLogger { private let authStateMachine: AuthStateMachine private let taskHelper: AWSAuthTaskHelper private let authConfiguration: AuthConfiguration + private let signInMetadataRequestMetadata: PasswordlessCustomAuthRequest var eventName: HubPayloadEventName { HubPayload.EventName.Auth.signInWithOTPAPI @@ -26,12 +27,18 @@ class AWSAuthSignInWithOTPTask: AuthSignInWithOTPTask, DefaultLogger { self.authStateMachine = authStateMachine self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) self.authConfiguration = configuration + self.signInMetadataRequestMetadata = .init( + signInMethod: .otp, action: .request, deliveryMedium: request.destination) } func execute() async throws -> AuthSignInResult { + log.verbose("Starting execution") await taskHelper.didStateMachineConfigured() - //Check if we have a user pool configuration + + // Check if we have a user pool configuration + // User pool configuration is used retrieve API Gateway information, + // so that sign up flow can take place guard let userPoolConfiguration = authConfiguration.getUserPoolConfiguration() else { let message = AuthPluginErrorConstants.configurationError let authError = AuthError.configuration( @@ -40,52 +47,56 @@ class AWSAuthSignInWithOTPTask: AuthSignInWithOTPTask, DefaultLogger { throw authError } + // Make sure current state is a valid state to initialize sign in try await validateCurrentState() do { - let result = try await doSignIn() - log.verbose("Received result") - return result + // Start OTP sign in + return try await startOTPSignIn() } catch { + + log.error(error: error) + // If OTP Sign in failed, send sign in cancellation event + await sendCancelSignInEvent() + + // Wait for sign in cancellation to complete await waitForSignInCancel() + + // throw error that came during sign in throw error } } - private func validateCurrentState() async throws { - - let stateSequences = await authStateMachine.listen() - log.verbose("Validating current state") - for await state in stateSequences { - guard case .configured(let authenticationState, _) = state else { - continue - } + - switch authenticationState { - case .signedIn: - let error = AuthError.invalidState( - "There is already a user in signedIn state. SignOut the user first before calling signIn", - AuthPluginErrorConstants.invalidStateError, nil) - throw error - case .signingIn: - log.verbose("Cancelling existing signIn flow") - await sendCancelSignInEvent() - case .signedOut: - return - default: continue - } + private func startOTPSignIn() async throws -> AuthSignInResult { + if request.flow == .signUpAndSignIn { + log.verbose("Starting OTP Passwordless Sign Up flow") + // [HS] TODO: Proceed to sign up flow first } + + log.verbose("Starting OTP Passwordless Sign In flow") + let result = try await doSignIn() + + log.verbose("Received result") + return result } private func doSignIn() async throws -> AuthSignInResult { + + log.verbose("Sending initiate Sign In event") + await sendInitiateSignInEvent() + + log.verbose("Start listening to state machine changes") + return try await listenToStateChanges() + } + + func listenToStateChanges() async throws -> AuthSignInResult { let stateSequences = await authStateMachine.listen() - log.verbose("Sending signIn event") - await sendSignInEvent() - log.verbose("Waiting for signin to complete") for await state in stateSequences { - guard case .configured(let authNState, - let authZState) = state else { continue } - + guard case .configured(let authNState, let authZState) = state else { + continue + } switch authNState { case .signedIn: if case .sessionEstablished = authZState { @@ -96,13 +107,14 @@ class AWSAuthSignInWithOTPTask: AuthSignInWithOTPTask, DefaultLogger { } case .error(let error): throw error.authError - case .signingIn(let signInState): - // [HS] TODO: Update next steps when new StateMachine is updated for Passwordless - guard let result = try UserPoolSignInHelper.checkNextStep(signInState) else { + guard let nextStepResult = try UserPoolSignInHelper.checkNextStep(signInState) else { + continue + } + guard let signInResult = try await parseAndValidate(signInResult: nextStepResult) else { continue } - return result + return signInResult default: continue } @@ -110,13 +122,101 @@ class AWSAuthSignInWithOTPTask: AuthSignInWithOTPTask, DefaultLogger { throw AuthError.unknown("Sign in reached an error state") } + func parseAndValidate(signInResult: AuthSignInResult) async throws -> AuthSignInResult? { + + guard case .confirmSignInWithCustomChallenge(let challengeParams) = signInResult.nextStep else { + log.error("Did not receive custom auth challenge as a next Step instead received: \(signInResult)") + throw AuthError.service( + "Did not receive custom auth challenge as a next Step.", + AmplifyErrorMessages.shouldNotHappenReportBugToAWS(), nil) + } + guard let nextStepString = challengeParams?["nextStep"] else { + log.error("Did not receive a valid next step. Received Challenge Params: \(challengeParams ?? [:])") + throw AuthError.service( + "Did not receive a valid next step for Passwordless OTP flow.", + AmplifyErrorMessages.shouldNotHappenReportBugToAWS(), nil) + } + + guard let nextStep = PasswordlessCustomAuthNextStep(rawValue: nextStepString) else { + log.error("Invalid next step. Next Step\(nextStepString)") + throw AuthError.service( + "Did not receive a valid next step for Passwordless OTP flow.", + AmplifyErrorMessages.shouldNotHappenReportBugToAWS(), nil) + } + + switch nextStep { + case .provideAuthParameters: + log.verbose("Sending Response for InitAuth challenge") + // Library handles creating auth parameters + await sendEventForProvidingAuthParameters() + return nil + + case .provideChallengeResponse: + // Ask the customer for OTP + return .init(nextStep: .confirmSignInWithOTP( + getCodeDeliveryDetails(parameters: challengeParams ?? [:]), nil)) + } + } + + // MARK: Events + private func sendInitiateSignInEvent() async { + let signInData = SignInEventData( + username: request.username, + clientMetadata: customerClientMetadata(), + signInMethod: .apiBased(.customWithoutSRP) + ) + let event = AuthenticationEvent.init(eventType: .signInRequested(signInData)) + await authStateMachine.send(event) + } - private func sendSignInEvent() async { - // [HS] TODO: Send Sign in Event + private func sendEventForProvidingAuthParameters() async { + var passwordlessFlowMetadata = signInMetadataRequestMetadata.toDictionary() + passwordlessFlowMetadata.merge(customerClientMetadata()) { passwordlessFlowMetadata, customerClientMetadata in + // Ideally key collision won't happen, because passwordless has been namespaced + // if for some reason collision still happens, + // prioritizing passwordlessFlow keys for flow to continue without any issues. + passwordlessFlowMetadata + } + let confirmSignInEventData = ConfirmSignInEventData( + // NOTE: answer is not applicable in this scenario + // because this event is only responsible for initializing the passwordless OTP workflow + answer: "", + metadata: passwordlessFlowMetadata) + let event = SignInChallengeEvent( + eventType: .verifyChallengeAnswer(confirmSignInEventData)) + await authStateMachine.send(event) } - private func clientMetadata() -> [String: String] { + // MARK: State Validations + + private func validateCurrentState() async throws { + + let stateSequences = await authStateMachine.listen() + log.verbose("Validating current state") + for await state in stateSequences { + guard case .configured(let authenticationState, _) = state else { + continue + } + switch authenticationState { + case .signedIn: + let error = AuthError.invalidState( + "There is already a user in signedIn state. SignOut the user first before calling signIn", + AuthPluginErrorConstants.invalidStateError, nil) + throw error + case .signingIn: + log.verbose("Cancelling existing signIn flow") + await sendCancelSignInEvent() + case .signedOut: + return + default: continue + } + } + } + + // MARK: Private helpers + + private func customerClientMetadata() -> [String: String] { if let options = request.options.pluginOptions as? AWSAuthSignUpAndSignInPasswordlessOptions, let clientMetadata = options.clientMetadata { @@ -128,6 +228,30 @@ class AWSAuthSignInWithOTPTask: AuthSignInWithOTPTask, DefaultLogger { return [:] } + private func getCodeDeliveryDetails(parameters: [String: String]) -> AuthCodeDeliveryDetails { + + var deliveryDestination = DeliveryDestination.unknown(nil) + var attribute: AuthUserAttributeKey? = nil + + // Retrieve Delivery medium and destination + let medium = parameters["deliveryMedium"] + let destination = parameters["destination"] + if medium == "SMS" { + deliveryDestination = .sms(destination) + } else if medium == "EMAIL" { + deliveryDestination = .email(destination) + } + + // Retrieve attribute name + if let attributeName = parameters["attributeName"] { + attribute = AuthUserAttributeKey(rawValue: attributeName) + } + + return AuthCodeDeliveryDetails( + destination: deliveryDestination, + attributeKey: attribute) + } + // MARK: Sign In Cancellation private func sendCancelSignInEvent() async { @@ -136,7 +260,6 @@ class AWSAuthSignInWithOTPTask: AuthSignInWithOTPTask, DefaultLogger { } private func waitForSignInCancel() async { - await sendCancelSignInEvent() let stateSequences = await authStateMachine.listen() log.verbose("Wait for signIn to cancel") for await state in stateSequences { diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInWithOTPTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInWithOTPTaskTests.swift new file mode 100644 index 0000000000..70bd752ee3 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInWithOTPTaskTests.swift @@ -0,0 +1,80 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +import XCTest +import Amplify +@testable import AWSCognitoAuthPlugin +import AWSCognitoIdentityProvider + +// swiftlint:disable type_body_length +// swiftlint:disable file_length +class AWSAuthConfirmSignInWithOTPTaskTests: BasePluginTest { + + override var initialState: AuthState { + AuthState.configured( + AuthenticationState.signingIn( + .resolvingChallenge( + .waitingForAnswer( + .testData( + challenge: .customChallenge, + parameters: [ + "nextStep": "PROVIDE_CHALLENGE_RESPONSE", + "attributeName": "email", + "deliveryMedium": "EMAIL", + "destination": "S***@g***" + ]), + .apiBased(.customWithoutSRP) + ), + .customChallenge, + .apiBased(.customWithoutSRP))), + AuthorizationState.sessionEstablished(.testData)) + } + + /// Test a successful confirmSignInWithOTP call with .done as next step + /// + /// - Given: an auth plugin with mocked service. Mocked service calls should mock a successful response + /// - When: + /// - I invoke confirmSignInWithOTP with a valid confirmation code + /// - Then: + /// - I should get a successful result with .done as the next step + /// + func testSuccessfulConfirmSignInWithOTP() async { + + let customerMetadata = [ + "somekey": "somevalue" + ] + self.mockIdentityProvider = MockIdentityProvider( + mockRespondToAuthChallengeResponse: { request in + XCTAssertEqual(request.challengeName, .customChallenge) + XCTAssertEqual(request.challengeResponses?["ANSWER"], "code") + XCTAssertEqual(request.clientMetadata?["Amplify.Passwordless.signInMethod"], "OTP") + XCTAssertEqual(request.clientMetadata?["Amplify.Passwordless.action"], "CONFIRM") + XCTAssertEqual(request.clientMetadata?["somekey"], "somevalue") + return .testData() + }) + + do { + let confirmSignInOptions = AWSAuthConfirmSignInWithOTPOptions( + metadata: customerMetadata) + let option = AuthConfirmSignInWithOTPRequest.Options(pluginOptions: confirmSignInOptions) + let confirmSignInResult = try await plugin.confirmSignInWithOTP( + challengeResponse: "code", + options: option) + + guard case .done = confirmSignInResult.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(confirmSignInResult.isSignedIn, "Signin result should be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInWithOTPTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInWithOTPTaskTests.swift new file mode 100644 index 0000000000..7b6ed2027d --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInWithOTPTaskTests.swift @@ -0,0 +1,95 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import AWSCognitoIdentity +@testable import Amplify +@testable import AWSCognitoAuthPlugin +import AWSCognitoIdentityProvider +import ClientRuntime + +class AWSAuthSignInWithOTPTaskTests: BasePluginTest { + + override var initialState: AuthState { + AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + } + + /// Test happy path for signInWithOTP + /// + /// - Given: An auth plugin with mocked service. + /// + /// - When: + /// - I invoke signInWithOTP + /// - Then: + /// - I should get `confirmSignInWithOTP` as the next step. + /// + func testSignInWithOTP() async { + + let clientMetadata = [ + "somekey": "somevalue" + ] + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + XCTAssertEqual(input.clientMetadata?["somekey"], "somevalue") + return InitiateAuthOutput( + authenticationResult: .none, + challengeName: .customChallenge, + challengeParameters: [ + "nextStep": "PROVIDE_AUTH_PARAMETERS" + ], + session: "someSession") + }, mockRespondToAuthChallengeResponse: { input in + XCTAssertEqual(input.clientMetadata?["Amplify.Passwordless.signInMethod"], "OTP") + XCTAssertEqual(input.clientMetadata?["Amplify.Passwordless.action"], "REQUEST") + XCTAssertEqual(input.clientMetadata?["Amplify.Passwordless.deliveryMedium"], "EMAIL") + XCTAssertEqual(input.clientMetadata?["somekey"], "somevalue") + + return RespondToAuthChallengeOutput( + authenticationResult: .none, + challengeName: .customChallenge, + challengeParameters: [ + "nextStep": "PROVIDE_CHALLENGE_RESPONSE", + "attributeName": "email", + "deliveryMedium": "EMAIL", + "destination": "S***@g***" + ], + session: "session") + }) + + let pluginOptions = AWSAuthSignInPasswordlessOptions(clientMetadata: clientMetadata) + let options = AuthSignInWithOTPRequest.Options(pluginOptions: pluginOptions) + do { + let result = try await plugin.signInWithOTP( + username: "username", + flow: .signIn, + destination: .email, + options: options) + + guard case .confirmSignInWithOTP(let codeDeliveryDetails, _) = result.nextStep else { + XCTFail("Result should be .confirmSignInWithOTP for next step") + return + } + + guard case .email(let destination) = codeDeliveryDetails.destination else { + XCTFail("Result should contain codeDeliveryDetails.destination") + return + } + + XCTAssertNotNil(destination, "Destination should not be nil") + XCTAssertEqual(destination, "S***@g***") + + guard case .email = codeDeliveryDetails.attributeKey else { + XCTFail("Result for codeDeliveryDetails.attributeKey should be email") + return + } + + XCTAssertFalse(result.isSignedIn, "Signin result should not be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + +}