diff --git a/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift b/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift index 273afaf184..6a1dcb3448 100644 --- a/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift +++ b/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift @@ -94,13 +94,41 @@ extension AuthCategory: AuthCategoryBehavior { redirectURL: String, options: AuthPasswordlessMagicLinkRequest.Options? = nil ) async throws -> AuthSignInResult { - try await plugin.signInWithMagicLink(username: username, flow: flow, redirectURL: redirectURL, options: options) + try await plugin.signInWithMagicLink( + username: username, + flow: flow, + redirectURL: redirectURL, + options: options) } public func confirmSignInWithMagicLink( challengeResponse: String, options: AuthPasswordlessMagicLinkRequest.Options? = nil ) async throws -> AuthSignInResult { - try await plugin.confirmSignInWithMagicLink(challengeResponse: challengeResponse, options: options) + try await plugin.confirmSignInWithMagicLink( + challengeResponse: challengeResponse, + options: options) + } + + public func signInWithOTP( + username: String, + flow: AuthPasswordlessFlow, + destination: AuthPasswordlessDeliveryDestination, + options: AuthSignInWithOTPRequest.Options? = nil + ) async throws -> AuthSignInResult { + try await plugin.signInWithOTP( + username: username, + flow: flow, + destination: destination, + options: options) + } + + public func confirmSignInWithOTP( + challengeResponse: String, + options: AuthConfirmSignInWithOTPRequest.Options? = nil + ) async throws -> AuthSignInResult { + try await plugin.confirmSignInWithOTP( + challengeResponse: challengeResponse, + options: options) } } diff --git a/Amplify/Categories/Auth/AuthCategoryBehavior.swift b/Amplify/Categories/Auth/AuthCategoryBehavior.swift index e5ec8c1d2e..fc1463617f 100644 --- a/Amplify/Categories/Auth/AuthCategoryBehavior.swift +++ b/Amplify/Categories/Auth/AuthCategoryBehavior.swift @@ -175,4 +175,32 @@ public protocol AuthCategoryBehavior: AuthCategoryUserBehavior, AuthCategoryDevi options: AuthPasswordlessMagicLinkRequest.Options? ) async throws -> AuthSignInResult + /// Initiates Sign in with OTP + /// + /// Invoke this operation to start sign in with Magic Link flow + /// + /// - Parameters: + /// - username: username used for sign in + /// - flow: `AuthPasswordlessFlow` type - can be `.signUpAndSignIn` or `.signIn` + /// - destination: `AuthPasswordlessDeliveryDestination` type - can be `sms` or `email` + /// - options: Parameters specific to plugin behavior + func signInWithOTP( + username: String, + flow: AuthPasswordlessFlow, + destination: AuthPasswordlessDeliveryDestination, + options: AuthSignInWithOTPRequest.Options? + ) async throws -> AuthSignInResult + + /// Confirms Sign in for OTP flow + /// + /// Invoke this operation to confirm sign in with OTP flow and sign in the user + /// + /// - Parameters: + /// - challengeResponse: challengeResponse received as a OTP on the destination provided during sign in + /// - options: Parameters specific to plugin behavior + func confirmSignInWithOTP( + challengeResponse: String, + options: AuthConfirmSignInWithOTPRequest.Options? + ) async throws -> AuthSignInResult + } diff --git a/Amplify/Categories/Auth/Request/AuthPasswordlessOTPRequest.swift b/Amplify/Categories/Auth/Request/AuthConfirmSignInWithOTPRequest.swift similarity index 61% rename from Amplify/Categories/Auth/Request/AuthPasswordlessOTPRequest.swift rename to Amplify/Categories/Auth/Request/AuthConfirmSignInWithOTPRequest.swift index 4da113152a..c7b42bbfa1 100644 --- a/Amplify/Categories/Auth/Request/AuthPasswordlessOTPRequest.swift +++ b/Amplify/Categories/Auth/Request/AuthConfirmSignInWithOTPRequest.swift @@ -7,18 +7,22 @@ import Foundation -/// Request to sign in a user with Passwordless OTP flow -public struct AuthPasswordlessOTPRequest: AmplifyOperationRequest { +/// Request to confirm sign in a user with OTP flow +public struct AuthConfirmSignInWithOTPRequest: AmplifyOperationRequest { + + /// The value of `challengeResponse`is the OTP that is received on the destination provided during sign in request + public let challengeResponse: String /// Extra request options defined in `AuthPasswordlessOTPRequest.Options` public var options: Options - public init(options: Options) { + public init(challengeResponse: String, options: Options) { + self.challengeResponse = challengeResponse self.options = options } } -public extension AuthPasswordlessOTPRequest { +public extension AuthConfirmSignInWithOTPRequest { struct Options { diff --git a/Amplify/Categories/Auth/Request/AuthSignInWithOTPRequest.swift b/Amplify/Categories/Auth/Request/AuthSignInWithOTPRequest.swift new file mode 100644 index 0000000000..3623e27e78 --- /dev/null +++ b/Amplify/Categories/Auth/Request/AuthSignInWithOTPRequest.swift @@ -0,0 +1,49 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to sign in a user with Passwordless OTP flow +public struct AuthSignInWithOTPRequest: AmplifyOperationRequest { + + /// User name for which the passwordless OTP was initiated + public let username: String + + /// The flow that the request should begin with. + public let flow: AuthPasswordlessFlow + + /// The destination where the OTP will be sent + public let destination: AuthPasswordlessDeliveryDestination + + /// Extra request options defined in `AuthSignInWithOTPRequest.Options` + public var options: Options + + public init(username: String, + flow: AuthPasswordlessFlow, + destination: AuthPasswordlessDeliveryDestination, + options: Options) { + self.username = username + self.flow = flow + self.destination = destination + self.options = options + } +} + +public extension AuthSignInWithOTPRequest { + + struct Options { + + /// Extra plugin specific options, only used in special circumstances when the existing options do not provide + /// a way to utilize the underlying auth plugin functionality. See plugin documentation for expected + /// key/values + public let pluginOptions: Any? + + public init(pluginOptions: Any? = nil) { + self.pluginOptions = pluginOptions + } + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift index 4e36844c1a..e669e8f7ad 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift @@ -209,4 +209,43 @@ extension AWSCognitoAuthPlugin: AuthCategoryBehavior { ) async throws -> AuthSignInResult { throw AuthError.unknown("Not Implemented") } + + public func signInWithOTP( + username: String, + flow: AuthPasswordlessFlow, + destination: AuthPasswordlessDeliveryDestination, + options: AuthSignInWithOTPRequest.Options? + ) async throws -> AuthSignInResult { + let options = options ?? AuthSignInWithOTPRequest.Options() + let request = AuthSignInWithOTPRequest( + username: username, + flow: flow, + destination: destination, + options: options) + let task = AWSAuthSignInWithOTPTask( + request, + authStateMachine: self.authStateMachine, + configuration: authConfiguration) + return try await taskQueue.sync { + return try await task.value + } as! AuthSignInResult + } + + public func confirmSignInWithOTP( + challengeResponse: String, + options: AuthConfirmSignInWithOTPRequest.Options? + ) async throws -> AuthSignInResult { + + let options = options ?? AuthConfirmSignInWithOTPRequest.Options() + let request = AuthConfirmSignInWithOTPRequest( + challengeResponse: challengeResponse, + options: options) + let task = AWSAuthConfirmSignInWithOTPTask( + request, + stateMachine: authStateMachine, + configuration: authConfiguration) + return try await taskQueue.sync { + return try await task.value + } as! AuthSignInResult + } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ConfirmSignInEventData.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ConfirmSignInEventData.swift index 448339c31c..a0016a9f29 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ConfirmSignInEventData.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ConfirmSignInEventData.swift @@ -14,6 +14,16 @@ struct ConfirmSignInEventData { let metadata: [String: String]? let friendlyDeviceName: String? + init(answer: String, + attributes: [String : String] = [:], + metadata: [String : String]? = nil, + friendlyDeviceName: String? = nil) { + self.answer = answer + self.attributes = attributes + self.metadata = metadata + self.friendlyDeviceName = friendlyDeviceName + } + } extension ConfirmSignInEventData: Equatable { } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Request/AuthPasswordlessOTPRequest+Validation.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Request/AuthPasswordlessOTPRequest+Validation.swift new file mode 100644 index 0000000000..6879e15849 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Request/AuthPasswordlessOTPRequest+Validation.swift @@ -0,0 +1,20 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +import Amplify + +extension AuthConfirmSignInWithOTPRequest { + + func hasError() -> AuthError? { + guard !challengeResponse.isEmpty else { + return AuthError.validation( + AuthPluginErrorConstants.confirmSignInChallengeResponseError.field, + AuthPluginErrorConstants.confirmSignInChallengeResponseError.errorDescription, + AuthPluginErrorConstants.confirmSignInChallengeResponseError.recoverySuggestion) + } + return nil + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInWithOTPTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInWithOTPTask.swift new file mode 100644 index 0000000000..07f4d6c388 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInWithOTPTask.swift @@ -0,0 +1,123 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +import Foundation +import Amplify +import AWSPluginsCore + +class AWSAuthConfirmSignInWithOTPTask: AuthConfirmSignInWithOTPTask, DefaultLogger { + + private let request: AuthConfirmSignInWithOTPRequest + private let authStateMachine: AuthStateMachine + private let taskHelper: AWSAuthTaskHelper + private let authConfiguration: AuthConfiguration + + var eventName: HubPayloadEventName { + HubPayload.EventName.Auth.confirmSignInWithOTPAPI + } + + init(_ request: AuthConfirmSignInWithOTPRequest, + stateMachine: AuthStateMachine, + configuration: AuthConfiguration) { + self.request = request + self.authStateMachine = stateMachine + self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) + self.authConfiguration = configuration + } + + 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 + } + let invalidStateError = AuthError.invalidState( + "User is not attempting signIn operation", + AuthPluginErrorConstants.invalidStateError, nil) + + guard case .configured(let authNState, _) = await authStateMachine.currentState, + case .signingIn(let signInState) = authNState else { + 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 + } + + switch challengeState { + case .waitingForAnswer, .error: + log.verbose("Sending confirm signIn event: \(challengeState)") + await sendConfirmSignInEvent() + default: + throw invalidStateError + } + + + let stateSequences = await authStateMachine.listen() + log.verbose("Waiting for response") + for await state in stateSequences { + guard case .configured(let authNState, let authZState) = state else { + continue + } + switch authNState { + case .signedIn: + if case .sessionEstablished = authZState { + return AuthSignInResult(nextStep: .done) + } else { + log.verbose("Signed In, waiting for authorization to complete") + } + case .error(let error): + throw AuthError.unknown("Sign in reached an error state", error) + + case .signingIn(let signInState): + guard let result = try UserPoolSignInHelper.checkNextStep(signInState) else { + continue + } + return result + case .notConfigured: + throw AuthError.configuration( + "UserPool configuration is missing", + AuthPluginErrorConstants.configurationError) + default: + throw invalidStateError + } + } + throw invalidStateError + } + + func sendConfirmSignInEvent() async { + let event = SignInChallengeEvent( + eventType: .verifyChallengeAnswer(createConfirmSignInEventData())) + await authStateMachine.send(event) + } + + private func createConfirmSignInEventData() -> ConfirmSignInEventData { + + // [HS] TODO: Confirm if any metadata needs to be passed during confirm sign in + /* + * Attributes + * Metadata + * Device Name + */ + return ConfirmSignInEventData( + answer: self.request.challengeResponse) + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignInWithOTPTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignInWithOTPTask.swift new file mode 100644 index 0000000000..145b5fe9c9 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignInWithOTPTask.swift @@ -0,0 +1,153 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +import Foundation +import Amplify +import AWSPluginsCore + +class AWSAuthSignInWithOTPTask: AuthSignInWithOTPTask, DefaultLogger { + + private let request: AuthSignInWithOTPRequest + private let authStateMachine: AuthStateMachine + private let taskHelper: AWSAuthTaskHelper + private let authConfiguration: AuthConfiguration + + var eventName: HubPayloadEventName { + HubPayload.EventName.Auth.signInWithOTPAPI + } + + init(_ request: AuthSignInWithOTPRequest, + authStateMachine: AuthStateMachine, + configuration: AuthConfiguration) { + self.request = request + self.authStateMachine = authStateMachine + self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) + self.authConfiguration = configuration + } + + func execute() async throws -> AuthSignInResult { + log.verbose("Starting execution") + await taskHelper.didStateMachineConfigured() + //Check if we have a user pool configuration + guard let userPoolConfiguration = authConfiguration.getUserPoolConfiguration() else { + let message = AuthPluginErrorConstants.configurationError + let authError = AuthError.configuration( + "Could not find user pool configuration", + message) + throw authError + } + + try await validateCurrentState() + + do { + let result = try await doSignIn() + log.verbose("Received result") + return result + } catch { + await waitForSignInCancel() + 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 doSignIn() 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 } + + switch authNState { + case .signedIn: + if case .sessionEstablished = authZState { + return AuthSignInResult(nextStep: .done) + } else if case .error(let error) = authZState { + log.verbose("Authorization reached an error state \(error)") + throw error.authError + } + 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 { + continue + } + return result + default: + continue + } + } + throw AuthError.unknown("Sign in reached an error state") + } + + + + private func sendSignInEvent() async { + // [HS] TODO: Send Sign in Event + } + + private func clientMetadata() -> [String: String] { + + if let options = request.options.pluginOptions as? AWSAuthSignUpAndSignInPasswordlessOptions, + let clientMetadata = options.clientMetadata { + return clientMetadata + } else if let options = request.options.pluginOptions as? AWSAuthSignInPasswordlessOptions, + let clientMetadata = options.clientMetadata { + return clientMetadata + } + return [:] + } + + // MARK: Sign In Cancellation + + private func sendCancelSignInEvent() async { + let event = AuthenticationEvent(eventType: .cancelSignIn) + await authStateMachine.send(event) + } + + private func waitForSignInCancel() async { + await sendCancelSignInEvent() + let stateSequences = await authStateMachine.listen() + log.verbose("Wait for signIn to cancel") + for await state in stateSequences { + guard case .configured(let authenticationState, _) = state else { + continue + } + switch authenticationState { + case .signedOut: + return + default: continue + } + } + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthConfirmSignInWithOTPTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthConfirmSignInWithOTPTask.swift new file mode 100644 index 0000000000..de50fe32bd --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthConfirmSignInWithOTPTask.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +import Foundation +import Amplify + +protocol AuthConfirmSignInWithOTPTask: AmplifyAuthTask where Request == AuthConfirmSignInWithOTPRequest, Success == AuthSignInResult, Failure == AuthError {} + +public extension HubPayload.EventName.Auth { + + /// eventName for HubPayloads emitted by this operation + static let confirmSignInWithOTPAPI = "Auth.confirmSignInWithOTPAPI" +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthSignInWithOTPTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthSignInWithOTPTask.swift new file mode 100644 index 0000000000..e86c171c79 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthSignInWithOTPTask.swift @@ -0,0 +1,16 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +import Foundation +import Amplify + +protocol AuthSignInWithOTPTask: AmplifyAuthTask where Request == AuthSignInWithOTPRequest, Success == AuthSignInResult, Failure == AuthError {} + +public extension HubPayload.EventName.Auth { + + /// eventName for HubPayloads emitted by this operation + static let signInWithOTPAPI = "Auth.signInWithOTPAPI" +} diff --git a/AmplifyTestCommon/Mocks/MockAuthCategoryPlugin.swift b/AmplifyTestCommon/Mocks/MockAuthCategoryPlugin.swift index 617644ad54..6093799470 100644 --- a/AmplifyTestCommon/Mocks/MockAuthCategoryPlugin.swift +++ b/AmplifyTestCommon/Mocks/MockAuthCategoryPlugin.swift @@ -110,11 +110,35 @@ class MockAuthCategoryPlugin: MessageReporter, AuthCategoryPlugin { fatalError() } - func signInWithMagicLink(username: String, flow: AuthPasswordlessFlow, redirectURL: String, options: AuthPasswordlessMagicLinkRequest.Options?) async throws -> AuthSignInResult { + func signInWithMagicLink( + username: String, + flow: AuthPasswordlessFlow, + redirectURL: String, + options: AuthPasswordlessMagicLinkRequest.Options? + ) async throws -> AuthSignInResult { fatalError() } - func confirmSignInWithMagicLink(challengeResponse: String, options: AuthPasswordlessMagicLinkRequest.Options?) async throws -> AuthSignInResult { + func confirmSignInWithMagicLink( + challengeResponse: String, + options: AuthPasswordlessMagicLinkRequest.Options? + ) async throws -> AuthSignInResult { + fatalError() + } + + func signInWithOTP( + username: String, + flow: AuthPasswordlessFlow, + destination: AuthPasswordlessDeliveryDestination, + options: AuthSignInWithOTPRequest.Options? + ) async throws -> AuthSignInResult { + fatalError() + } + + func confirmSignInWithOTP( + challengeResponse: String, + options: AuthConfirmSignInWithOTPRequest.Options? + ) async throws -> AuthSignInResult { fatalError() }