diff --git a/.github/workflows/integ_test_auth.yml b/.github/workflows/integ_test_auth.yml index c7722b5745..5cbceea4f7 100644 --- a/.github/workflows/integ_test_auth.yml +++ b/.github/workflows/integ_test_auth.yml @@ -58,3 +58,9 @@ jobs: resource_subfolder: auth timeout-minutes: 30 secrets: inherit + + # Disabling the integration test because the job is not able to connect to the local server + # auth-webauthn-integration-test-iOS: + # name: Auth WebAuthn Integration Tests (iOS) + # uses: ./.github/workflows/integ_test_auth_webauthn.yml + # secrets: inherit diff --git a/.github/workflows/integ_test_auth_webauthn.yml b/.github/workflows/integ_test_auth_webauthn.yml new file mode 100644 index 0000000000..f965a11531 --- /dev/null +++ b/.github/workflows/integ_test_auth_webauthn.yml @@ -0,0 +1,101 @@ +name: Integration Tests | Auth - WebAuthn +on: + workflow_dispatch: + workflow_call: + +permissions: + id-token: write + contents: read + +jobs: + auth-webauthn-integration-tests: + name: iOS Tests | AuthWebAuthnApp + runs-on: macos-15 + timeout-minutes: 30 + environment: IntegrationTest + + steps: + - name: Checkout repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + persist-credentials: false + + - name: Get build parameters for iOS + id: platform + uses: ./.github/composite_actions/get_platform_parameters + with: + platform: iOS + + - name: Create the test configuration directory + run: mkdir -p ~/.aws-amplify/amplify-ios/testconfiguration/ + + - name: Download the Integration Test configurations + uses: ./.github/composite_actions/download_test_configuration + with: + resource_subfolder: auth + aws_role_to_assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} + aws_region: ${{ secrets.AWS_REGION }} + aws_s3_bucket: ${{ secrets.AWS_S3_BUCKET_INTEG_V2 }} + destination: ~/.aws-amplify/amplify-ios/testconfiguration/ + + - name: Set up node + uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1 + with: + node-version: 16.x + + - name: Attempt to use the dependencies cache + id: dependencies-cache + timeout-minutes: 4 + continue-on-error: true + uses: actions/cache/restore@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1 + with: + path: ~/Library/Developer/Xcode/DerivedData/Amplify + key: amplify-packages-${{ hashFiles('Package.resolved') }} + restore-keys: | + amplify-packages- + + - name: Attempt to restore the build cache + id: build-cache + timeout-minutes: 4 + continue-on-error: true + uses: actions/cache/restore@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3.3.1 + with: + path: ${{ github.workspace }}/Build + key: Amplify-iOS-build-cache + + - name: Run Local Server + run: | + cd ./AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/LocalServer + npm install + npm start & + shell: bash + + - name: Run iOS Integration Tests + id: run-tests + continue-on-error: true + uses: ./.github/composite_actions/run_xcodebuild_test + with: + scheme: AuthWebAuthnApp + destination: ${{ steps.platform.outputs.destination }} + sdk: ${{ steps.platform.outputs.sdk }} + xcode_path: /Applications/Xcode_${{ steps.platform.outputs.xcode-version }}.app + project_path: ./AmplifyPlugins/Auth/Tests/AuthWebAuthnApp + generate_coverage: false + cloned_source_packages_path: ~/Library/Developer/Xcode/DerivedData/Amplify + derived_data_path: ${{ github.workspace }}/Build + disable_package_resolution: ${{ steps.dependencies-cache.outputs.cache-hit }} + + - name: Retry iOS Integration Tests + if: steps.run-tests.outcome=='failure' + id: retry-tests + uses: ./.github/composite_actions/run_xcodebuild_test + with: + scheme: AuthWebAuthnApp + destination: ${{ steps.platform.outputs.destination }} + sdk: ${{ steps.platform.outputs.sdk }} + xcode_path: /Applications/Xcode_${{ steps.platform.outputs.xcode-version }}.app + project_path: ./AmplifyPlugins/Auth/Tests/AuthWebAuthnApp + generate_coverage: false + cloned_source_packages_path: ~/Library/Developer/Xcode/DerivedData/Amplify + derived_data_path: ${{ github.workspace }}/Build + disable_package_resolution: true \ No newline at end of file diff --git a/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift b/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift index 5907f36810..b8d5a158c8 100644 --- a/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift +++ b/Amplify/Categories/Auth/AuthCategory+ClientBehavior.swift @@ -64,6 +64,10 @@ extension AuthCategory: AuthCategoryBehavior { return await plugin.signOut(options: options) } + public func autoSignIn() async throws -> AuthSignInResult { + try await plugin.autoSignIn() + } + public func deleteUser() async throws { try await plugin.deleteUser() } diff --git a/Amplify/Categories/Auth/AuthCategory+WebAuthnBehaviour.swift b/Amplify/Categories/Auth/AuthCategory+WebAuthnBehaviour.swift new file mode 100644 index 0000000000..be1a552453 --- /dev/null +++ b/Amplify/Categories/Auth/AuthCategory+WebAuthnBehaviour.swift @@ -0,0 +1,51 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension AuthCategory: AuthCategoryWebAuthnBehaviour { +#if os(iOS) || os(macOS) + @available(iOS 17.4, macOS 13.5, *) + public func associateWebAuthnCredential( + presentationAnchor: AuthUIPresentationAnchor? = nil, + options: AuthAssociateWebAuthnCredentialRequest.Options? = nil + ) async throws { + try await plugin.associateWebAuthnCredential( + presentationAnchor: presentationAnchor, + options: options + ) + } +#elseif os(visionOS) + public func associateWebAuthnCredential( + presentationAnchor: AuthUIPresentationAnchor, + options: AuthAssociateWebAuthnCredentialRequest.Options? = nil + ) async throws { + try await plugin.associateWebAuthnCredential( + presentationAnchor: presentationAnchor, + options: options + ) + } +#endif + + public func listWebAuthnCredentials( + options: AuthListWebAuthnCredentialsRequest.Options? = nil + ) async throws -> AuthListWebAuthnCredentialsResult { + return try await plugin.listWebAuthnCredentials( + options: options + ) + } + + public func deleteWebAuthnCredential( + credentialId: String, + options: AuthDeleteWebAuthnCredentialRequest.Options? = nil + ) async throws { + try await plugin.deleteWebAuthnCredential( + credentialId: credentialId, + options: options + ) + } +} diff --git a/Amplify/Categories/Auth/AuthCategoryBehavior.swift b/Amplify/Categories/Auth/AuthCategoryBehavior.swift index 4af66a67dd..e33e3d09fb 100644 --- a/Amplify/Categories/Auth/AuthCategoryBehavior.swift +++ b/Amplify/Categories/Auth/AuthCategoryBehavior.swift @@ -12,7 +12,7 @@ public typealias AuthUIPresentationAnchor = ASPresentationAnchor #endif /// Behavior of the Auth category that clients will use -public protocol AuthCategoryBehavior: AuthCategoryUserBehavior, AuthCategoryDeviceBehavior { +public protocol AuthCategoryBehavior: AuthCategoryUserBehavior, AuthCategoryDeviceBehavior, AuthCategoryWebAuthnBehaviour { /// SignUp a user with the authentication provider. /// @@ -102,6 +102,10 @@ public protocol AuthCategoryBehavior: AuthCategoryUserBehavior, AuthCategoryDevi options: AuthConfirmSignInRequest.Options? ) async throws -> AuthSignInResult + + /// Auto signs in the user for passwordless sign up + func autoSignIn() async throws -> AuthSignInResult + /// Sign out the currently logged-in user. /// /// - Parameters: diff --git a/Amplify/Categories/Auth/AuthCategoryWebAuthnBehaviour.swift b/Amplify/Categories/Auth/AuthCategoryWebAuthnBehaviour.swift new file mode 100644 index 0000000000..44e281f517 --- /dev/null +++ b/Amplify/Categories/Auth/AuthCategoryWebAuthnBehaviour.swift @@ -0,0 +1,35 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +public protocol AuthCategoryWebAuthnBehaviour: AnyObject { +#if os(iOS) || os(macOS) + /// - Tag: AuthCategoryWebAuthnBehaviour.associate + @available(iOS 17.4, macOS 13.5, *) + func associateWebAuthnCredential( + presentationAnchor: AuthUIPresentationAnchor?, + options: AuthAssociateWebAuthnCredentialRequest.Options? + ) async throws +#elseif os(visionOS) + func associateWebAuthnCredential( + presentationAnchor: AuthUIPresentationAnchor, + options: AuthAssociateWebAuthnCredentialRequest.Options? + ) async throws +#endif + + /// - Tag: AuthCategoryWebAuthnBehaviour.list + func listWebAuthnCredentials( + options: AuthListWebAuthnCredentialsRequest.Options? + ) async throws -> AuthListWebAuthnCredentialsResult + + /// - Tag: AuthCategoryWebAuthnBehaviour.delete + func deleteWebAuthnCredential( + credentialId: String, + options: AuthDeleteWebAuthnCredentialRequest.Options? + ) async throws +} diff --git a/Amplify/Categories/Auth/Models/AuthFactorType.swift b/Amplify/Categories/Auth/Models/AuthFactorType.swift new file mode 100644 index 0000000000..9dce5a8bfc --- /dev/null +++ b/Amplify/Categories/Auth/Models/AuthFactorType.swift @@ -0,0 +1,27 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +public enum AuthFactorType: String { + + /// An auth factor that uses password + case password + + /// An auth factor that uses SRP protocol + case passwordSRP + + /// An auth factor that uses SMS OTP + case smsOTP + + /// An auth factor that uses Email OTP + case emailOTP + +#if os(iOS) || os(macOS) || os(visionOS) + /// An auth factor that uses WebAuthn + @available(iOS 17.4, macOS 13.5, visionOS 1.0, *) + case webAuthn +#endif +} diff --git a/Amplify/Categories/Auth/Models/AuthSignInStep.swift b/Amplify/Categories/Auth/Models/AuthSignInStep.swift index 26837b627b..bf7f75101a 100644 --- a/Amplify/Categories/Auth/Models/AuthSignInStep.swift +++ b/Amplify/Categories/Auth/Models/AuthSignInStep.swift @@ -8,6 +8,9 @@ /// Set of allowed MFA types that would be used for continuing sign in during MFA selection step public typealias AllowedMFATypes = Set +/// Set of available factors that would be used for continuing/confirming sign in +public typealias AvailableAuthFactorTypes = Set + /// Auth SignIn flow steps /// /// @@ -26,6 +29,10 @@ public enum AuthSignInStep { /// case confirmSignInWithNewPassword(AdditionalInfo?) + /// Auth step required the user to give a password. + /// + case confirmSignInWithPassword + /// Auth step is TOTP multi factor authentication. /// /// Confirmation code for the MFA will be retrieved from the associated Authenticator app @@ -52,6 +59,10 @@ public enum AuthSignInStep { /// OTP for the factor will be sent to the delivery medium. case confirmSignInWithOTP(AuthCodeDeliveryDetails) + /// Auth step is for continuing sign in by selecting the first factor that would be used for signing in + /// + case continueSignInWithFirstFactorSelection(AvailableAuthFactorTypes) + /// Auth step required the user to change their password. /// case resetPassword(AdditionalInfo?) diff --git a/Amplify/Categories/Auth/Models/AuthSignUpStep.swift b/Amplify/Categories/Auth/Models/AuthSignUpStep.swift index 2d66d9cf56..861f6840cf 100644 --- a/Amplify/Categories/Auth/Models/AuthSignUpStep.swift +++ b/Amplify/Categories/Auth/Models/AuthSignUpStep.swift @@ -6,6 +6,7 @@ // public typealias UserId = String +public typealias Session = String /// SignUp step to be followed. public enum AuthSignUpStep { @@ -16,6 +17,10 @@ public enum AuthSignUpStep { AdditionalInfo? = nil, UserId? = nil) + /// Sign Up successfully completed + /// The customers can use this step to determine if they want to complete sign in + case completeAutoSignIn(Session) + /// Sign up is complete case done } diff --git a/Amplify/Categories/Auth/Models/AuthWebAuthnCredential.swift b/Amplify/Categories/Auth/Models/AuthWebAuthnCredential.swift new file mode 100644 index 0000000000..f99be622d6 --- /dev/null +++ b/Amplify/Categories/Auth/Models/AuthWebAuthnCredential.swift @@ -0,0 +1,52 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Represents the output of a call to +/// [`AuthCategoryWebAuthnBehaviour.listWebAuthnCredentials(options:)`](x-source-tag://AuthCategoryWebAuthnBehaviour.list) +/// +/// - Tag: AuthListWebAuthnCredentialsResult +public struct AuthListWebAuthnCredentialsResult { + /// The list of WebAuthn credentials + /// + /// - Tag: AuthListWebAuthnCredentialsResult.credentials + public var credentials: [AuthWebAuthnCredential] + + /// String indicating the page offset at which to resume a listing. + /// + /// This value is usually copied to + /// [AuthListWebAuthnCredentialsRequest.Options.nextToken](x-source-tag://AuthListWebAuthnCredentialsRequestOptions.nextToken). + /// + /// - Tag: AuthListWebAuthnCredentialsResult.nextToken + public let nextToken: String? + + /// - Tag: AuthListWebAuthnCredentialsResult.init + public init( + credentials: [AuthWebAuthnCredential], + nextToken: String? + ) { + self.credentials = credentials + self.nextToken = nextToken + } +} + +/// Defines a WebAuthn credential +/// - Tag: AuthWebAuthnCredential +public protocol AuthWebAuthnCredential { + /// The credential's ID + var credentialId: String { get } + + /// The credential's creation date + var createdAt: Date { get } + + /// The credential's relying party ID + var relyingPartyId: String { get } + + /// The credential's friendly name + var friendlyName: String? { get } +} diff --git a/Amplify/Categories/Auth/Request/AuthAssociateWebAuthnCredentialRequest.swift b/Amplify/Categories/Auth/Request/AuthAssociateWebAuthnCredentialRequest.swift new file mode 100644 index 0000000000..de2f4e547b --- /dev/null +++ b/Amplify/Categories/Auth/Request/AuthAssociateWebAuthnCredentialRequest.swift @@ -0,0 +1,40 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) +import Foundation + +/// Request for creating a new WebAuthn Credential and associating it with the signed in user +public struct AuthAssociateWebAuthnCredentialRequest: AmplifyOperationRequest { + /// Presentation anchor on which the credential request displayed + public let presentationAnchor: AuthUIPresentationAnchor? + + /// Extra request options defined in `AuthAssociateWebAuthnCredentialRequest.Options` + public let options: Options + + public init( + presentationAnchor: AuthUIPresentationAnchor?, + options: Options + ) { + self.presentationAnchor = presentationAnchor + self.options = options + } +} + +public extension AuthAssociateWebAuthnCredentialRequest { + 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 + } + } +} +#endif diff --git a/Amplify/Categories/Auth/Request/AuthAutoSignInRequest.swift b/Amplify/Categories/Auth/Request/AuthAutoSignInRequest.swift new file mode 100644 index 0000000000..c7cd21ef40 --- /dev/null +++ b/Amplify/Categories/Auth/Request/AuthAutoSignInRequest.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request to auto sign in +public struct AuthAutoSignInRequest: AmplifyOperationRequest { + + /// Extra request options defined in `AuthAutoSignInRequest.Options` + public var options: Options + + public init(options: Options) { + self.options = options + } +} + +public extension AuthAutoSignInRequest { + + 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/Amplify/Categories/Auth/Request/AuthConfirmSignInRequest.swift b/Amplify/Categories/Auth/Request/AuthConfirmSignInRequest.swift index 4609758e53..cff8e863dc 100644 --- a/Amplify/Categories/Auth/Request/AuthConfirmSignInRequest.swift +++ b/Amplify/Categories/Auth/Request/AuthConfirmSignInRequest.swift @@ -33,8 +33,22 @@ public extension AuthConfirmSignInRequest { /// key/values public let pluginOptions: Any? +#if os(iOS) || os(macOS) || os(visionOS) + /// Provide a presentation anchor if you are confirming sign in with WebAuthn. The WebAuthn assertion will be presented + /// in the presentation anchor provided. + public let presentationAnchorForWebAuthn: AuthUIPresentationAnchor? + + public init( + presentationAnchorForWebAuthn: AuthUIPresentationAnchor? = nil, + pluginOptions: Any? = nil + ) { + self.presentationAnchorForWebAuthn = presentationAnchorForWebAuthn + self.pluginOptions = pluginOptions + } +#else public init(pluginOptions: Any? = nil) { self.pluginOptions = pluginOptions } +#endif } } diff --git a/Amplify/Categories/Auth/Request/AuthDeleteWebAuthnCredentialRequest.swift b/Amplify/Categories/Auth/Request/AuthDeleteWebAuthnCredentialRequest.swift new file mode 100644 index 0000000000..edbe68105d --- /dev/null +++ b/Amplify/Categories/Auth/Request/AuthDeleteWebAuthnCredentialRequest.swift @@ -0,0 +1,38 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request for deleting a WebAuthn Credential +public struct AuthDeleteWebAuthnCredentialRequest: AmplifyOperationRequest { + /// The ID for the credential that will be deleted + public let credentialId: String + + /// Extra request options defined in `AuthDeleteWebAuthnCredentialRequest.Options` + public let options: Options + + public init( + credentialId: String, + options: Options + ) { + self.credentialId = credentialId + self.options = options + } +} + +public extension AuthDeleteWebAuthnCredentialRequest { + 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/Amplify/Categories/Auth/Request/AuthListWebAuthnCredentialsRequest.swift b/Amplify/Categories/Auth/Request/AuthListWebAuthnCredentialsRequest.swift new file mode 100644 index 0000000000..4d97431782 --- /dev/null +++ b/Amplify/Categories/Auth/Request/AuthListWebAuthnCredentialsRequest.swift @@ -0,0 +1,62 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +/// Request for listing WebAuthn Credentials +/// +/// - Tag: AuthListWebAuthnCredentialsRequest +public struct AuthListWebAuthnCredentialsRequest: AmplifyOperationRequest { + /// Extra request options + /// + /// - Tag: AuthListWebAuthnCredentialsRequest.options + public let options: Options + + /// - Tag: AuthListWebAuthnCredentialsRequest.init + public init(options: Options) { + self.options = options + } +} + +public extension AuthListWebAuthnCredentialsRequest { + /// Options available to callers of + /// [AuthCategoryWebAuthnBehaviour.list](x-source-tag://AuthCategoryWebAuthnBehaviour.list). + /// + /// - Tag: AuthListWebAuthnCredentialsRequestOptions + struct Options { + /// Number between 1 and 20 that indicates the limit of how many credentials to retrieve + /// + /// - Tag: AuthListWebAuthnCredentialsRequestOptions.pageSize + public let pageSize: UInt + + /// String indicating the page offset at which to resume a listing. + /// + /// This is usually a copy of the value from + /// [AuthListWebAuthnCredentialsResult.nextToken](x-source-tag://AuthListWebAuthnCredentialsResult.nextToken). + /// + /// - Tag: AuthListWebAuthnCredentialsRequestOptions.nextToken + public let nextToken: String? + + /// 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 + /// + /// - Tag: AuthListWebAuthnCredentialsRequestOptions.pluginOptions + public let pluginOptions: Any? + + /// - Tag: AuthListWebAuthnCredentialsRequestOptions.init + public init( + pageSize: UInt = 20, + nextToken: String? = nil, + pluginOptions: Any? = nil + ) { + self.pageSize = pageSize + self.nextToken = nextToken + self.pluginOptions = pluginOptions + } + } +} diff --git a/Amplify/Categories/Auth/Request/AuthSignInRequest.swift b/Amplify/Categories/Auth/Request/AuthSignInRequest.swift index c61f668092..057babd76d 100644 --- a/Amplify/Categories/Auth/Request/AuthSignInRequest.swift +++ b/Amplify/Categories/Auth/Request/AuthSignInRequest.swift @@ -35,8 +35,22 @@ public extension AuthSignInRequest { /// key/values public let pluginOptions: Any? +#if os(iOS) || os(macOS) || os(visionOS) + /// Provide a presentation anchor if you are signing in with WebAuthn. The WebAuthn assertion will be presented + /// in the presentation anchor provided. + public let presentationAnchorForWebAuthn: AuthUIPresentationAnchor? + + public init( + presentationAnchorForWebAuthn: AuthUIPresentationAnchor? = nil, + pluginOptions: Any? = nil + ) { + self.presentationAnchorForWebAuthn = presentationAnchorForWebAuthn + self.pluginOptions = pluginOptions + } +#else public init(pluginOptions: Any? = nil) { self.pluginOptions = pluginOptions } +#endif } } diff --git a/Amplify/Categories/Auth/Result/AuthSignUpResult.swift b/Amplify/Categories/Auth/Result/AuthSignUpResult.swift index 012856908a..b63154dda9 100644 --- a/Amplify/Categories/Auth/Result/AuthSignUpResult.swift +++ b/Amplify/Categories/Auth/Result/AuthSignUpResult.swift @@ -12,7 +12,7 @@ public struct AuthSignUpResult { /// Indicate whether the signUp flow is completed. public var isSignUpComplete: Bool { switch nextStep { - case .done: + case .completeAutoSignIn, .done: return true default: return false diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/Federation/ClearFederationToIdentityPool.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/Federation/ClearFederationToIdentityPool.swift index 1a8a869be1..78e85d50db 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/Federation/ClearFederationToIdentityPool.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/Federation/ClearFederationToIdentityPool.swift @@ -26,7 +26,7 @@ struct ClearFederationToIdentityPool: Action { try await credentialStoreClient?.deleteData(type: .amplifyCredentials) event = AuthenticationEvent.init(eventType: .clearedFederationToIdentityPool) } else { - let error = AuthenticationError.service(message: "Unable to find credentials to clear for federation.") + let error = AuthenticationError.service(message: "Unable to find credentials to clear for federation.", error: nil) event = AuthenticationEvent.init(eventType: .error(error)) logError("\(#fileID) Sending event \(event.type) with error \(error)", environment: environment) } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/InitializeResolveChallenge.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/InitializeResolveChallenge.swift index f631e133d3..be0a29c5f1 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/InitializeResolveChallenge.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/InitializeResolveChallenge.swift @@ -48,10 +48,10 @@ struct InitializeResolveChallenge: Action { return .confirmSignInWithCustomChallenge(challenge.parameters) case .newPasswordRequired: return .confirmSignInWithNewPassword(challenge.parameters) + case .passwordRequired: + return .confirmSignInWithPassword 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 @@ -65,6 +65,11 @@ struct InitializeResolveChallenge: Action { 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)") + case .smsOTP, .emailOTP: + let delivery = challenge.codeDeliveryDetails + return .confirmSignInWithOTP(delivery) + case .selectAuthFactor: + return .continueSignInWithFirstFactorSelection(challenge.getAllowedAuthFactorsForSelection) } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/IntializeSignInFlow.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/IntializeSignInFlow.swift index 8ea604a1ff..5b8be89cc5 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/IntializeSignInFlow.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/IntializeSignInFlow.swift @@ -13,6 +13,8 @@ struct InitializeSignInFlow: Action { var identifier: String = "IntializeSignInFlow" let signInEventData: SignInEventData + + let autoSignIn: Bool func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async { logVerbose("\(#fileID) Starting execution", environment: environment) @@ -54,18 +56,23 @@ struct InitializeSignInFlow: Action { with deviceMetadata: DeviceMetadata) -> SignInEvent { switch authflow { case .userSRP: - return .init(eventType: .initiateSignInWithSRP(signInEventData, deviceMetadata)) + return .init(eventType: .initiateSignInWithSRP(signInEventData, deviceMetadata, nil)) case .customWithoutSRP: return .init(eventType: .initiateCustomSignIn(signInEventData, deviceMetadata)) case .customWithSRP: return .init(eventType: .initiateCustomSignInWithSRP(signInEventData, deviceMetadata)) case .userPassword: - return .init(eventType: .initiateMigrateAuth(signInEventData, deviceMetadata)) + return .init(eventType: .initiateMigrateAuth(signInEventData, deviceMetadata, nil)) // Using `custom` here to keep the legacy behaviour from V1 intact, // which is custom flow type will start with SRP_A flow. case .custom: return .init(eventType: .initiateCustomSignInWithSRP(signInEventData, deviceMetadata)) - + case .userAuth: + if autoSignIn { + return .init(eventType: .initiateAutoSignIn(signInEventData, deviceMetadata)) + } else { + return .init(eventType: .initiateUserAuth(signInEventData, deviceMetadata)) + } } } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/MigrateAuth/InitiateMigrateAuth.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/MigrateAuth/InitiateMigrateAuth.swift index 34cf4ece9d..a8c2e74627 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/MigrateAuth/InitiateMigrateAuth.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/MigrateAuth/InitiateMigrateAuth.swift @@ -16,15 +16,18 @@ struct InitiateMigrateAuth: Action { let password: String let clientMetadata: [String: String] let deviceMetadata: DeviceMetadata + let respondToAuthChallenge: RespondToAuthChallenge? init(username: String, password: String, clientMetadata: [String: String], - deviceMetadata: DeviceMetadata) { + deviceMetadata: DeviceMetadata, + respondToAuthChallenge: RespondToAuthChallenge?) { self.username = username self.password = password self.clientMetadata = clientMetadata self.deviceMetadata = deviceMetadata + self.respondToAuthChallenge = respondToAuthChallenge } func execute(withDispatcher dispatcher: EventDispatcher, @@ -36,16 +39,34 @@ struct InitiateMigrateAuth: Action { let asfDeviceId = try await CognitoUserPoolASF.asfDeviceID( for: username, credentialStoreClient: authEnv.credentialsClient) - let request = await InitiateAuthInput.migrateAuth( - username: username, - password: password, - clientMetadata: clientMetadata, - asfDeviceId: asfDeviceId, - deviceMetadata: deviceMetadata, - environment: userPoolEnv) - - let responseEvent = try await sendRequest(request: request, - environment: userPoolEnv) + + let responseEvent: StateMachineEvent + if let session = respondToAuthChallenge?.session { + let request = await RespondToAuthChallengeInput.userPasswordInputForUserAuth( + username: username, + password: password, + session: session, + clientMetadata: clientMetadata, + asfDeviceId: asfDeviceId, + deviceMetadata: deviceMetadata, + environment: userPoolEnv) + responseEvent = try await sendRequest( + request: request, + environment: userPoolEnv) + + } else { + let request = await InitiateAuthInput.migrateAuth( + username: username, + password: password, + clientMetadata: clientMetadata, + asfDeviceId: asfDeviceId, + deviceMetadata: deviceMetadata, + environment: userPoolEnv) + responseEvent = try await sendRequest( + request: request, + environment: userPoolEnv) + } + logVerbose("\(#fileID) Sending event \(responseEvent)", environment: environment) await dispatcher.send(responseEvent) @@ -64,30 +85,34 @@ struct InitiateMigrateAuth: Action { } - private func sendRequest(request: InitiateAuthInput, + private func sendRequest(request: RespondToAuthChallengeInput, environment: UserPoolEnvironment) async throws -> StateMachineEvent { let cognitoClient = try environment.cognitoUserPoolFactory() logVerbose("\(#fileID) Starting execution", environment: environment) - let response = try await cognitoClient.initiateAuth(input: request) - return try UserPoolSignInHelper.parseResponse(response, - for: username, - signInMethod: .apiBased(.userPassword)) + let response = try await cognitoClient.respondToAuthChallenge(input: request) + return UserPoolSignInHelper.parseResponse(response, + for: username, + signInMethod: .apiBased(.userPassword)) } -} + private func sendRequest(request: InitiateAuthInput, + environment: UserPoolEnvironment) async throws -> StateMachineEvent { -extension InitiateMigrateAuth: DefaultLogger { - public static var log: Logger { - Amplify.Logging.logger(forCategory: CategoryType.auth.displayName, forNamespace: String(describing: self)) - } + let cognitoClient = try environment.cognitoUserPoolFactory() + logVerbose("\(#fileID) Starting execution", environment: environment) - public var log: Logger { - Self.log + let response = try await cognitoClient.initiateAuth(input: request) + return UserPoolSignInHelper.parseResponse(response, + for: username, + signInMethod: .apiBased(.userPassword)) } + } +extension InitiateMigrateAuth: DefaultLogger { } + extension InitiateMigrateAuth: CustomDebugDictionaryConvertible { var debugDictionary: [String: Any] { [ diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/MigrateAuth/StartMigrateAuthFlow.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/MigrateAuth/StartMigrateAuthFlow.swift index 435e3fa1e7..9c68104d14 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/MigrateAuth/StartMigrateAuthFlow.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/MigrateAuth/StartMigrateAuthFlow.swift @@ -13,10 +13,11 @@ struct StartMigrateAuthFlow: Action { let signInEventData: SignInEventData let deviceMetadata: DeviceMetadata + let respondToAuthChallenge: RespondToAuthChallenge? func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async { logVerbose("\(#fileID) Start execution", environment: environment) - let event = SignInEvent(id: UUID().uuidString, eventType: .initiateMigrateAuth(signInEventData, deviceMetadata)) + let event = SignInEvent(id: UUID().uuidString, eventType: .initiateMigrateAuth(signInEventData, deviceMetadata, respondToAuthChallenge)) logVerbose("\(#fileID) Sending event \(event.type)", environment: environment) await dispatcher.send(event) } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/InitiateAuthSRP.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/InitiateAuthSRP.swift index b1645d0ee1..6828d17537 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/InitiateAuthSRP.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/InitiateAuthSRP.swift @@ -18,17 +18,20 @@ struct InitiateAuthSRP: Action { let authFlowType: AuthFlowType let deviceMetadata: DeviceMetadata let clientMetadata: [String: String] + let respondToAuthChallenge: RespondToAuthChallenge? init(username: String, password: String, authFlowType: AuthFlowType = .userSRP, deviceMetadata: DeviceMetadata = .noData, - clientMetadata: [String: String] = [:]) { + clientMetadata: [String: String] = [:], + respondToAuthChallenge: RespondToAuthChallenge?) { self.username = username self.password = password self.authFlowType = authFlowType self.deviceMetadata = deviceMetadata self.clientMetadata = clientMetadata + self.respondToAuthChallenge = respondToAuthChallenge } func execute(withDispatcher dispatcher: EventDispatcher, @@ -56,18 +59,39 @@ struct InitiateAuthSRP: Action { for: username, credentialStoreClient: authEnv.credentialsClient) - let request = await InitiateAuthInput.srpInput( - username: username, - publicSRPAHexValue: srpKeyPair.publicKeyHexValue, - authFlowType: authFlowType, - clientMetadata: clientMetadata, - asfDeviceId: asfDeviceId, - deviceMetadata: deviceMetadata, - environment: userPoolEnv) - - let responseEvent = try await sendRequest(request: request, - environment: userPoolEnv, - srpStateData: srpStateData) + let responseEvent: SignInEvent + if case .userAuth = authFlowType, + let session = respondToAuthChallenge?.session { + let request = await RespondToAuthChallengeInput.srpInputForUserAuth( + username: username, + publicSRPAHexValue: srpKeyPair.publicKeyHexValue, + session: session, + clientMetadata: clientMetadata, + asfDeviceId: asfDeviceId, + deviceMetadata: deviceMetadata, + environment: userPoolEnv) + + responseEvent = try await sendRequest( + request: request, + environment: userPoolEnv, + srpStateData: srpStateData) + } else { + let request = await InitiateAuthInput.srpInput( + username: username, + publicSRPAHexValue: srpKeyPair.publicKeyHexValue, + authFlowType: authFlowType, + clientMetadata: clientMetadata, + asfDeviceId: asfDeviceId, + deviceMetadata: deviceMetadata, + environment: userPoolEnv) + + responseEvent = try await sendRequest( + request: request, + environment: userPoolEnv, + srpStateData: srpStateData) + } + + logVerbose("\(#fileID) Sending event \(responseEvent)", environment: srpEnv) await dispatcher.send(responseEvent) @@ -86,6 +110,17 @@ struct InitiateAuthSRP: Action { } + private func sendRequest(request: RespondToAuthChallengeInput, + environment: UserPoolEnvironment, + srpStateData: SRPStateData) async throws -> SignInEvent { + + let cognitoClient = try environment.cognitoUserPoolFactory() + logVerbose("\(#fileID) Starting execution", environment: environment) + let response = try await cognitoClient.respondToAuthChallenge(input: request) + logVerbose("\(#fileID) InitiateAuth response success", environment: environment) + return SignInEvent(eventType: .respondPasswordVerifier(srpStateData, response, clientMetadata)) + } + private func sendRequest(request: InitiateAuthInput, environment: UserPoolEnvironment, srpStateData: SRPStateData) async throws -> SignInEvent { @@ -99,6 +134,7 @@ struct InitiateAuthSRP: Action { let username = parameters?["USERNAME"] ?? username let respondToAuthChallenge = RespondToAuthChallenge( challenge: .customChallenge, + availableChallenges: [], username: username, session: response.session, parameters: parameters) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/StartSRPFlow.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/StartSRPFlow.swift index ade3d7e158..a9e1befb50 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/StartSRPFlow.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/StartSRPFlow.swift @@ -15,9 +15,11 @@ struct StartSRPFlow: Action { let deviceMetadata: DeviceMetadata + let respondToAuthChallenge: RespondToAuthChallenge? + func execute(withDispatcher dispatcher: EventDispatcher, environment: Environment) async { logVerbose("\(#fileID) Start execution", environment: environment) - let event = SignInEvent(id: UUID().uuidString, eventType: .initiateSignInWithSRP(signInEventData, deviceMetadata)) + let event = SignInEvent(id: UUID().uuidString, eventType: .initiateSignInWithSRP(signInEventData, deviceMetadata, respondToAuthChallenge)) logVerbose("\(#fileID) Sending event \(event.type)", environment: environment) await dispatcher.send(event) } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/ThrowSignInError.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/ThrowSignInError.swift index 80222157e2..3ec2fc56a8 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/ThrowSignInError.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/ThrowSignInError.swift @@ -18,7 +18,7 @@ struct ThrowSignInError: Action { logVerbose("\(#fileID) Starting execution", environment: environment) let event = AuthenticationEvent( - eventType: .error(.service(message: "\(error)"))) + eventType: .error(.service(message: "\(error)", error: error))) logVerbose("\(#fileID) Sending event \(event)", environment: environment) await dispatcher.send(event) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift index d6687065a7..ba7e4f6c8f 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/SRPAuth/VerifyPasswordSRP.swift @@ -13,11 +13,11 @@ struct VerifyPasswordSRP: Action { let identifier = "VerifyPasswordSRP" let stateData: SRPStateData - let authResponse: InitiateAuthOutput + let authResponse: SignInResponseBehavior let clientMetadata: ClientMetadata init(stateData: SRPStateData, - authResponse: InitiateAuthOutput, + authResponse: SignInResponseBehavior, clientMetadata: ClientMetadata) { self.stateData = stateData self.authResponse = authResponse diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/UserAuth/InitiateUserAuth.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/UserAuth/InitiateUserAuth.swift new file mode 100644 index 0000000000..1c8562107f --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/UserAuth/InitiateUserAuth.swift @@ -0,0 +1,114 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import Foundation +import AWSCognitoIdentityProvider + +struct InitiateUserAuth: Action { + let identifier = "InitiateUserAuth" + + let signInEventData: SignInEventData + let deviceMetadata: DeviceMetadata + + init(signInEventData: SignInEventData, + deviceMetadata: DeviceMetadata) { + self.signInEventData = signInEventData + self.deviceMetadata = deviceMetadata + } + + func execute(withDispatcher dispatcher: EventDispatcher, + environment: Environment) async { + do { + let userPoolEnv = try environment.userPoolEnvironment() + let authEnv = try environment.authEnvironment() + + guard let username = signInEventData.username else { + logVerbose("\(#fileID) Unable to extract username from signInEventData", environment: environment) + let authError = SignInError.inputValidation(field: "Unable to extract username") + let event = SignInEvent( + eventType: .throwAuthError(authError) + ) + await dispatcher.send(event) + return + } + + let preferredChallengeAuthParams: [String: String] + let srpStateData: SRPStateData? + if case .apiBased(let authFlow) = signInEventData.signInMethod, + case .userAuth(let firstFactor) = authFlow, + let authFactor = firstFactor { + let preferredChallengeHelper = PreferredChallengeHelper( + authFactor: authFactor, + password: signInEventData.password, + username: username, + environment: environment) + preferredChallengeAuthParams = try preferredChallengeHelper.toCognitoAuthParameters() + srpStateData = preferredChallengeHelper.srpStateData + } else { + preferredChallengeAuthParams = [:] + srpStateData = nil + } + + + let asfDeviceId = try await CognitoUserPoolASF.asfDeviceID( + for: username, + credentialStoreClient: authEnv.credentialsClient) + let request = await InitiateAuthInput.userAuth( + username: username, + preferredChallengeAuthParams: preferredChallengeAuthParams, + clientMetadata: signInEventData.clientMetadata, + asfDeviceId: asfDeviceId, + deviceMetadata: deviceMetadata, + environment: userPoolEnv) + + let cognitoClient = try userPoolEnv.cognitoUserPoolFactory() + logVerbose("\(#fileID) Starting execution", environment: environment) + let response = try await cognitoClient.initiateAuth(input: request) + let responseEvent = UserPoolSignInHelper.parseResponse( + response, + for: username, + signInMethod: signInEventData.signInMethod, + presentationAnchor: signInEventData.presentationAnchor, + srpStateData: srpStateData + ) + + logVerbose("\(#fileID) Sending event \(responseEvent)", environment: environment) + await dispatcher.send(responseEvent) + + } catch let error as SignInError { + logVerbose("\(#fileID) Raised error \(error)", environment: environment) + let event = SignInEvent(eventType: .throwAuthError(error)) + await dispatcher.send(event) + } catch { + logVerbose("\(#fileID) Caught error \(error)", environment: environment) + let authError = SignInError.service(error: error) + let event = SignInEvent( + eventType: .throwAuthError(authError) + ) + await dispatcher.send(event) + } + } +} + +extension InitiateUserAuth: DefaultLogger { } + +extension InitiateUserAuth: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "identifier": identifier, + "signInEventData": signInEventData.debugDictionary, + "deviceMetadata": deviceMetadata, + ] + } +} + +extension InitiateUserAuth: CustomDebugStringConvertible { + var debugDescription: String { + debugDictionary.debugDescription + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift index 0beead2f68..34dc973111 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/VerifySignInChallenge.swift @@ -27,33 +27,40 @@ struct VerifySignInChallenge: Action { 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") + try await handleContinueSignInWithMFASetupSelection( + withDispatcher: dispatcher, + environment: environment, + username: username) + return + } else if case .continueSignInWithFirstFactorSelection = currentSignInStep, + let authFactorType = AuthFactorType(rawValue: confirmSignEventData.answer) { + if (authFactorType == .password || authFactorType == .passwordSRP) { + try await handleContinueSignInWithPassword( + withDispatcher: dispatcher, + environment: environment, + username: username, + authFactorType: authFactorType) + return + } else if isWebAuthn(authFactorType) { + let signInData = WebAuthnSignInData( + username: username, + presentationAnchor: confirmSignEventData.presentationAnchor + ) + let event = SignInEvent(eventType: .initiateWebAuthnSignIn(signInData, challenge)) + logVerbose("\(#fileID) Sending event \(event)", environment: environment) + await dispatcher.send(event) + return } - - logVerbose("\(#fileID) Sending event \(event)", environment: environment) - await dispatcher.send(event) + } else if case .confirmSignInWithPassword = currentSignInStep { + try await handleConfirmSignInWithPassword( + withDispatcher: dispatcher, + environment: environment, + username: username) return } + let userpoolEnv = try environment.userPoolEnvironment() let username = challenge.username let session = challenge.session @@ -111,6 +118,107 @@ struct VerifySignInChallenge: Action { } } + func handleConfirmSignInWithPassword( + withDispatcher dispatcher: EventDispatcher, + environment: Environment, + username: String + ) async throws { + + let newDeviceMetadata = await DeviceMetadataHelper.getDeviceMetadata( + for: username, + with: environment) + if challenge.challenge == .password { + + let event = SignInEvent( + eventType: .initiateMigrateAuth( + .init(username: username, + password: confirmSignEventData.answer, + signInMethod: signInMethod), + newDeviceMetadata, + challenge)) + + await dispatcher.send(event) + } else if challenge.challenge == .passwordSrp { + let event = SignInEvent( + eventType: .initiateSignInWithSRP( + .init(username: username, + password: confirmSignEventData.answer, + signInMethod: signInMethod), + newDeviceMetadata, + challenge)) + await dispatcher.send(event) + } else { + throw SignInError.unknown( + message: "confirmSignInWithPassword received an unknown challenge type. Received: \(challenge.challenge)") + } + } + + func handleContinueSignInWithPassword( + withDispatcher dispatcher: EventDispatcher, + environment: Environment, + username: String, + authFactorType: AuthFactorType + ) async throws { + + let authFactorType = AuthFactorType(rawValue: confirmSignEventData.answer) + var challengeType: CognitoIdentityProviderClientTypes.ChallengeNameType? = nil + + if case .password = authFactorType { + challengeType = .password + } else if case .passwordSRP = authFactorType { + challengeType = .passwordSrp + } else if isWebAuthn(authFactorType) { + throw SignInError.unknown( + message: "This code path only supports password and password SRP. Received: \(challenge.challenge)") + } + + guard let challengeType = challengeType else { + throw SignInError.unknown( + message: "Unable to determine challenge type from \(String(describing: authFactorType))") + } + + let newChallenge = RespondToAuthChallenge( + challenge: challengeType, + availableChallenges: [], + username: challenge.username, + session: challenge.session, + parameters: [:]) + + let event = SignInEvent(eventType: .receivedChallenge(newChallenge)) + logVerbose("\(#fileID) Sending event \(event)", environment: environment) + await dispatcher.send(event) + } + + func handleContinueSignInWithMFASetupSelection( + withDispatcher dispatcher: EventDispatcher, + environment: Environment, + username: String + ) async throws { + let newChallenge = RespondToAuthChallenge( + challenge: .mfaSetup, + availableChallenges: [], + 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) + } + func deviceNotFound(error: Error, deviceMetadata: DeviceMetadata) -> Bool { // If deviceMetadata was not send, the error returned is not from device not found. @@ -121,6 +229,14 @@ struct VerifySignInChallenge: Action { return error is AWSCognitoIdentityProvider.ResourceNotFoundException } + private func isWebAuthn(_ factorType: AuthFactorType?) -> Bool { + #if os(iOS) || os(macOS) || os(visionOS) + if #available(iOS 17.4, macOS 13.5, *) { + return .webAuthn == factorType + } + #endif + return false + } } extension VerifySignInChallenge: CustomDebugDictionaryConvertible { diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/AssertWebAuthnCredentials.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/AssertWebAuthnCredentials.swift new file mode 100644 index 0000000000..aed5ffd058 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/AssertWebAuthnCredentials.swift @@ -0,0 +1,99 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) +import Amplify +import Foundation + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +struct AssertWebAuthnCredentials: Action { + let identifier = "AssertWebAuthnCredentials" + let username: String + let options: CredentialAssertionOptions + let respondToAuthChallenge: RespondToAuthChallenge + let presentationAnchor: AuthUIPresentationAnchor? + + private let credentialAsserter: CredentialAsserterProtocol + + init( + username: String, + options: CredentialAssertionOptions, + respondToAuthChallenge: RespondToAuthChallenge, + presentationAnchor: AuthUIPresentationAnchor?, + asserterFactory: (AuthUIPresentationAnchor?) -> CredentialAsserterProtocol = { anchor in + PlatformWebAuthnCredentials(presentationAnchor: anchor) + } + ) { + self.username = username + self.options = options + self.respondToAuthChallenge = respondToAuthChallenge + self.presentationAnchor = presentationAnchor + self.credentialAsserter = asserterFactory(presentationAnchor) + } + + func execute( + withDispatcher dispatcher: EventDispatcher, + environment: Environment + ) async { + logVerbose("\(#fileID) Starting execution", environment: environment) + do { + let payload = try await credentialAsserter.assert(with: options) + let event = WebAuthnEvent(eventType: .verifyCredentialsAndSignIn( + try payload.stringify(), + .init( + username: username, + challenge: respondToAuthChallenge, + presentationAnchor: presentationAnchor + ) + )) + logVerbose("\(#fileID) Sending event \(event)", environment: environment) + await dispatcher.send(event) + } catch { + logVerbose("\(#fileID) Raised error \(error)", environment: environment) + let event = WebAuthnEvent( + eventType: .error(webAuthnError(from: error), respondToAuthChallenge) + ) + await dispatcher.send(event) + } + } + + private func webAuthnError(from error: Error) -> WebAuthnError { + if let webAuthnError = error as? WebAuthnError { + return webAuthnError + } + if let authError = error as? AuthErrorConvertible { + return .service(error: authError.authError) + } + return .unknown( + message: "Unable to assert WebAuthn credentials", + error: error + ) + } +} + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +extension AssertWebAuthnCredentials: DefaultLogger { } + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +extension AssertWebAuthnCredentials: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "identifier": identifier, + "respondToAuthChallenge": respondToAuthChallenge.debugDictionary, + "username": username.masked(), + "options": options.debugDictionary + ] + } +} + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +extension AssertWebAuthnCredentials: CustomDebugStringConvertible { + var debugDescription: String { + debugDictionary.debugDescription + } +} +#endif diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/FetchCredentialOptions.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/FetchCredentialOptions.swift new file mode 100644 index 0000000000..2ea1e468e2 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/FetchCredentialOptions.swift @@ -0,0 +1,108 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) +import Amplify +import Foundation +import AWSCognitoIdentityProvider + +struct FetchCredentialOptions: Action { + let identifier = "FetchCredentialOptions" + let username: String + let respondToAuthChallenge: RespondToAuthChallenge + let presentationAnchor: AuthUIPresentationAnchor? + + func execute(withDispatcher dispatcher: EventDispatcher, + environment: Environment) async { + logVerbose("\(#fileID) Starting execution", environment: environment) + do { + let authEnv = try environment.authEnvironment() + let userPoolEnv = try environment.userPoolEnvironment() + let asfDeviceId = try await CognitoUserPoolASF.asfDeviceID( + for: username, + credentialStoreClient: authEnv.credentialsClient + ) + let deviceMetadata = await DeviceMetadataHelper.getDeviceMetadata( + for: username, + with: environment + ) + let request = await RespondToAuthChallengeInput.webAuthnInput( + username: username, + session: respondToAuthChallenge.session, + asfDeviceId: asfDeviceId, + deviceMetadata: deviceMetadata, + environment: userPoolEnv + ) + + let cognitoClient = try userPoolEnv.cognitoUserPoolFactory() + let response = try await cognitoClient.respondToAuthChallenge(input: request) + guard let credentialOptions = response.challengeParameters?["CREDENTIAL_REQUEST_OPTIONS"], + let challengeName = response.challengeName else { + let message = "Response did not contain SignIn info" + let error = SignInError.invalidServiceResponse(message: message) + let event = SignInEvent(eventType: .throwAuthError(error)) + await dispatcher.send(event) + return + } + + let options = try CredentialAssertionOptions(from: credentialOptions) + let newRespondToAuthChallenge = RespondToAuthChallenge( + challenge: challengeName, + availableChallenges: [], + username: username, + session: response.session, + parameters: response.challengeParameters + ) + let event = WebAuthnEvent( + eventType: .assertCredentials(options, .init( + username: username, + challenge: newRespondToAuthChallenge, + presentationAnchor: presentationAnchor + )) + ) + await dispatcher.send(event) + } catch { + logVerbose("\(#fileID) Caught error \(error)", environment: environment) + let event = WebAuthnEvent( + eventType: .error(webAuthnError(from: error), respondToAuthChallenge) + ) + await dispatcher.send(event) + } + } + + private func webAuthnError(from error: Error) -> WebAuthnError { + if let webAuthnError = error as? WebAuthnError { + return webAuthnError + } + if let authError = error as? AuthErrorConvertible { + return .service(error: authError.authError) + } + return .unknown( + message: "Unable to fetch credential creation options", + error: error + ) + } +} + +extension FetchCredentialOptions: DefaultLogger { } + +extension FetchCredentialOptions: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "identifier": identifier, + "username": username.masked(), + "respondToAuthChallenge": respondToAuthChallenge.debugDictionary + ] + } +} + +extension FetchCredentialOptions: CustomDebugStringConvertible { + var debugDescription: String { + debugDictionary.debugDescription + } +} +#endif diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/InitializeWebAuthn.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/InitializeWebAuthn.swift new file mode 100644 index 0000000000..337b06943c --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/InitializeWebAuthn.swift @@ -0,0 +1,93 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) +import Amplify +import Foundation + +struct InitializeWebAuthn: Action { + let identifier = "InitializeWebAuthn" + let username: String + let respondToAuthChallenge: RespondToAuthChallenge + let presentationAnchor: AuthUIPresentationAnchor? + + func execute( + withDispatcher dispatcher: EventDispatcher, + environment: Environment + ) async { + logVerbose("\(#fileID) Starting execution", environment: environment) + do { + guard let credentialOptions = respondToAuthChallenge.parameters?["CREDENTIAL_REQUEST_OPTIONS"] else { + let event = WebAuthnEvent( + eventType: .fetchCredentialOptions(.init( + username: username, + challenge: respondToAuthChallenge, + presentationAnchor: presentationAnchor + )) + ) + logVerbose("\(#fileID) Sending event \(event)", environment: environment) + await dispatcher.send(event) + return + } + let options = try CredentialAssertionOptions(from: credentialOptions) + let event = WebAuthnEvent( + eventType: .assertCredentials(options, .init( + username: username, + challenge: respondToAuthChallenge, + presentationAnchor: presentationAnchor + )) + ) + logVerbose("\(#fileID) Sending event \(event)", environment: environment) + await dispatcher.send(event) + } catch let error as SignInError { + logVerbose("\(#fileID) Raised error \(error)", environment: environment) + let webAuthnError = WebAuthnError.service(error: error) + let event = WebAuthnEvent( + eventType: .error(webAuthnError, respondToAuthChallenge) + ) + await dispatcher.send(event) + } catch { + logVerbose("\(#fileID) Caught error \(error)", environment: environment) + let event = WebAuthnEvent( + eventType: .error(webAuthnError(from: error), respondToAuthChallenge) + ) + await dispatcher.send(event) + } + } + + private func webAuthnError(from error: Error) -> WebAuthnError { + if let webAuthnError = error as? WebAuthnError { + return webAuthnError + } + if let authError = error as? AuthErrorConvertible { + return .service(error: authError.authError) + } + return .unknown( + message: "Unable to initiate WebAuthn flow", + error: error + ) + } +} + +extension InitializeWebAuthn: DefaultLogger { } + +extension InitializeWebAuthn: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "identifier": identifier, + "username": username.masked(), + "respondToAuthChallenge": respondToAuthChallenge.debugDictionary + ] + } +} + +extension InitializeWebAuthn: CustomDebugStringConvertible { + var debugDescription: String { + debugDictionary.debugDescription + } +} +#endif diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/PlatformWebAuthnCredentials.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/PlatformWebAuthnCredentials.swift new file mode 100644 index 0000000000..12e176ca7b --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/PlatformWebAuthnCredentials.swift @@ -0,0 +1,238 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) +import Amplify +import AuthenticationServices +import Foundation + +protocol WebAuthnCredentialsProtocol { + var presentationAnchor: AuthUIPresentationAnchor? { get } +} + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +protocol CredentialRegistrantProtocol: WebAuthnCredentialsProtocol { + func create(with options: CredentialCreationOptions) async throws -> CredentialRegistrationPayload +} + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +protocol CredentialAsserterProtocol: WebAuthnCredentialsProtocol { + func assert(with options: CredentialAssertionOptions) async throws -> CredentialAssertionPayload +} + +// - MARK: WebAuthnCredentialsProtocol +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +class PlatformWebAuthnCredentials: NSObject, WebAuthnCredentialsProtocol { + private enum OperationType: String { + case assert + case register + } + + let presentationAnchor: AuthUIPresentationAnchor? + private var assertionContinuation: CheckedContinuation? + private var registrationContinuation: CheckedContinuation? + + init(presentationAnchor: AuthUIPresentationAnchor?) { + self.presentationAnchor = presentationAnchor + } +} + +// - MARK: CredentialAsserterProtocol +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +extension PlatformWebAuthnCredentials: CredentialAsserterProtocol { + func assert(with options: CredentialAssertionOptions) async throws -> CredentialAssertionPayload { + guard assertionContinuation == nil else { + throw WebAuthnError.unknown( + message: "There's a WebAuthn assertion already in progress", + error: nil + ) + } + + let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: options.relyingPartyId + ) + + let platformKeyRequest = platformProvider.createCredentialAssertionRequest( + challenge: try options.challenge + ) + + return try await withCheckedThrowingContinuation { continuation in + assertionContinuation = continuation + let authController = ASAuthorizationController( + authorizationRequests: [platformKeyRequest] + ) + authController.delegate = self + authController.presentationContextProvider = self + authController.performRequests() + } + } + + private func resumeAssertionContinuation(with result: CredentialAssertionPayload) { + assertionContinuation?.resume(returning: result) + assertionContinuation = nil + } + + private func resumeAssertionContinuation(throwing error: any Error) { + log.error(error: error) + assertionContinuation?.resume(throwing: error) + assertionContinuation = nil + } + + private func resumeRegistrationContinuation(with result: CredentialRegistrationPayload) { + registrationContinuation?.resume(returning: result) + registrationContinuation = nil + } + + private func resumeRegistrationContinuation(throwing error: any Error) { + log.error(error: error) + registrationContinuation?.resume(throwing: error) + registrationContinuation = nil + } +} + +// - MARK: CredentialRegistrantProtocol +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +extension PlatformWebAuthnCredentials: CredentialRegistrantProtocol { + func create(with options: CredentialCreationOptions) async throws -> CredentialRegistrationPayload { + guard registrationContinuation == nil else { + throw WebAuthnError.unknown( + message: "There's a WebAuthn registration already in progress", + error: nil + ) + } + let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: options.relyingParty.id + ) + + let platformKeyRequest = platformProvider.createCredentialRegistrationRequest( + challenge: options.challenge, + name: options.user.name, + userID: options.user.id + ) + platformKeyRequest.excludedCredentials = options.excludeCredentials.compactMap { credential in + return .init(credentialID: credential.id) + } + + return try await withCheckedThrowingContinuation { continuation in + registrationContinuation = continuation + let authController = ASAuthorizationController( + authorizationRequests: [platformKeyRequest] + ) + authController.delegate = self + authController.presentationContextProvider = self + authController.performRequests() + } + } +} + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +extension PlatformWebAuthnCredentials: DefaultLogger {} + +// - MARK: ASAuthorizationControllerDelegate +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +extension PlatformWebAuthnCredentials: ASAuthorizationControllerDelegate { + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + switch authorization.credential { + case let assertionCredential as ASAuthorizationPlatformPublicKeyCredentialAssertion: + do { + try resumeAssertionContinuation(with: .init(from: assertionCredential)) + } catch { + resumeAssertionContinuation(throwing: error) + } + case let registrationCredential as ASAuthorizationPublicKeyCredentialRegistration: + do { + try resumeRegistrationContinuation(with: .init(from: registrationCredential)) + } catch { + resumeRegistrationContinuation(throwing: error) + } + default: + log.verbose("Unexpected type of credential: \(type(of: authorization.credential)).") + handleUnexpectedResult(for: controller, throwing: nil) + } + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: any Error + ) { + log.error(error: error) + guard let operationType = operationType(for: controller) else { + // In the extremely unlikely scenario in which this happens, resume all continuations to prevent blocking the app + resumeAssertionContinuation(throwing: AuthError.unknown("Unable to assert WebAuthm Credential", error)) + resumeRegistrationContinuation(throwing: AuthError.unknown("Unable to register WebAuthm Credential", error)) + return + } + + guard let authorizationError = error as? ASAuthorizationError else { + handleUnexpectedResult(for: operationType, throwing: error) + return + } + + switch operationType { + case .assert: + log.verbose("Unable to assert existing credential") + resumeAssertionContinuation( + throwing: WebAuthnError.assertionFailed(error: authorizationError) + ) + case .register: + log.verbose("Unable to register new credential") + resumeRegistrationContinuation( + throwing: WebAuthnError.creationFailed(error: authorizationError) + ) + } + } + + private func operationType(for controller: ASAuthorizationController) -> OperationType? { + for request in controller.authorizationRequests { + if request is ASAuthorizationPlatformPublicKeyCredentialAssertionRequest { + return .assert + } + if request is ASAuthorizationPublicKeyCredentialRegistrationRequest { + return .register + } + } + + return nil + } + + private func handleUnexpectedResult( + for controller: ASAuthorizationController, + throwing error: (any Error)? + ) { + if let operationType = operationType(for: controller) { + handleUnexpectedResult(for: operationType, throwing: error) + } + } + + private func handleUnexpectedResult( + for operationType: OperationType, + throwing error: (any Error)? + ) { + switch operationType { + case .assert: + resumeAssertionContinuation( + throwing: AuthError.unknown("Unable to assert WebAuthm Credential", error) + ) + case .register: + resumeRegistrationContinuation( + throwing: AuthError.unknown("Unable to register WebAuthm Credential", error) + ) + } + } +} + +// - MARK: ASAuthorizationControllerPresentationContextProviding +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +extension PlatformWebAuthnCredentials: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + return presentationAnchor ?? ASPresentationAnchor() + } +} +#endif diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/VerifyWebAuthnCredential.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/VerifyWebAuthnCredential.swift new file mode 100644 index 0000000000..525427fdbe --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignIn/WebAuthn/VerifyWebAuthnCredential.swift @@ -0,0 +1,116 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) +import Amplify +import AWSCognitoIdentityProvider +import Foundation + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +struct VerifyWebAuthnCredential: Action { + let identifier = "VerifyWebAuthnCredential" + let username: String + let credentials: String + let respondToAuthChallenge: RespondToAuthChallenge + + func execute( + withDispatcher dispatcher: EventDispatcher, + environment: Environment + ) async { + logVerbose("\(#fileID) Starting execution", environment: environment) + do { + let authEnv = try environment.authEnvironment() + let userPoolEnv = try environment.userPoolEnvironment() + let asfDeviceId = try await CognitoUserPoolASF.asfDeviceID( + for: username, + credentialStoreClient: authEnv.credentialsClient + ) + let request = await RespondToAuthChallengeInput.verifyWebauthCredential( + username: username, + credential: credentials, + session: respondToAuthChallenge.session, + asfDeviceId: asfDeviceId, + environment: userPoolEnv + ) + + let cognitoClient = try userPoolEnv.cognitoUserPoolFactory() + let response = try await cognitoClient.respondToAuthChallenge(input: request) + + guard let authenticationResult = response.authenticationResult, + let idToken = authenticationResult.idToken, + let accessToken = authenticationResult.accessToken, + let refreshToken = authenticationResult.refreshToken else { + let message = "Response did not contain SignIn info" + let error = SignInError.invalidServiceResponse(message: message) + let event = SignInEvent(eventType: .throwAuthError(error)) + await dispatcher.send(event) + return + } + let userPoolTokens = AWSCognitoUserPoolTokens( + idToken: idToken, + accessToken: accessToken, + refreshToken: refreshToken + ) + let signedInData = SignedInData( + signedInDate: Date(), + signInMethod: .apiBased( + .userAuth(preferredFirstFactor: .webAuthn) + ), + deviceMetadata: authenticationResult.deviceMetadata, + cognitoUserPoolTokens: userPoolTokens + ) + let event = WebAuthnEvent( + eventType: .signedIn(signedInData) + ) + logVerbose("\(#fileID) Sending event \(event)", environment: environment) + await dispatcher.send(event) + } catch { + logVerbose("\(#fileID) Caught error \(error)", environment: environment) + let event = WebAuthnEvent( + eventType: .error(webAuthnError(from: error), respondToAuthChallenge) + ) + await dispatcher.send(event) + } + } + + private func webAuthnError(from error: Error) -> WebAuthnError { + if let webAuthnError = error as? WebAuthnError { + return webAuthnError + } + if let authError = error as? AuthErrorConvertible { + return .service(error: authError.authError) + } + return .unknown( + message: "Unable to verify WebAuthn credential", + error: error + ) + } +} + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +extension VerifyWebAuthnCredential: DefaultLogger { } + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +extension VerifyWebAuthnCredential: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "identifier": identifier, + "username": username.masked(), + "credentials": credentials.masked(), + "respondToAuthChallenge": respondToAuthChallenge.debugDictionary + ] + } +} + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +extension VerifyWebAuthnCredential: CustomDebugStringConvertible { + var debugDescription: String { + debugDictionary.debugDescription + } +} + +#endif diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/AutoSignIn.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/AutoSignIn.swift new file mode 100644 index 0000000000..92289d85cd --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/AutoSignIn.swift @@ -0,0 +1,130 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import Foundation +import AWSCognitoIdentityProvider + +struct AutoSignIn: Action { + + var identifier: String = "AutoSignIn" + let signInEventData: SignInEventData + let deviceMetadata: DeviceMetadata + + func execute(withDispatcher dispatcher: any EventDispatcher, environment: any Environment) async { + do { + let userPoolEnv = try environment.userPoolEnvironment() + let authEnv = try environment.authEnvironment() + + guard let username = signInEventData.username else { + logVerbose("\(#fileID) Unable to extract username from signInEventData", environment: environment) + let authError = SignInError.inputValidation(field: "Unable to extract username") + let event = SignInEvent( + eventType: .throwAuthError(authError) + ) + await dispatcher.send(event) + return + } + + var authParameters = [ + "USERNAME": username + ] + + let configuration = userPoolEnv.userPoolConfiguration + let userPoolClientId = configuration.clientId + + if let clientSecret = configuration.clientSecret { + let clientSecretHash = ClientSecretHelper.clientSecretHash( + username: username, + userPoolClientId: userPoolClientId, + clientSecret: clientSecret + ) + authParameters["SECRET_HASH"] = clientSecretHash + } + + if case .metadata(let data) = deviceMetadata { + authParameters["DEVICE_KEY"] = data.deviceKey + } + + let asfDeviceId = try await CognitoUserPoolASF.asfDeviceID( + for: username, + credentialStoreClient: authEnv.credentialsClient) + + var userContextData: CognitoIdentityProviderClientTypes.UserContextDataType? + if let encodedData = await CognitoUserPoolASF.encodedContext( + username: username, + asfDeviceId: asfDeviceId, + asfClient: userPoolEnv.cognitoUserPoolASFFactory(), + userPoolConfiguration: configuration) { + userContextData = .init(encodedData: encodedData) + } + let analyticsMetadata = userPoolEnv + .cognitoUserPoolAnalyticsHandlerFactory() + .analyticsMetadata() + + let request = InitiateAuthInput( + analyticsMetadata: analyticsMetadata, + authFlow: .userAuth, + authParameters: authParameters, + clientId: userPoolClientId, + clientMetadata: signInEventData.clientMetadata, + session: signInEventData.session, + userContextData: userContextData + ) + + let responseEvent = try await sendRequest( + request: request, + username: username, + environment: userPoolEnv) + logVerbose("\(#fileID) Sending event \(responseEvent)", environment: environment) + await dispatcher.send(responseEvent) + + } catch let error as SignInError { + logVerbose("\(#fileID) Raised error \(error)", environment: environment) + let event = SignInEvent(eventType: .throwAuthError(error)) + await dispatcher.send(event) + } catch { + logVerbose("\(#fileID) Caught error \(error)", environment: environment) + let authError = SignInError.service(error: error) + let event = SignInEvent( + eventType: .throwAuthError(authError) + ) + await dispatcher.send(event) + } + } + + private func sendRequest(request: InitiateAuthInput, + username: String, + environment: UserPoolEnvironment) async throws -> StateMachineEvent { + + let cognitoClient = try environment.cognitoUserPoolFactory() + logVerbose("\(#fileID) Starting execution", environment: environment) + + let response = try await cognitoClient.initiateAuth(input: request) + return UserPoolSignInHelper.parseResponse( + response, + for: username, + signInMethod: signInEventData.signInMethod, + presentationAnchor: signInEventData.presentationAnchor + ) + } +} + +extension AutoSignIn: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "identifier": identifier, + "signInEventData": signInEventData.debugDictionary + ] + } +} + +extension AutoSignIn: CustomDebugStringConvertible { + var debugDescription: String { + debugDictionary.debugDescription + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/ConfirmSignUp.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/ConfirmSignUp.swift new file mode 100644 index 0000000000..44d289bce6 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/ConfirmSignUp.swift @@ -0,0 +1,78 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import Foundation +import AWSCognitoIdentityProvider + +struct ConfirmSignUp: Action { + + var identifier: String = "ConfirmSignUp" + let data: SignUpEventData + let confirmationCode: String + let forceAliasCreation: Bool? + + func execute(withDispatcher dispatcher: any EventDispatcher, environment: any Environment) async { + do { + let authEnvironment = try environment.authEnvironment() + let userPoolEnvironment = authEnvironment.userPoolEnvironment + let asfDeviceId = try await CognitoUserPoolASF.asfDeviceID( + for: data.username, + credentialStoreClient: authEnvironment.credentialsClient) + let client = try userPoolEnvironment.cognitoUserPoolFactory() + let input = await ConfirmSignUpInput(username: data.username, + confirmationCode: confirmationCode, + clientMetadata: data.clientMetadata, + asfDeviceId: asfDeviceId, + forceAliasCreation: forceAliasCreation, + session: data.session, + environment: userPoolEnvironment) + let response = try await client.confirmSignUp(input: input) + let dataToSend = SignUpEventData( + username: data.username, + clientMetadata: data.clientMetadata, + validationData: data.validationData, + session: response.session + ) + logVerbose("\(#fileID) ConfirmSignUp response succcess", environment: environment) + + if let session = response.session { + await dispatcher.send(SignUpEvent(eventType: .signedUp(dataToSend, .init(.completeAutoSignIn(session))))) + } else { + await dispatcher.send(SignUpEvent(eventType: .signedUp(dataToSend, .init(.done)))) + } + } catch let error as SignUpError { + let errorEvent = SignUpEvent(eventType: .throwAuthError(error)) + logVerbose("\(#fileID) Sending event \(errorEvent)", + environment: environment) + await dispatcher.send(errorEvent) + } catch { + let error = SignUpError.service(error: error) + let errorEvent = SignUpEvent(eventType: .throwAuthError(error)) + logVerbose("\(#fileID) Sending event \(errorEvent)", + environment: environment) + await dispatcher.send(errorEvent) + } + } +} + +extension ConfirmSignUp: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "identifier": identifier, + "signUpEventData": data.debugDictionary, + "confirmationCode": confirmationCode.masked(), + "forceAliasCreation": forceAliasCreation + ] + } +} + +extension ConfirmSignUp: CustomDebugStringConvertible { + var debugDescription: String { + debugDictionary.debugDescription + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/InitiateSignUp.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/InitiateSignUp.swift new file mode 100644 index 0000000000..67693df60f --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Actions/SignUp/InitiateSignUp.swift @@ -0,0 +1,94 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import Foundation +import AWSCognitoIdentityProvider + +struct InitiateSignUp: Action { + + var identifier: String = "InitiateSignUp" + let data: SignUpEventData + let password: String? + let attributes: [AuthUserAttribute]? + + func execute( + withDispatcher dispatcher: any EventDispatcher, + environment: any Environment + ) async { + logVerbose("\(#fileID) Starting execution", environment: environment) + do { + let authEnvironment = try environment.authEnvironment() + let userPoolEnvironment = authEnvironment.userPoolEnvironment + let client = try userPoolEnvironment.cognitoUserPoolFactory() + let asfDeviceId = try await CognitoUserPoolASF.asfDeviceID( + for: data.username, + credentialStoreClient: authEnvironment.credentialsClient) + let attributes = attributes?.reduce( + into: [String: String]()) { + $0[$1.key.rawValue] = $1.value + } ?? [:] + let input = await SignUpInput( + username: data.username, + password: password, + clientMetadata: data.clientMetadata, + validationData: data.validationData, + attributes: attributes, + asfDeviceId: asfDeviceId, + environment: userPoolEnvironment + ) + + let response = try await client.signUp(input: input) + let dataToSend = SignUpEventData( + username: data.username, + clientMetadata: data.clientMetadata, + validationData: data.validationData, + session: response.session + ) + logVerbose("\(#fileID) SignUp response succcess", environment: environment) + let event: SignUpEvent + if response.authResponse.isSignUpComplete { + if let session = response.session { + event = SignUpEvent(eventType: .signedUp(dataToSend, .init(.completeAutoSignIn(session)))) + } else { + event = SignUpEvent(eventType: .signedUp(dataToSend, response.authResponse)) + } + } else { + event = SignUpEvent(eventType: .initiateSignUpComplete(dataToSend, response.authResponse)) + } + await dispatcher.send(event) + } catch let error as SignUpError { + let errorEvent = SignUpEvent(eventType: .throwAuthError(error)) + logVerbose("\(#fileID) Sending event \(errorEvent)", + environment: environment) + await dispatcher.send(errorEvent) + } catch { + let error = SignUpError.service(error: error) + let errorEvent = SignUpEvent(eventType: .throwAuthError(error)) + logVerbose("\(#fileID) Sending event \(errorEvent)", + environment: environment) + await dispatcher.send(errorEvent) + } + } + +} + +extension InitiateSignUp: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "identifier": identifier, + "signUpEventData": data.debugDictionary, + "attributes": attributes + ] + } +} + +extension InitiateSignUp: CustomDebugStringConvertible { + var debugDescription: String { + debugDictionary.debugDescription + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift index 503c9b3d4c..3e7dc8628c 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+ClientBehavior.swift @@ -13,13 +13,13 @@ import AuthenticationServices extension AWSCognitoAuthPlugin: AuthCategoryBehavior { public func signUp(username: String, - password: String?, + password: String? = nil, options: AuthSignUpRequest.Options?) async throws -> AuthSignUpResult { let options = options ?? AuthSignUpRequest.Options() let request = AuthSignUpRequest(username: username, password: password, options: options) - let task = AWSAuthSignUpTask(request, authEnvironment: authEnvironment) + let task = AWSAuthSignUpTask(request, authStateMachine: authStateMachine, authEnvironment: authEnvironment) return try await taskQueue.sync { return try await task.value } as! AuthSignUpResult @@ -33,7 +33,7 @@ extension AWSCognitoAuthPlugin: AuthCategoryBehavior { let request = AuthConfirmSignUpRequest(username: username, code: confirmationCode, options: options) - let task = AWSAuthConfirmSignUpTask(request, authEnvironment: authEnvironment) + let task = AWSAuthConfirmSignUpTask(request, authStateMachine: authStateMachine, authEnvironment: authEnvironment) return try await taskQueue.sync { return try await task.value } as! AuthSignUpResult @@ -158,6 +158,17 @@ extension AWSCognitoAuthPlugin: AuthCategoryBehavior { return try await task.value } as! AuthSignInResult } + + public func autoSignIn() async throws -> AuthSignInResult { + let options = AuthAutoSignInRequest.Options() + let request = AuthAutoSignInRequest(options: options) + let task = AWSAuthAutoSignInTask(request, + authStateMachine: self.authStateMachine, + authEnvironment: authEnvironment) + return try await taskQueue.sync { + return try await task.value + } as! AuthSignInResult + } public func deleteUser() async throws { let task = AWSAuthDeleteUserTask( diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+WebAuthnBehaviour.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+WebAuthnBehaviour.swift new file mode 100644 index 0000000000..674c995070 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/ClientBehavior/AWSCognitoAuthPlugin+WebAuthnBehaviour.swift @@ -0,0 +1,93 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import AuthenticationServices + +extension AWSCognitoAuthPlugin: AuthCategoryWebAuthnBehaviour { +#if os(iOS) || os(macOS) + @available(iOS 17.4, macOS 13.5, *) + public func associateWebAuthnCredential( + presentationAnchor: AuthUIPresentationAnchor? = nil, + options: AuthAssociateWebAuthnCredentialRequest.Options? = nil + ) async throws { + let request = AuthAssociateWebAuthnCredentialRequest( + presentationAnchor: presentationAnchor, + options: options ?? .init() + ) + let task = AssociateWebAuthnCredentialTask( + request: request, + authStateMachine: authStateMachine, + userPoolFactory: authEnvironment.cognitoUserPoolFactory + ) + + _ = try await taskQueue.sync { + try await task.value + } + } +#elseif os(visionOS) + public func associateWebAuthnCredential( + presentationAnchor: AuthUIPresentationAnchor, + options: AuthAssociateWebAuthnCredentialRequest.Options? = nil + ) async throws { + let request = AuthAssociateWebAuthnCredentialRequest( + presentationAnchor: presentationAnchor, + options: options ?? .init() + ) + let task = AssociateWebAuthnCredentialTask( + request: request, + authStateMachine: authStateMachine, + userPoolFactory: authEnvironment.cognitoUserPoolFactory + ) + + _ = try await taskQueue.sync { + try await task.value + } + } +#endif + + public func listWebAuthnCredentials( + options: AuthListWebAuthnCredentialsRequest.Options? = nil + ) async throws -> AuthListWebAuthnCredentialsResult { + let request = AuthListWebAuthnCredentialsRequest( + options: options ?? .init() + ) + let task = ListWebAuthnCredentialsTask ( + request: request, + authStateMachine: authStateMachine, + userPoolFactory: authEnvironment.cognitoUserPoolFactory + ) + + let result = try await taskQueue.sync { + try await task.value + } + + guard let credentials = result as? AuthListWebAuthnCredentialsResult else { + throw AuthError.unknown("Unable to create AuthListWebAuthnCredentialsResult from the result", nil) + } + return credentials + } + + public func deleteWebAuthnCredential( + credentialId: String, + options: AuthDeleteWebAuthnCredentialRequest.Options? = nil + ) async throws { + let request = AuthDeleteWebAuthnCredentialRequest( + credentialId: credentialId, + options: options ?? .init() + ) + let task = DeleteWebAuthnCredentialTask( + request: request, + authStateMachine: authStateMachine, + userPoolFactory: authEnvironment.cognitoUserPoolFactory + ) + + _ = try await taskQueue.sync { + try await task.value + } + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/CredentialStorage/AWSCognitoAuthCredentialStore.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/CredentialStorage/AWSCognitoAuthCredentialStore.swift index 28654896ea..1e8b1f77f5 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/CredentialStorage/AWSCognitoAuthCredentialStore.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/CredentialStorage/AWSCognitoAuthCredentialStore.swift @@ -63,8 +63,7 @@ struct AWSCognitoAuthCredentialStore { /// - Old Identity Pool Config == New Identity Pool Config if oldUserPoolConfiguration == nil && newIdentityConfigData != nil && - oldIdentityPoolConfiguration == newIdentityConfigData - { + oldIdentityPoolConfiguration == newIdentityConfigData { // retrieve data from the old namespace and save with the new namespace if let oldCognitoCredentialsData = try? keychain._getData(oldNameSpace) { try? keychain._set(oldCognitoCredentialsData, key: newNameSpace) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/HubEvents/AuthHubEventHandler.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/HubEvents/AuthHubEventHandler.swift index c8248e156f..34f7de1dca 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/HubEvents/AuthHubEventHandler.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/HubEvents/AuthHubEventHandler.swift @@ -52,7 +52,7 @@ class AuthHubEventHandler: AuthHubEventBehavior { } self?.handleSignInEvent(result) - #if os(iOS) || os(macOS) + #if os(iOS) || os(macOS) || os(visionOS) case HubPayload.EventName.Auth.webUISignInAPI: guard let event = payload.data as? AWSAuthWebUISignInTask.AmplifyAuthTaskResult, case let .success(result) = event else { diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AWSCognitoWebAuthnCredential.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AWSCognitoWebAuthnCredential.swift new file mode 100644 index 0000000000..ac328f45c4 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AWSCognitoWebAuthnCredential.swift @@ -0,0 +1,28 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import Foundation + +public struct AWSCognitoWebAuthnCredential: AuthWebAuthnCredential { + public let credentialId: String + public let createdAt: Date + public let relyingPartyId: String + public let friendlyName: String? + + init( + credentialId: String, + createdAt: Date, + relyingPartyId: String, + friendlyName: String? = nil + ) { + self.credentialId = credentialId + self.createdAt = createdAt + self.friendlyName = friendlyName + self.relyingPartyId = relyingPartyId + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthChallengeType.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthChallengeType.swift index b0002f27f0..c385f352e0 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthChallengeType.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthChallengeType.swift @@ -16,13 +16,19 @@ enum AuthChallengeType { case newPasswordRequired + case passwordRequired + case totpMFA case selectMFAType case setUpMFA - case emailMFA + case smsOTP + + case emailOTP + + case selectAuthFactor case unknown(CognitoIdentityProviderClientTypes.ChallengeNameType) @@ -35,6 +41,8 @@ extension CognitoIdentityProviderClientTypes.ChallengeNameType: Codable { return .customChallenge case .newPasswordRequired: return .newPasswordRequired + case .password, .passwordSrp: + return .passwordRequired case .smsMfa: return .smsMfa case .softwareTokenMfa: @@ -44,7 +52,13 @@ extension CognitoIdentityProviderClientTypes.ChallengeNameType: Codable { case .mfaSetup: return .setUpMFA case .emailOtp: - return .emailMFA + return .emailOTP + case .smsOtp: + return .smsOTP + case .selectChallenge: + return .selectAuthFactor + case .webAuthn: + fatalError("implement me!") default: return .unknown(self) } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthFactorTypeExtension.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthFactorTypeExtension.swift new file mode 100644 index 0000000000..9b32f0a2af --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthFactorTypeExtension.swift @@ -0,0 +1,53 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify + +extension AuthFactorType: DefaultLogger { + + internal init?(rawValue: String) { + switch rawValue { + case "PASSWORD": self = .password + case "PASSWORD_SRP": self = .passwordSRP + case "SMS_OTP": self = .smsOTP + case "EMAIL_OTP": self = .emailOTP + case "WEB_AUTHN": + #if os(iOS) || os(macOS) || os(visionOS) + if #available(iOS 17.4, macOS 13.5, *) { + self = .webAuthn + } else { + Self.log.error("WEB_AUTHN is not supported in this OS version.") + return nil + } + #else + Self.log.error("WEB_AUTHN is only available in iOS and macOS.") + return nil + #endif + default: + Self.log.error("Tried to initialize an unsupported MFA type with value: \(rawValue)") + return nil + } + } + + /// String value of Auth Factor Type + public var rawValue: String { + return challengeResponse + } + + /// String value to be used as an input parameter for confirmSignIn API + public var challengeResponse: String { + switch self { + case .passwordSRP: return "PASSWORD_SRP" + case .password: return "PASSWORD" + case .smsOTP: return "SMS_OTP" + case .emailOTP: return "EMAIL_OTP" + #if os(iOS) || os(macOS) || os(visionOS) + case .webAuthn: return "WEB_AUTHN" + #endif + } + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthFlowType.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthFlowType.swift index 7320fe6bef..0229bd5285 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthFlowType.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/AuthFlowType.swift @@ -7,8 +7,9 @@ import Foundation import AWSCognitoIdentityProvider +import Amplify -public enum AuthFlowType: String { +public enum AuthFlowType { /// Authentication flow for the Secure Remote Password (SRP) protocol case userSRP @@ -28,9 +29,99 @@ public enum AuthFlowType: String { /// If a user migration Lambda trigger is set, this flow will invoke the user migration /// Lambda if it doesn't find the user name in the user pool. case userPassword + + /// Authentication flow used for user discovering enabled first factors for a user. + /// - `preferredFirstFactor`: the auth factor type the user should begin signing with if available. If the preferred first factor is not available, the flow would fallback to provide available first factors. + case userAuth(preferredFirstFactor: AuthFactorType?) + + var rawValue: String { + switch self { + case .custom, .customWithSRP, .customWithoutSRP: + return "CUSTOM_AUTH" + case .userSRP: + return "USER_SRP_AUTH" + case .userPassword: + return "USER_PASSWORD_AUTH" + case .userAuth: + return "USER_AUTH" + } + } + + public static var userAuth: AuthFlowType { + return .userAuth(preferredFirstFactor: nil) + } +} + +// MARK: - Equatable Conformance +extension AuthFlowType: Equatable { + public static func ==(lhs: AuthFlowType, rhs: AuthFlowType) -> Bool { + switch (lhs, rhs) { + case (.userSRP, .userSRP), + (.custom, .custom), + (.customWithSRP, .customWithSRP), + (.customWithoutSRP, .customWithoutSRP), + (.userPassword, .userPassword): + return true + case (.userAuth(let lhsFactor), .userAuth(let rhsFactor)): + return lhsFactor == rhsFactor + default: + return false + } + } } -extension AuthFlowType: Codable { } +// MARK: - Codable Conformance +extension AuthFlowType: Codable { + enum CodingKeys: String, CodingKey { + case type + case preferredFirstFactor + } + + // Encoding the enum + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + // Encode the type (raw value) + try container.encode(rawValue, forKey: .type) + + // Handle associated values (for userAuth case) + switch self { + case .userAuth(let preferredFirstFactor): + try container.encode(preferredFirstFactor?.rawValue, forKey: .preferredFirstFactor) + default: + break // For other cases, no associated values to encode + } + } + + // Decoding the enum + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + // Decode the type (raw value) + let type = try container.decode(String.self, forKey: .type) + + // Initialize based on the type + switch type { + case "USER_SRP_AUTH": + self = .userSRP + case "CUSTOM_AUTH": + // Depending on your needs, choose either `.custom`, `.customWithSRP`, or `.customWithoutSRP` + // In this case, we'll default to `.custom` + self = .custom + case "USER_PASSWORD_AUTH": + self = .userPassword + case "USER_AUTH": + let preferredFirstFactorString = try container.decode(String.self, forKey: .preferredFirstFactor) + if let preferredFirstFactor = AuthFactorType(rawValue: preferredFirstFactorString) { + self = .userAuth(preferredFirstFactor: preferredFirstFactor) + } else { + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unable to decode preferredFirstFactor value") + } + default: + throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Invalid AuthFlowType value") + } + } +} extension AuthFlowType { @@ -42,6 +133,8 @@ extension AuthFlowType { return .userSrpAuth case .userPassword: return .userPasswordAuth + case .userAuth: + return .userAuth } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Errors/AWSCognitoAuthError.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Errors/AWSCognitoAuthError.swift index 5e7474fb6e..ffa1315432 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Errors/AWSCognitoAuthError.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/Errors/AWSCognitoAuthError.swift @@ -88,4 +88,25 @@ public enum AWSCognitoAuthError: Error { /// Thrown when a user tries to use a login which is already linked to another account. case resourceConflictException + + /// The WebAuthn credentials don't match an existing request + case webAuthnChallengeNotFound + + /// The client doesn't support WebAuhn authentication + case webAuthnClientMismatch + + /// WebAuthn is not supported on this device + case webAuthnNotSupported + + /// WebAuthn is not enabled + case webAuthnNotEnabled + + /// The device origin is not registered as an allowed origin + case webAuthnOriginNotAllowed + + /// The relying party ID doesn't match + case webAuthnRelyingPartyMismatch + + /// The WebAuthm configuration is missing or incomplete + case webAuthnConfigurationMissing } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFATypeExtension.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFATypeExtension.swift index 3a11701d6d..2a178610c1 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFATypeExtension.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Models/MFATypeExtension.swift @@ -18,7 +18,7 @@ extension MFAType: DefaultLogger { } else if rawValue.caseInsensitiveCompare("EMAIL_OTP") == .orderedSame { self = .email } else { - Self.log.error("Tried to initialize an unsupported MFA type with value: \(rawValue) ") + Self.log.error("Tried to initialize an unsupported MFA type with value: \(rawValue)") return nil } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/ClearFederationOperationHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/ClearFederationOperationHelper.swift index df5c6d46fe..b8220c6430 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/ClearFederationOperationHelper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/ClearFederationOperationHelper.swift @@ -14,7 +14,7 @@ struct ClearFederationOperationHelper { let currentState = await authStateMachine.currentState - guard case .configured(let authNState, let authZState) = currentState else { + guard case .configured(let authNState, let authZState, _) = currentState else { let authError = AuthError.invalidState( "Clearing of federation failed.", AuthPluginErrorConstants.invalidStateError, nil) @@ -38,7 +38,7 @@ struct ClearFederationOperationHelper { await authStateMachine.send(event) let stateSequences = await authStateMachine.listen() for await state in stateSequences { - guard case .configured(let authNState, _) = state else { + guard case .configured(let authNState, _, _) = state else { continue } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/FetchAuthSessionOperationHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/FetchAuthSessionOperationHelper.swift index 98504afe89..8069b50163 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/FetchAuthSessionOperationHelper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/FetchAuthSessionOperationHelper.swift @@ -15,7 +15,7 @@ class FetchAuthSessionOperationHelper { func fetch(_ authStateMachine: AuthStateMachine, forceRefresh: Bool = false) async throws -> AuthSession { let state = await authStateMachine.currentState - guard case .configured(_, let authorizationState) = state else { + guard case .configured(_, let authorizationState, _) = state else { let message = "Auth state machine not in configured state: \(state)" let error = AuthError.invalidState(message, "", nil) throw error @@ -88,7 +88,7 @@ class FetchAuthSessionOperationHelper { let stateSequences = await authStateMachine.listen() log.verbose("Waiting for session to establish") for await state in stateSequences { - guard case .configured(let authenticationState, let authorizationState) = state else { + guard case .configured(let authenticationState, let authorizationState, _) = state else { let message = "Auth state machine not in configured state: \(state)" let error = AuthError.invalidState(message, "", nil) throw error diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/HostedUISignInHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/HostedUISignInHelper.swift index 5b0c35cefe..d6790505fb 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/HostedUISignInHelper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Operations/Helpers/HostedUISignInHelper.swift @@ -42,7 +42,7 @@ struct HostedUISignInHelper: DefaultLogger { let stateSequences = await authStateMachine.listen() log.verbose("Wait for a valid state") for await state in stateSequences { - guard case .configured(let authenticationState, _) = state else { + guard case .configured(let authenticationState, _, _) = state else { continue } switch authenticationState { @@ -82,7 +82,7 @@ struct HostedUISignInHelper: DefaultLogger { log.verbose("Wait for signIn to complete") for await state in stateSequences { guard case .configured(let authNState, - let authZState) = state else { continue } + let authZState, _) = state else { continue } switch authNState { case .signedIn: @@ -140,7 +140,7 @@ struct HostedUISignInHelper: DefaultLogger { log.verbose("Wait for signIn to cancel") let stateSequences = await authStateMachine.listen() for await state in stateSequences { - guard case .configured(let authenticationState, _) = state else { + guard case .configured(let authenticationState, _, _) = state else { continue } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/CognitoUserPoolBehavior.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/CognitoUserPoolBehavior.swift index 9a9b38b049..55efa75c81 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/CognitoUserPoolBehavior.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/CognitoUserPoolBehavior.swift @@ -89,4 +89,68 @@ protocol CognitoUserPoolBehavior { /// Throws SetUserMFAPreferenceOutputError func setUserMFAPreference(input: SetUserMFAPreferenceInput) async throws -> SetUserMFAPreferenceOutput + /// Lists the WebAuthn credentials + /// + /// - Parameter input: A `ListWebAuthnCredentialsInput` that contains the access token + /// - Returns: a `ListWebAuthnCredentialsOutput` that contains the list of WebAuthn credentials + /// - Throws: See __Possible Errors__ bellow. + /// + /// __Possible Errors:__ + /// - `ForbiddenException` : WAF rejected the request based on a web ACL associated with the user pool. + /// - `InternalErrorException` : Amazon Cognito encountered an internal error. + /// - `InvalidParameterException` : Amazon Cognito encountered an invalid parameter. + /// - `NotAuthorizedException` : The user isn't authorized. + func listWebAuthnCredentials(input: ListWebAuthnCredentialsInput) async throws -> ListWebAuthnCredentialsOutput + + /// Deletes a WebAuthn credential. + /// + /// - Parameter input: A `DeleteWebAuthnCredentialInput` that contains the access token and the ID of the credential to delete + /// - Returns: An empty `DeleteWebAuthnCredentialOutput`. + /// - Throws: See __Possible Errors__ bellow. + /// + /// __Possible Errors:__ + /// - `ForbiddenException` : WAF rejected the request based on a web ACL associated with the user pool. + /// - `InternalErrorException` : Amazon Cognito encountered an internal error. + /// - `InvalidParameterException` : Amazon Cognito encountered an invalid parameter. + /// - `NotAuthorizedException` : The user isn't authorized. + /// - `ResourceNotFoundException` : The Amazon Cognito service couldn't find the requested resource. + func deleteWebAuthnCredential(input: DeleteWebAuthnCredentialInput) async throws -> DeleteWebAuthnCredentialOutput + + + /// Starts the registration of a new WebAuthn credential + /// + /// - Parameter input: A `GetWebAuthnRegistrationOptionsInput` that contains the access token + /// - Returns: A `GetWebAuthnRegistrationOptionsOutput` that contains the credential creation options + /// - Throws: See __Possible Errors__ bellow. + /// + /// __Possible Errors:__ + /// - `ForbiddenException` : WAF rejected the request based on a web ACL associated with the user pool. + /// - `InternalErrorException` : Amazon Cognito encountered an internal error. + /// - `InvalidParameterException` : Amazon Cognito encountered an invalid parameter. + /// - `LimitExceededException` : The user has exceeded the limit for a this resource. + /// - `NotAuthorizedException` : The user isn't authorized. + /// - `TooManyRequestsException` : The user has made too many requests for this operation + /// - `WebAuthnConfigurationMissingException` : The user presented passkey credentials from an unregistered device. + /// - `WebAuthnNotEnabledException` : The user selected passkey authentication but it's not enabled. + func startWebAuthnRegistration(input: StartWebAuthnRegistrationInput) async throws -> StartWebAuthnRegistrationOutput + + /// Completes the registration of a WebAuthn credential. + /// + /// - Parameter input: A `VerifyWebAuthnRegistrationResultInput` that contains the access token and the credential to verify + /// - Returns: An empty `VerifyWebAuthnRegistrationResultOutput` + /// - Throws: See __Possible Errors__ bellow. + /// + /// __Possible Errors:__ + /// - `ForbiddenException` : WAF rejected the request based on a web ACL associated with the user pool. + /// - `InternalErrorException` : Amazon Cognito encountered an internal error. + /// - `InvalidParameterException` : Amazon Cognito encountered an invalid parameter. + /// - `NotAuthorizedException` : The user isn't authorized. + /// - `TooManyRequestsException` : The user has made too many requests for this operation. + /// - `WebAuthnChallengeNotFoundException` : Passkey credentials were sent to a challenge that doesn't match an existing request. + /// - `WebAuthnClientMismatchException` : The user attempted to sign in with a passkey with an app client that doesn't support passkey authentication. + /// - `WebAuthnCredentialNotSupportedException` : The user presented passkey credentials from an unsupported device. + /// - `WebAuthnNotEnabledException` : The user selected passkey authentication but it's not enabled. + /// - `WebAuthnOriginNotAllowedException` : The user presented passkey credentials from a device origin that isn't registered as an allowed origin. Registering allowed origins is optional. + /// - `WebAuthnRelyingPartyMismatchException` : The user's passkey didn't have an entry for the current relying party ID. + func completeWebAuthnRegistration(input: CompleteWebAuthnRegistrationInput) async throws -> CompleteWebAuthnRegistrationOutput } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/AWSCongnitoIdentityProvider+AuthErrorConvertible.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/AWSCongnitoIdentityProvider+AuthErrorConvertible.swift index 2328f1da8d..5cb44b18e4 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/AWSCongnitoIdentityProvider+AuthErrorConvertible.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/ErrorMapping/AWSCongnitoIdentityProvider+AuthErrorConvertible.swift @@ -338,6 +338,90 @@ extension AWSCognitoIdentityProvider.EnableSoftwareTokenMFAException: AuthErrorC } } +extension AWSCognitoIdentityProvider.WebAuthnChallengeNotFoundException: AuthErrorConvertible { + var fallbackDescription: String { "The credentials provided don't match an existing request" } + + var authError: AuthError { + .service( + message ?? fallbackDescription, + AuthPluginErrorConstants.webAuthnChallengeNotFound, + AWSCognitoAuthError.webAuthnChallengeNotFound + ) + } +} + +extension AWSCognitoIdentityProvider.WebAuthnClientMismatchException: AuthErrorConvertible { + var fallbackDescription: String { "The App client doesn't support WebAuthn authentication" } + + var authError: AuthError { + .service( + message ?? fallbackDescription, + AuthPluginErrorConstants.webAuthnClientMismatch, + AWSCognitoAuthError.webAuthnClientMismatch + ) + } +} + +extension AWSCognitoIdentityProvider.WebAuthnCredentialNotSupportedException: AuthErrorConvertible { + var fallbackDescription: String { "The device is unsupported" } + + var authError: AuthError { + .service( + message ?? fallbackDescription, + AuthPluginErrorConstants.webAuthnCredentialNotSupported, + AWSCognitoAuthError.webAuthnNotSupported + ) + } +} + +extension AWSCognitoIdentityProvider.WebAuthnNotEnabledException: AuthErrorConvertible { + var fallbackDescription: String { "WebAuthn authentication is not enabled" } + + var authError: AuthError { + .service( + message ?? fallbackDescription, + AuthPluginErrorConstants.webAuthnNotEnabled, + AWSCognitoAuthError.webAuthnNotEnabled + ) + } +} + +extension AWSCognitoIdentityProvider.WebAuthnOriginNotAllowedException: AuthErrorConvertible { + var fallbackDescription: String { "The device origin is not registered as an allowed origin" } + + var authError: AuthError { + .service( + message ?? fallbackDescription, + AuthPluginErrorConstants.webAuthnOriginNotAllowed, + AWSCognitoAuthError.webAuthnOriginNotAllowed + ) + } +} + +extension AWSCognitoIdentityProvider.WebAuthnRelyingPartyMismatchException: AuthErrorConvertible { + var fallbackDescription: String { "The credential does not match the relying party ID" } + + var authError: AuthError { + .service( + message ?? fallbackDescription, + AuthPluginErrorConstants.webAuthnRelyingPartyMismatch, + AWSCognitoAuthError.webAuthnRelyingPartyMismatch + ) + } +} + +extension AWSCognitoIdentityProvider.WebAuthnConfigurationMissingException: AuthErrorConvertible { + var fallbackDescription: String { "The WebAuthm configuration is missing" } + + var authError: AuthError { + .service( + message ?? fallbackDescription, + AuthPluginErrorConstants.webAuthnConfigurationMissing, + AWSCognitoAuthError.webAuthnConfigurationMissing + ) + } +} + extension AWSClientRuntime.UnknownAWSHTTPServiceError: AuthErrorConvertible { var fallbackDescription: String { "" } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/Helpers/SignInResponseBehavior.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/Helpers/SignInResponseBehavior.swift index 5e397ba94f..91cc2c3b7b 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/Helpers/SignInResponseBehavior.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Service/Helpers/SignInResponseBehavior.swift @@ -14,12 +14,21 @@ protocol SignInResponseBehavior { var authenticationResult: CognitoIdentityProviderClientTypes.AuthenticationResultType? { get } /// The challenge name. var challengeName: CognitoIdentityProviderClientTypes.ChallengeNameType? { get } + + /// Available challenges in UserAuth flow. The output is only available in InitiateAuth API's response. + var availableChallenges: [CognitoIdentityProviderClientTypes.ChallengeNameType]? { get } + /// The challenge parameters. var challengeParameters: [Swift.String: Swift.String]? { get } /// The session which should be passed both ways in challenge-response calls to the service. If the caller needs to go through another challenge, they return a session with other challenge parameters. This session should be passed as it is to the next RespondToAuthChallenge API call. var session: Swift.String? { get } } -extension RespondToAuthChallengeOutput: SignInResponseBehavior { } +extension RespondToAuthChallengeOutput: SignInResponseBehavior { + // This is not supported in RespondToAuthChallenge + var availableChallenges: [CognitoIdentityProviderClientTypes.ChallengeNameType]? { + return nil + } +} extension InitiateAuthOutput: SignInResponseBehavior { } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ChallengeNameTypeExtension.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ChallengeNameTypeExtension.swift new file mode 100644 index 0000000000..60e55c81bc --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ChallengeNameTypeExtension.swift @@ -0,0 +1,34 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import AWSCognitoIdentityProvider + +extension CognitoIdentityProviderClientTypes.ChallengeNameType { + var authFactor: AuthFactorType? { + switch self { + case .emailOtp: + return .emailOTP + case .password: + return .password + case .passwordSrp: + return .passwordSRP + case .smsOtp: + return .smsOTP + case .webAuthn: + #if os(iOS) || os(macOS) || os(visionOS) + if #available(iOS 17.4, macOS 13.5, *) { + return .webAuthn + } + #endif + fallthrough + default: + // everything else is not supported as an auth factor + return nil + } + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ConfirmSignInEventData.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ConfirmSignInEventData.swift index 448339c31c..c846179532 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ConfirmSignInEventData.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/ConfirmSignInEventData.swift @@ -5,6 +5,9 @@ // SPDX-License-Identifier: Apache-2.0 // +#if os(iOS) || os(macOS) || os(visionOS) +import typealias Amplify.AuthUIPresentationAnchor +#endif import Foundation struct ConfirmSignInEventData { @@ -13,6 +16,7 @@ struct ConfirmSignInEventData { let attributes: [String: String] let metadata: [String: String]? let friendlyDeviceName: String? + let presentationAnchor: AuthUIPresentationAnchor? } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/RespondToAuthChallenge.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/RespondToAuthChallenge.swift index 70018df3a5..036b21465b 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/RespondToAuthChallenge.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/RespondToAuthChallenge.swift @@ -13,6 +13,8 @@ struct RespondToAuthChallenge: Equatable { let challenge: CognitoIdentityProviderClientTypes.ChallengeNameType + let availableChallenges: [CognitoIdentityProviderClientTypes.ChallengeNameType] + let username: String let session: String? @@ -49,6 +51,10 @@ extension RespondToAuthChallenge { return getMFATypes(forKey: "MFAS_CAN_SETUP") } + var getAllowedAuthFactorsForSelection: Set { + return Set(availableChallenges.compactMap({ $0.authFactor })) + } + /// Helper method to extract MFA types from parameters private func getMFATypes(forKey key: String) -> Set { guard let mfaTypeParameters = parameters?[key], @@ -69,7 +75,7 @@ extension RespondToAuthChallenge { func getChallengeKey() throws -> String { switch challenge { - case .customChallenge, .selectMfaType: return "ANSWER" + case .customChallenge, .selectMfaType, .selectChallenge: return "ANSWER" case .smsMfa: return "SMS_MFA_CODE" case .softwareTokenMfa: return "SOFTWARE_TOKEN_MFA_CODE" case .newPasswordRequired: return "NEW_PASSWORD" @@ -77,6 +83,7 @@ extension RespondToAuthChallenge { // 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" + case .smsOtp: return "SMS_OTP_CODE" 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/Data/SignInEventData.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignInEventData.swift index dad365c91d..e69fd399d1 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignInEventData.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignInEventData.swift @@ -5,6 +5,10 @@ // SPDX-License-Identifier: Apache-2.0 // +#if os(iOS) || os(macOS) || os(visionOS) +import typealias Amplify.AuthUIPresentationAnchor +#endif + struct SignInEventData { let username: String? @@ -14,16 +18,34 @@ struct SignInEventData { let clientMetadata: [String: String] let signInMethod: SignInMethod + + let session: String? + + private(set) var presentationAnchor: AuthUIPresentationAnchor? = nil - init(username: String?, - password: String?, - clientMetadata: [String: String] = [:], - signInMethod: SignInMethod) { + init( + username: String?, + password: String?, + clientMetadata: [String: String] = [:], + signInMethod: SignInMethod, + session: String? = nil, + presentationAnchor: AuthUIPresentationAnchor? = nil + ) { self.username = username self.password = password self.clientMetadata = clientMetadata self.signInMethod = signInMethod + self.session = session + self.presentationAnchor = presentationAnchor + } + + var authFlowType: AuthFlowType? { + if case .apiBased(let authFlowType) = signInMethod { + return authFlowType + } + return nil } + } extension SignInEventData: Equatable { } @@ -34,7 +56,8 @@ extension SignInEventData: CustomDebugDictionaryConvertible { "username": username.masked(), "password": password.redacted(), "clientMetadata": clientMetadata, - "signInMethod": signInMethod + "signInMethod": signInMethod, + "session": session?.redacted() ?? "" ] } } @@ -44,4 +67,8 @@ extension SignInEventData: CustomDebugStringConvertible { } } -extension SignInEventData: Codable { } +extension SignInEventData: Codable { + private enum CodingKeys: String, CodingKey { + case username, password, clientMetadata, signInMethod, session + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignUpEventData.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignUpEventData.swift new file mode 100644 index 0000000000..c32b1c748c --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/SignUpEventData.swift @@ -0,0 +1,47 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// +import Amplify + +struct SignUpEventData { + + let username: String + let clientMetadata: [String: String]? + let validationData: [String: String]? + var session: String? + + init(username: String, + clientMetadata: [String: String]? = nil, + validationData: [String: String]? = nil, + session: String? = nil) { + self.username = username + self.clientMetadata = clientMetadata + self.validationData = validationData + self.session = session + } +} + + +extension SignUpEventData: Equatable { } + +extension SignUpEventData: CustomDebugDictionaryConvertible { + var debugDictionary: [String: Any] { + [ + "username": username.masked(), + "clientMetadata": clientMetadata ?? "", + "validationData": validationData ?? "", + "session": session?.masked() ?? "" + ] + } +} + +extension SignUpEventData: CustomDebugStringConvertible { + var debugDescription: String { + debugDictionary.debugDescription + } +} + +extension SignUpEventData: Codable { } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/WebAuthnSignInData.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/WebAuthnSignInData.swift new file mode 100644 index 0000000000..83f2cae7cd --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Data/WebAuthnSignInData.swift @@ -0,0 +1,18 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) +import typealias Amplify.AuthUIPresentationAnchor +#endif +import Foundation + +struct WebAuthnSignInData { + let username: String + let presentationAnchor: AuthUIPresentationAnchor? +} + +extension WebAuthnSignInData: Equatable {} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Errors/AuthenticationError.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Errors/AuthenticationError.swift index 2c5d4b4caa..190f9cfc89 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Errors/AuthenticationError.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Errors/AuthenticationError.swift @@ -9,7 +9,7 @@ import Amplify enum AuthenticationError: Error { case configuration(message: String) - case service(message: String) + case service(message: String, error: Error?) case unknown(message: String) } @@ -18,37 +18,40 @@ extension AuthenticationError: AuthErrorConvertible { switch self { case .configuration(let message): return .configuration(message, "") - case .service(let message): - return .service(message, "") + case .service(let message, let error): + if let initiateAuthError = error as? AuthErrorConvertible { + return initiateAuthError.authError + } else { + return .service(message, "", error) + } case .unknown(let message): return .unknown(message) } } } -extension AuthenticationError: Equatable {} - extension AuthenticationError: Codable { + enum CodingKeys: CodingKey { case configuration, service, unknown } - + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - + switch self { case .configuration(let message): try container.encode(message, forKey: .configuration) - case .service(let message): + case .service(let message, let error): try container.encode(message, forKey: .service) case .unknown(let message): try container.encode(message, forKey: .unknown) } } - + init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - + guard let key = container.allKeys.first else { throw DecodingError.dataCorrupted( DecodingError.Context( @@ -57,17 +60,32 @@ extension AuthenticationError: Codable { ) ) } - + switch key { case .configuration: let message = try container.decode(String.self, forKey: key) self = .configuration(message: message) case .service: let message = try container.decode(String.self, forKey: key) - self = .service(message: message) + self = .service(message: message, error: nil) case .unknown: let message = try container.decode(String.self, forKey: key) self = .unknown(message: message) } } } + +extension AuthenticationError: Equatable { + static func == (lhs: AuthenticationError, rhs: AuthenticationError) -> Bool { + switch (lhs, rhs) { + case (.configuration(let lhsMessage), .configuration(let rhsMessage)): + return lhsMessage == rhsMessage + case (.service, .service): + return true + case (.unknown, .unknown): + return true + default: + return false + } + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Errors/SignInError.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Errors/SignInError.swift index f5a10c7b4c..0e7626cd82 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Errors/SignInError.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Errors/SignInError.swift @@ -14,6 +14,7 @@ enum SignInError: Error { case invalidServiceResponse(message: String) case calculation(SRPError) case hostedUI(HostedUIError) + case webAuthn(WebAuthnError) case unknown(message: String) } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Errors/WebAuthnError.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Errors/WebAuthnError.swift new file mode 100644 index 0000000000..9f01691311 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Errors/WebAuthnError.swift @@ -0,0 +1,43 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import AuthenticationServices + +enum WebAuthnError: Error { + case assertionFailed(error: ASAuthorizationError) + case creationFailed(error: ASAuthorizationError) + case service(error: AuthErrorConvertible) + case unknown(message: String, error: Error? = nil) +} + +extension WebAuthnError: Equatable { + static func == (lhs: WebAuthnError, rhs: WebAuthnError) -> Bool { + switch (lhs, rhs) { + case (.assertionFailed(let lError), .assertionFailed(let rError)): + return lError == rError + case (.creationFailed(let lError), .creationFailed(let rError)): + return lError == rError + case (.service(let lhsError), .service(let rhsError)): + return areEquals(lhsError.authError, rhsError.authError) + case (.unknown(let lhsMessage, let lhsError), .unknown(let rhsMessage, let rhsError)): + return lhsMessage == rhsMessage && areEquals(lhsError, rhsError) + default: + return false + } + } + + private static func areEquals(_ lhsError: Error?, _ rhsError: Error?) -> Bool { + guard let lhsError, let rhsError else { + return false + } + let left = lhsError as NSError + let right = rhsError as NSError + return left.code == right.code && + left.domain == right.domain + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/AuthenticationEvent.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/AuthenticationEvent.swift index 3372768b4b..9ca1f9723a 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/AuthenticationEvent.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/AuthenticationEvent.swift @@ -7,6 +7,7 @@ import Foundation +typealias InitiateAutoSignIn = Bool struct AuthenticationEvent: StateMachineEvent { enum EventType: Equatable { @@ -32,7 +33,7 @@ struct AuthenticationEvent: StateMachineEvent { case signInCompleted(SignedInData) /// Emitted when a user sign in is requested - case signInRequested(SignInEventData) + case signInRequested(SignInEventData, InitiateAutoSignIn = false) /// Emitted when we should cancel the signIn case cancelSignIn diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInEvent.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInEvent.swift index 35bce207a6..33f4901c90 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInEvent.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignInEvent.swift @@ -7,6 +7,9 @@ import Foundation import AWSCognitoIdentityProvider +#if os(iOS) || os(macOS) || os(visionOS) +import typealias Amplify.AuthUIPresentationAnchor +#endif typealias Username = String typealias Password = String @@ -18,7 +21,7 @@ struct SignInEvent: StateMachineEvent { enum EventType { - case initiateSignInWithSRP(SignInEventData, DeviceMetadata) + case initiateSignInWithSRP(SignInEventData, DeviceMetadata, RespondToAuthChallenge?) case initiateCustomSignIn(SignInEventData, DeviceMetadata) @@ -26,11 +29,17 @@ struct SignInEvent: StateMachineEvent { case initiateHostedUISignIn(HostedUIOptions) - case initiateMigrateAuth(SignInEventData, DeviceMetadata) + case initiateMigrateAuth(SignInEventData, DeviceMetadata, RespondToAuthChallenge?) - case respondPasswordVerifier(SRPStateData, InitiateAuthOutput, ClientMetadata) + case initiateUserAuth(SignInEventData, DeviceMetadata) - case retryRespondPasswordVerifier(SRPStateData, InitiateAuthOutput, ClientMetadata) + case initiateWebAuthnSignIn(WebAuthnSignInData, RespondToAuthChallenge) + + case initiateAutoSignIn(SignInEventData, DeviceMetadata) + + case respondPasswordVerifier(SRPStateData, SignInResponseBehavior, ClientMetadata) + + case retryRespondPasswordVerifier(SRPStateData, SignInResponseBehavior, ClientMetadata) case initiateDeviceSRP(Username, SignInResponseBehavior) @@ -66,7 +75,9 @@ struct SignInEvent: StateMachineEvent { case .initiateCustomSignInWithSRP: return "SignInEvent.initiateCustomSignInWithSRP" case .initiateHostedUISignIn: return "SignInEvent.initiateHostedUISignIn" case .initiateMigrateAuth: return "SignInEvent.initiateMigrateAuth" + case .initiateUserAuth: return "SignInEvent.initiateUserAuth" case .initiateDeviceSRP: return "SignInEvent.initiateDeviceSRP" + case .initiateAutoSignIn: return "SignInEvent.initiateAutoSignIn" case .respondDeviceSRPChallenge: return "SignInEvent.respondDeviceSRPChallenge" case .respondDevicePasswordVerifier: return "SignInEvent.respondDevicePasswordVerifier" case .respondPasswordVerifier: return "SignInEvent.respondPasswordVerifier" @@ -79,6 +90,7 @@ struct SignInEvent: StateMachineEvent { case .verifySMSChallenge: return "SignInEvent.verifySMSChallenge" case .retryRespondPasswordVerifier: return "SignInEvent.retryRespondPasswordVerifier" case .initiateTOTPSetup: return "SignInEvent.initiateTOTPSetup" + case .initiateWebAuthnSignIn: return "SignInEvent.initiateWebAuthnSignIn" } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignUpEvent.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignUpEvent.swift new file mode 100644 index 0000000000..cbed9442ac --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/SignUpEvent.swift @@ -0,0 +1,45 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import AWSCognitoIdentityProvider +import Amplify + +typealias ConfirmationCode = String +typealias ForceAliasCreation = Bool +struct SignUpEvent: StateMachineEvent { + var id: String + var time: Date? + let eventType: EventType + + enum EventType { + case initiateSignUp(SignUpEventData, Password?, [AuthUserAttribute]?) + case initiateSignUpComplete(SignUpEventData, AuthSignUpResult) + case confirmSignUp(SignUpEventData, ConfirmationCode, ForceAliasCreation?) + case signedUp(SignUpEventData, AuthSignUpResult) + case throwAuthError(SignUpError) + } + + init(id: String = UUID().uuidString, + eventType: EventType, + time: Date? = nil) { + self.id = id + self.eventType = eventType + self.time = time + } + + var type: String { + switch eventType { + case .initiateSignUp: return "SignUpEvent.initiateSignUp" + case .initiateSignUpComplete: return "SignUpEvent.initiateSignUpComplete" + case .confirmSignUp: return "SignUpEvent.confirmSignUp" + case .signedUp: return "SignUpEvent.signedUp" + case .throwAuthError: return "SignUpEvent.throwAuthError" + } + } + +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/WebAuthnEvent.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/WebAuthnEvent.swift new file mode 100644 index 0000000000..eb3e5f9a3e --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/Events/WebAuthnEvent.swift @@ -0,0 +1,50 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) +import Foundation +import Amplify + +struct WebAuthnEvent: StateMachineEvent { + + enum EventType: Equatable { + case fetchCredentialOptions(Input) + case assertCredentials(CredentialAssertionOptions, Input) + case verifyCredentialsAndSignIn(String, Input) + case signedIn(SignedInData) + case error(WebAuthnError, RespondToAuthChallenge) + } + + let id: String + let eventType: EventType + let time: Date? + + var type: String { + switch eventType { + case .fetchCredentialOptions: return "WebAuthnEvent.fetchCredentialOptions" + case .assertCredentials: return "WebAuthnEvent.assertCredentials" + case .verifyCredentialsAndSignIn: return "WebAuthnEvent.verifyCredentials" + case .signedIn: return "WebAuthnEvent.signedIn" + case .error: return "WebAuthnEvent.error" + } + } + + init(id: String = UUID().uuidString, + eventType: EventType, + time: Date? = nil) { + self.id = id + self.eventType = eventType + self.time = time + } + + struct Input: Equatable { + let username: String + let challenge: RespondToAuthChallenge + let presentationAnchor: AuthUIPresentationAnchor? + } +} +#endif diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/AuthState.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/AuthState.swift index a0f4fa5d7c..423b51e45b 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/AuthState.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/AuthState.swift @@ -19,7 +19,7 @@ enum AuthState: State { case configuringAuthorization(AuthenticationState, AuthorizationState) - case configured(AuthenticationState, AuthorizationState) + case configured(AuthenticationState, AuthorizationState, SignUpState) } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/AuthState+Debug.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/AuthState+Debug.swift index 56a8c92f92..03d86bdcf8 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/AuthState+Debug.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/AuthState+Debug.swift @@ -26,10 +26,10 @@ extension AuthState: CustomDebugStringConvertible { additionalMetadataDictionary = authenticationState.debugDictionary.merging( authorizationState.debugDictionary, uniquingKeysWith: {$1} ) - case .configured(let authenticationState, let authorizationState): - additionalMetadataDictionary = authenticationState.debugDictionary.merging( - authorizationState.debugDictionary, uniquingKeysWith: {$1} - ) + case .configured(let authenticationState, let authorizationState, let signUpState): + additionalMetadataDictionary = authenticationState.debugDictionary + .merging(authorizationState.debugDictionary, uniquingKeysWith: {$1}) + .merging(signUpState.debugDictionary, uniquingKeysWith: {$1}) } return [type: additionalMetadataDictionary] } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInState+Debug.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInState+Debug.swift index 1cba475ba8..2e50c0f571 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInState+Debug.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignInState+Debug.swift @@ -48,8 +48,16 @@ extension SignInState: CustomDebugDictionaryConvertible { additionalMetadataDictionary = [ "SignInTOTPSetupState": signInTOTPSetupState.debugDictionary, "SignInEventData": signInEventData.debugDictionary] + case .autoSigningIn(let data): + additionalMetadataDictionary = ["SignInData": data.debugDictionary] case .error: additionalMetadataDictionary = [:] + case .signingInWithUserAuth(let signInEventData): + additionalMetadataDictionary = ["signingInWithUserAuth": signInEventData.debugDictionary] + case .signingInWithWebAuthn(let webAuthnState): + additionalMetadataDictionary = [ + "signingInWithWebAuthn": webAuthnState.debugDictionary + ] } return [type: additionalMetadataDictionary] } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignUpState+Debug.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignUpState+Debug.swift new file mode 100644 index 0000000000..719957f1e5 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/SignUpState+Debug.swift @@ -0,0 +1,32 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension SignUpState: CustomDebugDictionaryConvertible { + + var debugDictionary: [String: Any] { + + let additionalMetadataDictionary: [String: Any] + + switch self { + case .notStarted: + additionalMetadataDictionary = [:] + case .initiatingSignUp(let data): + additionalMetadataDictionary = data.debugDictionary + case .awaitingUserConfirmation: + additionalMetadataDictionary = [:] + case .confirmingSignUp(let data): + additionalMetadataDictionary = data.debugDictionary + case .signedUp(let data, _): + additionalMetadataDictionary = data.debugDictionary + case .error(let signUpError): + additionalMetadataDictionary = ["Error": signUpError] + } + return [type: additionalMetadataDictionary] + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/WebAuthnSignInState+Debug.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/WebAuthnSignInState+Debug.swift new file mode 100644 index 0000000000..6def4fa311 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/DebugInfo/WebAuthnSignInState+Debug.swift @@ -0,0 +1,33 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension WebAuthnSignInState: CustomDebugDictionaryConvertible { + + var debugDictionary: [String: Any] { + + let additionalMetadataDictionary: [String: Any] + + switch self { + case .notStarted: + additionalMetadataDictionary = [:] + case .fetchingCredentialOptions: + additionalMetadataDictionary = [:] + case .assertingCredentials: + additionalMetadataDictionary = [:] + case .verifyingCredentialsAndSigningIn: + additionalMetadataDictionary = [:] + case .error(let error, _): + additionalMetadataDictionary = ["Error": error] + case .signedIn(let signedInData): + additionalMetadataDictionary = ["SignedInData": signedInData.debugDictionary] + } + + return [type: additionalMetadataDictionary] + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/MigrateSignInState.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/MigrateSignInState.swift index b9742ad727..af7c3e3956 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/MigrateSignInState.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/MigrateSignInState.swift @@ -25,19 +25,6 @@ extension MigrateSignInState { case .error: return "MigrateSignInState.error" } } - - static func == (lhs: MigrateSignInState, rhs: MigrateSignInState) -> Bool { - switch (lhs, rhs) { - case (.notStarted, .notStarted): - return true - case (.signingIn(let lhsData), .signingIn(let rhsData)): - return lhsData == rhsData - case (.signedIn(let lhsData), .signedIn(let rhsData)): - return lhsData == rhsData - case (.error(let lhsData), .error(let rhsData)): - return lhsData == rhsData - default: - return false - } - } } + +extension MigrateSignInState: Equatable { } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInState.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInState.swift index 5333ee124f..c8cf014626 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInState.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignInState.swift @@ -13,9 +13,12 @@ enum SignInState: State { case signingInWithSRPCustom(SRPSignInState, SignInEventData) case signingInWithCustom(CustomSignInState, SignInEventData) case signingInViaMigrateAuth(MigrateSignInState, SignInEventData) + case signingInWithUserAuth(SignInEventData) + case signingInWithWebAuthn(WebAuthnSignInState) case resolvingChallenge(SignInChallengeState, AuthChallengeType, SignInMethod) case resolvingTOTPSetup(SignInTOTPSetupState, SignInEventData) case signingInWithHostedUI(HostedUISignInState) + case autoSigningIn(SignInEventData) case confirmingDevice case resolvingDeviceSrpa(DeviceSRPState) case signedIn(SignedInData) @@ -32,12 +35,15 @@ extension SignInState { case .signingInWithSRPCustom: return "SignInState.signingInWithSRPCustom" case .signingInWithCustom: return "SignInState.signingInWithCustom" case .signingInViaMigrateAuth: return "SignInState.signingInViaMigrateAuth" + case .signingInWithUserAuth: return "SignInState.signingInWithUserAuth" + case .autoSigningIn: return "SignInState.autoSigningIn" case .resolvingChallenge: return "SignInState.resolvingChallenge" case .resolvingTOTPSetup: return "SignInState.resolvingTOTPSetup" case .confirmingDevice: return "SignInState.confirmingDevice" case .resolvingDeviceSrpa: return "SignInState.resolvingDeviceSrpa" case .signedIn: return "SignInState.signedIn" case .error: return "SignInState.error" + case .signingInWithWebAuthn: return "SignInState.signingInWithWebAuthn" } } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignUpState.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignUpState.swift new file mode 100644 index 0000000000..d76dbc1481 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/SignUpState.swift @@ -0,0 +1,48 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Amplify + +enum SignUpState: State { + case notStarted + case initiatingSignUp(SignUpEventData) + case awaitingUserConfirmation(SignUpEventData, AuthSignUpResult) + case confirmingSignUp(SignUpEventData) + case signedUp(SignUpEventData, AuthSignUpResult) + case error(SignUpError) +} + +extension SignUpState { + + var type: String { + switch self { + case .notStarted: return "SignUpState.notStarted" + case .initiatingSignUp: return "SignUpState.initiatingSignUp" + case .awaitingUserConfirmation: return "SignUpState.awaitingUserConfirmation" + case .confirmingSignUp: return "SignUpState.confirmingSignUp" + case .signedUp: return "SignUpState.signedUp" + case .error: return "SignUpState.error" + } + } +} + +extension SignUpState { + static func == (lhs: SignUpState, rhs: SignUpState) -> Bool { + switch (lhs, rhs) { + case (.notStarted, .notStarted), + (.initiatingSignUp, .initiatingSignUp), + (.awaitingUserConfirmation, .awaitingUserConfirmation), + (.confirmingSignUp, .confirmingSignUp), + (.signedUp, .signedUp), + (.error, .error): + return true + default: return false + } + } + +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/WebAuthnSignInState.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/WebAuthnSignInState.swift new file mode 100644 index 0000000000..52f193d9c7 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/CodeGen/States/WebAuthnSignInState.swift @@ -0,0 +1,30 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +enum WebAuthnSignInState: State { + case notStarted + case fetchingCredentialOptions + case assertingCredentials + case verifyingCredentialsAndSigningIn + case signedIn(SignedInData) + case error(SignInError, RespondToAuthChallenge) +} + +extension WebAuthnSignInState { + var type: String { + switch self { + case .notStarted: return "WebAuthnSignInState.notStarted" + case .fetchingCredentialOptions: return "WebAuthnSignInState.fetchingCredentialOptions" + case .assertingCredentials: return "WebAuthnSignInState.assertingCredentialsWithAuthenticator" + case .verifyingCredentialsAndSigningIn: return "WebAuthnSignInState.verifyingCredentials" + case .signedIn: return "WebAuthnSignInState.signedIn" + case .error: return "WebAuthnSignInState.error" + } + } +} + +extension WebAuthnSignInState: Equatable { } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/ErrorMapping/SignInError+Helper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/ErrorMapping/SignInError+Helper.swift index a6e21bced9..6981e3f6d0 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/ErrorMapping/SignInError+Helper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/ErrorMapping/SignInError+Helper.swift @@ -55,6 +55,8 @@ extension SignInError: AuthErrorConvertible { return .unknown(message, nil) case .hostedUI(let error): return error.authError + case .webAuthn(let error): + return error.authError } } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/ErrorMapping/WebAuthnError+AuthConvertible.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/ErrorMapping/WebAuthnError+AuthConvertible.swift new file mode 100644 index 0000000000..5cf0a1d330 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/ErrorMapping/WebAuthnError+AuthConvertible.swift @@ -0,0 +1,69 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import AuthenticationServices +import Foundation + +extension WebAuthnError: AuthErrorConvertible { + var authError: AuthError { + switch self { + case .assertionFailed(error: let error): + let errorString: AuthPluginErrorString + if error.code == .canceled { + errorString = AuthPluginErrorConstants.signInWithWebAuthnUserCancelledError + } else { + errorString = AuthPluginErrorConstants.signInWithWebAuthnAssertionFailedError + } + + return .service( + errorString.errorDescription, + errorString.recoverySuggestion, + error + ) + case .creationFailed(error: let error): + let errorString: AuthPluginErrorString + if case .canceled = error.code { + errorString = AuthPluginErrorConstants.associateWebAuthnCredentialUserCancelledError + } else if isMatchedExcludedCredential(error.code) { + errorString = AuthPluginErrorConstants.associateWebAuthnCredentialAlreadyExistError + } else { + errorString = AuthPluginErrorConstants.associateWebAuthnCreationFailedError + } + + return .service( + errorString.errorDescription, + errorString.recoverySuggestion, + error + ) + case .service(let error): + return error.authError + case .unknown(let message, let error): + return .service( + "An unknown error type was thrown by the service. \(message).", + """ + This should not happen. There is a possibility that there is a bug if this error persists. + Please take a look at https://github.com/aws-amplify/amplify-swift/issues to see if there are any + existing issues that match your scenario, and file an issue with the details of the bug if there isn't. + """, + error + ) + } + } + + private func isMatchedExcludedCredential(_ code: ASAuthorizationError.Code) -> Bool { + // ASAuthorizationError.matchedExcludedCredential is only defined in iOS 18/macOS 15, + // This check doesn't work correctly without these runtimes installed. + // Until we require Xcode 16, we'll just use its rawValue + // if #available(iOS 18.0, macOS 15.0, *) { + // return code == .matchedExcludedCredential + // } else { + // return code.rawValue == 1006 + // } + return code.rawValue == 1006 + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/AuthState/AuthState+Resolver.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/AuthState/AuthState+Resolver.swift index d4f9e98c6c..96ec4aecd3 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/AuthState/AuthState+Resolver.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/AuthState/AuthState+Resolver.swift @@ -76,10 +76,10 @@ extension AuthState { return .init(newState: newState, actions: authNresolution.actions + authZresolution.actions) } - let newState = AuthState.configured(authNresolution.newState, authZresolution.newState) + let newState = AuthState.configured(authNresolution.newState, authZresolution.newState, .notStarted) return .init(newState: newState, actions: authNresolution.actions + authZresolution.actions) - case .configured(let authenticationState, let authorizationState): + case .configured(let authenticationState, let authorizationState, let signUpState): if case .reconfigure(let authConfiguration) = isAuthEvent(event)?.eventType { let newState = AuthState.configuringAuth let action = InitializeAuthConfiguration(authConfiguration: authConfiguration) @@ -87,10 +87,12 @@ extension AuthState { } let authenticationResolver = AuthenticationState.Resolver() let authorizationResolver = AuthorizationState.Resolver() + let signUpResolver = SignUpState.Resolver() let authNresolution = authenticationResolver.resolve(oldState: authenticationState, byApplying: event) let authZresolution = authorizationResolver.resolve(oldState: authorizationState, byApplying: event) - let newState = AuthState.configured(authNresolution.newState, authZresolution.newState) - return .init(newState: newState, actions: authNresolution.actions + authZresolution.actions) + let signUpResolution = signUpResolver.resolve(oldState: signUpState, byApplying: event) + let newState = AuthState.configured(authNresolution.newState, authZresolution.newState, signUpResolution.newState) + return .init(newState: newState, actions: authNresolution.actions + authZresolution.actions + signUpResolution.actions) } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/Authentication/AuthenticationState+Resolver.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/Authentication/AuthenticationState+Resolver.swift index d7703d9482..20273e5b2c 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/Authentication/AuthenticationState+Resolver.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/Authentication/AuthenticationState+Resolver.swift @@ -86,11 +86,11 @@ extension AuthenticationState { return .init(newState: .federatedToIdentityPool) case .throwError(let authZError): let authNError = AuthenticationError.service( - message: "Authorization error: \(authZError)") + message: "Authorization error: \(authZError)", error: authZError) return .init(newState: .error(authNError)) case .receivedSessionError(let sessionError): let authNError = AuthenticationError.service( - message: "Session error: \(sessionError)") + message: "Session error: \(sessionError)", error: sessionError) return .init(newState: .error(authNError)) default: return .from(oldState) @@ -164,6 +164,10 @@ extension AuthenticationState { return .from(.signedOut(signedOutData)) case .initializedFederated: return .from(.federatedToIdentityPool) + /// for auto signing up case from sign up + case .signInRequested(let signInData, let autoSignIn): + let action = InitializeSignInFlow(signInEventData: signInData, autoSignIn: autoSignIn) + return .init(newState: .signingIn(.notStarted), actions: [action]) default: return .from(.configured) } @@ -174,8 +178,8 @@ extension AuthenticationState { to currentSignedOutData: SignedOutData ) -> StateResolution { switch authEvent.eventType { - case .signInRequested(let signInData): - let action = InitializeSignInFlow(signInEventData: signInData) + case .signInRequested(let signInData, let autoSignIn): + let action = InitializeSignInFlow(signInEventData: signInData, autoSignIn: autoSignIn) return .init(newState: .signingIn(.notStarted), actions: [action]) case .signOutRequested(let eventData): let action = InitiateGuestSignOut(signOutEventData: eventData) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/MigrateAuth/MigrateSignInState+Resolver.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/MigrateAuth/MigrateSignInState+Resolver.swift index 21e4ef5f70..6fde977d7b 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/MigrateAuth/MigrateSignInState+Resolver.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/MigrateAuth/MigrateSignInState+Resolver.swift @@ -41,7 +41,7 @@ extension MigrateSignInState { private func resolveNotStarted(byApplying signInEvent: SignInEvent) -> StateResolution { switch signInEvent.eventType { - case .initiateMigrateAuth(let signInEventData, let deviceMetadata): + case .initiateMigrateAuth(let signInEventData, let deviceMetadata, let respondToAuthChallenge): guard let username = signInEventData.username, !username.isEmpty else { let error = SignInError.inputValidation( field: AuthPluginErrorConstants.signInUsernameError.field @@ -59,7 +59,8 @@ extension MigrateSignInState { username: username, password: password, clientMetadata: signInEventData.clientMetadata, - deviceMetadata: deviceMetadata) + deviceMetadata: deviceMetadata, + respondToAuthChallenge: respondToAuthChallenge) return StateResolution( newState: MigrateSignInState.signingIn(signInEventData), actions: [action] diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SRP/SRPSignInState+Resolver.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SRP/SRPSignInState+Resolver.swift index 7336e0ad58..2a37225a59 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SRP/SRPSignInState+Resolver.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SRP/SRPSignInState+Resolver.swift @@ -49,7 +49,7 @@ extension SRPSignInState { private func resolveNotStarted(byApplying signInEvent: SignInEvent) -> StateResolution { switch signInEvent.eventType { - case .initiateSignInWithSRP(let signInEventData, let deviceMetadata): + case .initiateSignInWithSRP(let signInEventData, let deviceMetadata, let respondToAuthChallenge): guard let username = signInEventData.username, !username.isEmpty else { let error = SignInError.inputValidation( field: AuthPluginErrorConstants.signInUsernameError.field @@ -72,7 +72,8 @@ extension SRPSignInState { password: password, authFlowType: authFlowType, deviceMetadata: deviceMetadata, - clientMetadata: signInEventData.clientMetadata) + clientMetadata: signInEventData.clientMetadata, + respondToAuthChallenge: respondToAuthChallenge) return StateResolution( newState: SRPSignInState.initiatingSRPA(signInEventData), actions: [action] diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/SignInState+Resolver.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/SignInState+Resolver.swift index d95da1588c..bde1186eb6 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/SignInState+Resolver.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/SignInState+Resolver.swift @@ -6,6 +6,7 @@ // import Foundation +// swiftlint:disable type_body_length extension SignInState { // swiftlint:disable:next nesting @@ -22,10 +23,11 @@ extension SignInState { switch oldState { case .notStarted: - if case .initiateSignInWithSRP(let signInEventData, let deviceMetadata) = event.isSignInEvent { + if case .initiateSignInWithSRP(let signInEventData, let deviceMetadata, let respondToAuthChallenge) = event.isSignInEvent { let action = StartSRPFlow( signInEventData: signInEventData, - deviceMetadata: deviceMetadata) + deviceMetadata: deviceMetadata, + respondToAuthChallenge: respondToAuthChallenge) return .init(newState: .signingInWithSRP(.notStarted, signInEventData), actions: [action]) } @@ -40,7 +42,8 @@ extension SignInState { if case .initiateCustomSignInWithSRP(let signInEventData, let deviceMetadata) = event.isSignInEvent { let action = StartSRPFlow( signInEventData: signInEventData, - deviceMetadata: deviceMetadata) + deviceMetadata: deviceMetadata, + respondToAuthChallenge: nil) return .init(newState: .signingInWithSRPCustom(.notStarted, signInEventData), actions: [action]) } @@ -48,13 +51,28 @@ extension SignInState { let action = InitializeHostedUISignIn(options: options) return .init(newState: .signingInWithHostedUI(.notStarted), actions: [action]) } - if case .initiateMigrateAuth(let signInEventData, let deviceMetadata) = event.isSignInEvent { + if case .initiateMigrateAuth(let signInEventData, let deviceMetadata, let respondToAuthChallenge) = event.isSignInEvent { let action = StartMigrateAuthFlow( signInEventData: signInEventData, - deviceMetadata: deviceMetadata) + deviceMetadata: deviceMetadata, + respondToAuthChallenge: respondToAuthChallenge) return .init(newState: .signingInViaMigrateAuth(.notStarted, signInEventData), actions: [action]) } + if case .initiateUserAuth(let signInEventData, let deviceMetadata) = event.isSignInEvent { + let action = InitiateUserAuth( + signInEventData: signInEventData, + deviceMetadata: deviceMetadata) + return .init(newState: .signingInWithUserAuth(signInEventData), + actions: [action]) + } + if case .initiateAutoSignIn(let signInEventData, let deviceMetadata) = event.isSignInEvent { + let action = AutoSignIn( + signInEventData: signInEventData, + deviceMetadata: deviceMetadata) + return .init(newState: .autoSigningIn(signInEventData), + actions: [action]) + } return .from(oldState) case .signingInWithHostedUI(let hostedUIState): @@ -220,7 +238,25 @@ extension SignInState { actions: [action]) } - // This could when we have nested challenges + if case .initiateSignInWithSRP(let signInEventData, let deviceMetadata, let respondToAuthChallenge) = event.isSignInEvent { + let action = StartSRPFlow( + signInEventData: signInEventData, + deviceMetadata: deviceMetadata, + respondToAuthChallenge: respondToAuthChallenge) + return .init(newState: .signingInWithSRP(.notStarted, signInEventData), + actions: [action]) + } + + if case .initiateMigrateAuth(let signInEventData, let deviceMetadata, let respondToAuthChallenge) = event.isSignInEvent { + let action = StartMigrateAuthFlow( + signInEventData: signInEventData, + deviceMetadata: deviceMetadata, + respondToAuthChallenge: respondToAuthChallenge) + return .init(newState: .signingInViaMigrateAuth(.notStarted, signInEventData), + actions: [action]) + } + + // This could happen when we have nested challenges // Example newPasswordRequired -> sms_mfa if let signInEvent = event as? SignInEvent, case .receivedChallenge(let challenge) = signInEvent.eventType { @@ -246,6 +282,21 @@ extension SignInState { actions: [action]) } + #if os(iOS) || os(macOS) || os(visionOS) + if let signInEvent = event as? SignInEvent, + case .initiateWebAuthnSignIn(let data, let respondToAuthChallenge) = signInEvent.eventType { + let action = InitializeWebAuthn( + username: data.username, + respondToAuthChallenge: respondToAuthChallenge, + presentationAnchor: data.presentationAnchor + ) + let subState = WebAuthnSignInState.notStarted + return .init(newState: .signingInWithWebAuthn( + subState + ), actions: [action]) + } + #endif + let resolution = SignInChallengeState.Resolver().resolve( oldState: challengeState, byApplying: event) @@ -382,6 +433,146 @@ extension SignInState { return .from(oldState) case .signedIn, .error: return .from(oldState) + case .signingInWithUserAuth(let signInEventData): + if case .finalizeSignIn(let signedInData) = event.isSignInEvent { + return .init(newState: .signedIn(signedInData), + actions: [SignInComplete(signedInData: signedInData)]) + } + + if let signInEvent = event as? SignInEvent, + case .confirmDevice(let signedInData) = signInEvent.eventType { + let action = ConfirmDevice(signedInData: signedInData) + return .init(newState: .confirmingDevice, + actions: [action]) + } + + if let signInEvent = event as? SignInEvent, + case .initiateDeviceSRP(let username, let challengeResponse) = signInEvent.eventType { + let action = StartDeviceSRPFlow( + username: username, + authResponse: challengeResponse) + return .init(newState: .resolvingDeviceSrpa(.notStarted), + actions: [action]) + } + + if let signInEvent = event as? SignInEvent, + case .receivedChallenge(let challenge) = signInEvent.eventType { + let action = InitializeResolveChallenge(challenge: challenge, + signInMethod: signInEventData.signInMethod) + let subState = SignInChallengeState.notStarted + return .init(newState: .resolvingChallenge( + subState, + challenge.challenge.authChallengeType, + signInEventData.signInMethod + ), actions: [action]) + } + + #if os(iOS) || os(macOS) || os(visionOS) + if let signInEvent = event as? SignInEvent, + case .initiateWebAuthnSignIn(let data, let respondToAuthChallenge) = signInEvent.eventType { + let action = InitializeWebAuthn( + username: data.username, + respondToAuthChallenge: respondToAuthChallenge, + presentationAnchor: data.presentationAnchor + ) + let subState = WebAuthnSignInState.notStarted + return .init( + newState: .signingInWithWebAuthn(subState), + actions: [action] + ) + } + #endif + + if case .respondPasswordVerifier(let srpStateData, let authResponse, let clientMetadata) = event.isSignInEvent { + let action = VerifyPasswordSRP( + stateData: srpStateData, + authResponse: authResponse, + clientMetadata: clientMetadata) + return .init( + newState: .signingInWithSRP( + .respondingPasswordVerifier(srpStateData), + signInEventData), + actions: [action]) + } + + if let signInEvent = event as? SignInEvent, + case .throwAuthError(let error) = signInEvent.eventType { + let action = ThrowSignInError(error: error) + return StateResolution( + newState: .error, + actions: [action]) + + } + return .from(oldState) + case .signingInWithWebAuthn(let webAuthnState): + #if os(iOS) || os(macOS) || os(visionOS) + if #available(iOS 17.4, macOS 13.5, *) { + if case .throwAuthError(let error) = event.isSignInEvent { + let action = ThrowSignInError(error: error) + return .init( + newState: .error, + actions: [action] + ) + } + + if case .initiateWebAuthnSignIn(let data, let respondToAuthChallenge) = event.isSignInEvent { + let action = InitializeWebAuthn( + username: data.username, + respondToAuthChallenge: respondToAuthChallenge, + presentationAnchor: data.presentationAnchor + ) + return .init( + newState: .signingInWithWebAuthn(.notStarted), + actions: [action] + ) + } + + let resolution = WebAuthnSignInState.Resolver().resolve( + oldState: webAuthnState, + byApplying: event + ) + return .init( + newState: .signingInWithWebAuthn(resolution.newState), + actions: resolution.actions + ) + } else { + // "WebAuthn is not supported in this OS version + // It should technically never happen. + let error = SignInError.unknown(message: "WebAuthn is not supported in this OS version") + return .init( + newState: .error, + actions: [ThrowSignInError(error: error)] + ) + } + #else + let error = SignInError.unknown(message: "WebAuthn is only supported in iOS and macOS") + return .init( + newState: .error, + actions: [ThrowSignInError(error: error)] + ) + #endif + case .autoSigningIn: + if case .finalizeSignIn(let signedInData) = event.isSignInEvent { + return .init(newState: .signedIn(signedInData), + actions: [SignInComplete(signedInData: signedInData)]) + } + + if let signInEvent = event as? SignInEvent, + case .confirmDevice(let signedInData) = signInEvent.eventType { + let action = ConfirmDevice(signedInData: signedInData) + return .init(newState: .confirmingDevice, + actions: [action]) + } + + if let signInEvent = event as? SignInEvent, + case .throwAuthError(let error) = signInEvent.eventType { + let action = ThrowSignInError(error: error) + return StateResolution( + newState: .error, + actions: [action]) + + } + return .from(oldState) } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/WebAuthnSignInState+Resolver.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/WebAuthnSignInState+Resolver.swift new file mode 100644 index 0000000000..e38951c46b --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignIn/WebAuthnSignInState+Resolver.swift @@ -0,0 +1,102 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) +import enum Amplify.AuthFactorType +import Foundation + +extension WebAuthnSignInState { + + @available(iOS 17.4, macOS 13.5, visionOS 1.0, *) + struct Resolver: StateMachineResolver { + + typealias StateType = WebAuthnSignInState + let defaultState = WebAuthnSignInState.notStarted + + func resolve( + oldState: StateType, + byApplying event: StateMachineEvent) + -> StateResolution { + if case .error(let error, let challenge) = event.isWebAuthnEvent { + return .init( + newState: .error(.webAuthn(error), challenge) + ) + } + + switch oldState { + case .notStarted: + if case .fetchCredentialOptions(let input) = event.isWebAuthnEvent { + let action = FetchCredentialOptions( + username: input.username, + respondToAuthChallenge: input.challenge, + presentationAnchor: input.presentationAnchor + ) + return .init(newState: .fetchingCredentialOptions, actions: [action]) + } + if case .assertCredentials(let options, let input) = event.isWebAuthnEvent { + let action = AssertWebAuthnCredentials( + username: input.username, + options: options, + respondToAuthChallenge: input.challenge, + presentationAnchor: input.presentationAnchor + ) + return .init(newState: .assertingCredentials, actions: [action]) + } + case .fetchingCredentialOptions: + if case .assertCredentials(let options, let input) = event.isWebAuthnEvent { + let action = AssertWebAuthnCredentials( + username: input.username, + options: options, + respondToAuthChallenge: input.challenge, + presentationAnchor: input.presentationAnchor + ) + return .init(newState: .assertingCredentials, actions: [action]) + } + case .assertingCredentials: + if case .verifyCredentialsAndSignIn(let credentials, let input) = event.isWebAuthnEvent { + let action = VerifyWebAuthnCredential( + username: input.username, + credentials: credentials, + respondToAuthChallenge: input.challenge + ) + return .init( + newState: .verifyingCredentialsAndSigningIn, + actions: [action] + ) + } + case .verifyingCredentialsAndSigningIn: + if case .signedIn(let signedInData) = event.isWebAuthnEvent { + return .init( + newState: .signedIn(signedInData), + actions: [SignInComplete(signedInData: signedInData)] + ) + } + case .signedIn: + return .from(oldState) + case .error(_, let challenge): + // The WebAuthn flow can be retried on error state when confirming Sign In, + // so if we receive a new .verifyChallengeAnswer event for WebAuthn, we'll restart the flow + if case .verifyChallengeAnswer(let data) = event.isChallengeEvent, + let authFactorType = AuthFactorType(rawValue: data.answer), + case .webAuthn = authFactorType { + let action = VerifySignInChallenge( + challenge: challenge, + confirmSignEventData: data, + signInMethod: .apiBased(.userAuth), + currentSignInStep: .continueSignInWithFirstFactorSelection([authFactorType]) + ) + return .init( + newState: .notStarted, + actions: [action] + ) + } + } + return .from(oldState) + } + } +} +#endif diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignUp/SignUpState+Resolver.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignUp/SignUpState+Resolver.swift new file mode 100644 index 0000000000..c7d8b937ba --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/StateMachine/Resolvers/SignUp/SignUpState+Resolver.swift @@ -0,0 +1,148 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +extension SignUpState { + struct Resolver: StateMachineResolver { + typealias StateType = SignUpState + let defaultState = SignUpState.notStarted + + init() { } + + func resolve( + oldState: SignUpState, + byApplying event: StateMachineEvent + ) -> StateResolution { + guard let signUpEvent = event as? SignUpEvent else { + return .from(oldState) + } + switch oldState { + case .notStarted: + return resolveNotStarted(byApplying: signUpEvent, from: oldState) + case .initiatingSignUp: + return resolveInitiatingSignUp(byApplying: signUpEvent, from: oldState) + case .awaitingUserConfirmation: + return resolveAwaitingUserConfirmation(byApplying: signUpEvent, from: oldState) + case .confirmingSignUp: + return resolveConfirmingSignUp(byApplying: signUpEvent, from: oldState) + case .signedUp: + return resolveSignedUp(byApplying: signUpEvent, from: oldState) + case .error: + return resolveError(byApplying: signUpEvent, from: oldState) + } + } + + private func resolveNotStarted( + byApplying signUpEvent: SignUpEvent, + from oldState: SignUpState + ) -> StateResolution { + switch signUpEvent.eventType { + case .initiateSignUp(let data, let password, let userAttributes): + let action = InitiateSignUp(data: data, password: password, attributes: userAttributes) + return .init(newState: .initiatingSignUp(data), actions: [action]) + case .confirmSignUp(let data, let code, let forceAliasCreation): + let action = ConfirmSignUp(data: data, confirmationCode: code, forceAliasCreation: forceAliasCreation) + return .init(newState: .confirmingSignUp(data), actions: [action]) + case .throwAuthError(let error): + return .init(newState: .error(error)) + default: + return .from(oldState) + } + } + + private func resolveError( + byApplying signUpEvent: SignUpEvent, + from oldState: SignUpState + ) -> StateResolution { + switch signUpEvent.eventType { + case .initiateSignUp(let data, let password, let userAttributes): + let action = InitiateSignUp(data: data, password: password, attributes: userAttributes) + return .init(newState: .initiatingSignUp(data), actions: [action]) + case .confirmSignUp(let data, let code, let forceAliasCreation): + let action = ConfirmSignUp(data: data, confirmationCode: code, forceAliasCreation: forceAliasCreation) + return .init(newState: .confirmingSignUp(data), actions: [action]) + default: + return .from(oldState) + } + } + + private func resolveInitiatingSignUp( + byApplying signUpEvent: SignUpEvent, + from oldState: SignUpState + ) -> StateResolution { + switch signUpEvent.eventType { + case .initiateSignUp(let data, let password, let userAttributes): + let action = InitiateSignUp(data: data, password: password, attributes: userAttributes) + return .init(newState: .initiatingSignUp(data), actions: [action]) + case .initiateSignUpComplete(let data, let result): + return .init(newState: .awaitingUserConfirmation(data, result)) + case .confirmSignUp(let data, let code, let forceAliasCreation): + let action = ConfirmSignUp(data: data, confirmationCode: code, forceAliasCreation: forceAliasCreation) + return .init(newState: .confirmingSignUp(data), actions: [action]) + case .signedUp(let data, let result): + return .init(newState: .signedUp(data, result)) + case .throwAuthError(let error): + return .init(newState: .error(error)) + } + } + + private func resolveAwaitingUserConfirmation( + byApplying signUpEvent: SignUpEvent, + from oldState: SignUpState + ) -> StateResolution { + switch signUpEvent.eventType { + case .initiateSignUp(let data, let password, let userAttributes): + let action = InitiateSignUp(data: data, password: password, attributes: userAttributes) + return .init(newState: .initiatingSignUp(data), actions: [action]) + case .confirmSignUp(let data, let code, let forceAliasCreation): + let action = ConfirmSignUp(data: data, confirmationCode: code, forceAliasCreation: forceAliasCreation) + return .init(newState: .confirmingSignUp(data), actions: [action]) + case .throwAuthError(let error): + return .init(newState: .error(error)) + default: + return .from(oldState) + } + } + + private func resolveConfirmingSignUp( + byApplying signUpEvent: SignUpEvent, + from oldState: SignUpState + ) -> StateResolution { + switch signUpEvent.eventType { + case .initiateSignUp(let data, let password, let userAttributes): + let action = InitiateSignUp(data: data, password: password, attributes: userAttributes) + return .init(newState: .initiatingSignUp(data), actions: [action]) + case .confirmSignUp(let data, let code, let forceAliasCreation): + let action = ConfirmSignUp(data: data, confirmationCode: code, forceAliasCreation: forceAliasCreation) + return .init(newState: .confirmingSignUp(data), actions: [action]) + case .signedUp(let data, let result): + return .init(newState: .signedUp(data, result)) + case .throwAuthError(let error): + return .init(newState: .error(error)) + default: + return .from(oldState) + } + } + + private func resolveSignedUp( + byApplying signUpEvent: SignUpEvent, + from oldState: SignUpState + ) -> StateResolution { + switch signUpEvent.eventType { + case .initiateSignUp(let data, let password, let userAttributes): + let action = InitiateSignUp(data: data, password: password, attributes: userAttributes) + return .init(newState: .initiatingSignUp(data), actions: [action]) + case .confirmSignUp(let data, let code, let forceAliasCreation): + let action = ConfirmSignUp(data: data, confirmationCode: code, forceAliasCreation: forceAliasCreation) + return .init(newState: .confirmingSignUp(data), actions: [action]) + case .throwAuthError(let error): + return .init(newState: .error(error)) + default: + return .from(oldState) + } + } + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Constants/AuthPluginErrorConstants.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Constants/AuthPluginErrorConstants.swift index ab899e5682..fe4ad79eb9 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Constants/AuthPluginErrorConstants.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Constants/AuthPluginErrorConstants.swift @@ -171,6 +171,31 @@ enum AuthPluginErrorConstants { static let userSignedOutError: AuthPluginErrorString = ( "There is no user signed in to the Auth category", "SignIn to Auth category by using one of the sign in methods and then try again") + + static let associateWebAuthnCredentialUserCancelledError: AuthPluginErrorString = ( + "User cancelled the creation of a new WebAuthn credential", + "Invoke the associate WebAuthn credential flow again" + ) + + static let associateWebAuthnCredentialAlreadyExistError: AuthPluginErrorString = ( + "The user already has associated a WebAuthn credential with this device", + "Remove the old WebAuthn credential and try again" + ) + + static let associateWebAuthnCreationFailedError: AuthPluginErrorString = ( + "Unable to complete the association of the given WebAuthn credential", + "Invoke the associate WebAuthn credential flow again" + ) + + static let signInWithWebAuthnUserCancelledError: AuthPluginErrorString = ( + "User cancelled the signIn flow and could not be completed", + "Invoke the sign in with WebAuthn flow again" + ) + + static let signInWithWebAuthnAssertionFailedError: AuthPluginErrorString = ( + "Unable to complete assertion of the given WebAuthn credential", + "Invoke the sign in with WebAuthn flow again" + ) } // Field validation errors @@ -232,6 +257,15 @@ extension AuthPluginErrorConstants { """ ) + static let confirmSignInFactorSelectionResponseError: AuthPluginValidationErrorString = ( + "challengeResponse", + "challengeResponse for factor selection can only be one of the `AuthFactorType` values.", + """ + Make sure that a valid challenge response is passed for confirmSignIn. + Try using `AuthFactorType..challengeResponse` as the challenge response. + """ + ) + static let confirmResetPasswordUsernameError: AuthPluginValidationErrorString = ( "username", "username is required to confirmResetPassword", @@ -354,4 +388,32 @@ extension AuthPluginErrorConstants { Make sure the requests sent are controlled and concurrent operations are handled properly """ + static let webAuthnChallengeNotFound: RecoverySuggestion = """ + Call this API after receiving an WebAuthn challenge + """ + + static let webAuthnClientMismatch: RecoverySuggestion = """ + Use an App client that supports passkey authentication + """ + + static let webAuthnCredentialNotSupported: RecoverySuggestion = """ + Create credentials using a supported device + """ + + static let webAuthnNotEnabled: RecoverySuggestion = """ + Check that WebAuthn authentication is enabled + """ + + static let webAuthnOriginNotAllowed: RecoverySuggestion = """ + Check that the device origin is registered as an allowed origin + """ + + static let webAuthnRelyingPartyMismatch: RecoverySuggestion = """ + Check that the relying party ID is correct + """ + + static let webAuthnConfigurationMissing: RecoverySuggestion = """ + Check that the device is registered + """ + } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/PreferredChallengeHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/PreferredChallengeHelper.swift new file mode 100644 index 0000000000..4a92e9c70e --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/PreferredChallengeHelper.swift @@ -0,0 +1,74 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import Foundation + +class PreferredChallengeHelper { + + let authFactor: AuthFactorType + let password: String? + let username: String + let environment: Environment + private(set) var srpStateData: SRPStateData? = nil + + init(authFactor: AuthFactorType, + password: String?, + username: String, + environment: Environment) { + self.authFactor = authFactor + self.password = password + self.username = username + self.environment = environment + } + + func toCognitoAuthParameters() throws -> [String: String] { + var authParameters: [String: String] = [:] + authParameters["PREFERRED_CHALLENGE"] = authFactor.challengeResponse + + switch authFactor { + case .password: + guard let password = password else { + throw AuthError.validation( + AuthPluginErrorConstants.signInPasswordError.field, + AuthPluginErrorConstants.signInPasswordError.errorDescription, + AuthPluginErrorConstants.signInPasswordError.recoverySuggestion) + } + authParameters["PASSWORD"] = password + case .passwordSRP: + let srpStateData = try generateSRPStateData() + authParameters["SRP_A"] = srpStateData.srpKeyPair.publicKeyHexValue + default: + break + } + return authParameters + } + + private func generateSRPStateData() throws -> SRPStateData { + let srpEnv = try environment.srpEnvironment() + let nHexValue = srpEnv.srpConfiguration.nHexValue + let gHexValue = srpEnv.srpConfiguration.gHexValue + + let srpClient = try SRPSignInHelper.srpClient(srpEnv) + let srpKeyPair = srpClient.generateClientKeyPair() + guard let password = password else { + throw AuthError.validation( + AuthPluginErrorConstants.signInPasswordError.field, + AuthPluginErrorConstants.signInPasswordError.errorDescription, + AuthPluginErrorConstants.signInPasswordError.recoverySuggestion) + } + let srpStateData = SRPStateData( + username: username, + password: password, + NHexValue: nHexValue, + gHexValue: gHexValue, + srpKeyPair: srpKeyPair, + clientTimestamp: Date()) + self.srpStateData = srpStateData + return srpStateData + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift index 05097b818c..c3a2b37b64 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Helpers/UserPoolSignInHelper.swift @@ -43,7 +43,7 @@ struct UserPoolSignInHelper: DefaultLogger { } 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) @@ -52,6 +52,9 @@ struct UserPoolSignInHelper: DefaultLogger { case .waitingForAnswer(let totpSetupData) = totpSetupState { return .init(nextStep: .continueSignInWithTOTPSetup( .init(sharedSecret: totpSetupData.secretCode, username: totpSetupData.username))) + } else if case .signingInWithWebAuthn(let webAuthnState) = signInState, + case .error(let signInError, _) = webAuthnState { + return try validateError(signInError: signInError) } return nil } @@ -81,17 +84,20 @@ struct UserPoolSignInHelper: DefaultLogger { static func parseResponse( _ response: SignInResponseBehavior, for username: String, - signInMethod: SignInMethod) -> StateMachineEvent { + signInMethod: SignInMethod, + presentationAnchor: AuthUIPresentationAnchor? = nil, + srpStateData: SRPStateData? = nil + ) -> StateMachineEvent { if let authenticationResult = response.authenticationResult, let idToken = authenticationResult.idToken, let accessToken = authenticationResult.accessToken, let refreshToken = authenticationResult.refreshToken { - - let userPoolTokens = AWSCognitoUserPoolTokens(idToken: idToken, - accessToken: accessToken, - refreshToken: refreshToken, - expiresIn: authenticationResult.expiresIn) + let userPoolTokens = AWSCognitoUserPoolTokens( + idToken: idToken, + accessToken: accessToken, + refreshToken: refreshToken, + expiresIn: authenticationResult.expiresIn) let signedInData = SignedInData( signedInDate: Date(), signInMethod: signInMethod, @@ -109,15 +115,31 @@ struct UserPoolSignInHelper: DefaultLogger { let parameters = response.challengeParameters let respondToAuthChallenge = RespondToAuthChallenge( challenge: challengeName, + availableChallenges: response.availableChallenges ?? [], username: username, session: response.session, parameters: parameters) switch challengeName { - case .smsMfa, .customChallenge, .newPasswordRequired, .softwareTokenMfa, .selectMfaType, .emailOtp: + case .smsMfa, .customChallenge, .newPasswordRequired, .softwareTokenMfa, .selectMfaType, .smsOtp, .emailOtp, .selectChallenge: return SignInEvent(eventType: .receivedChallenge(respondToAuthChallenge)) + case .passwordVerifier: + guard let srpStateData else { + let message = "Unable to extract SRP state data to continue with password verification." + let error = SignInError.invalidServiceResponse(message: message) + return SignInEvent(eventType: .throwAuthError(error)) + } + return SignInEvent( + eventType: .respondPasswordVerifier(srpStateData, response, [:]) + ) case .deviceSrpAuth: return SignInEvent(eventType: .initiateDeviceSRP(username, response)) + case .webAuthn: + let signInData = WebAuthnSignInData( + username: username, + presentationAnchor: presentationAnchor + ) + return SignInEvent(eventType: .initiateWebAuthnSignIn(signInData, respondToAuthChallenge)) case .mfaSetup: let allowedMFATypesForSetup = respondToAuthChallenge.getAllowedMFATypesForSetup if allowedMFATypesForSetup.contains(.totp) && allowedMFATypesForSetup.contains(.email) { diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/StateMachineSupport/StateMachineEvent+Type.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/StateMachineSupport/StateMachineEvent+Type.swift index d3f631df00..a2b2aa64cb 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/StateMachineSupport/StateMachineEvent+Type.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/StateMachineSupport/StateMachineEvent+Type.swift @@ -57,6 +57,13 @@ extension StateMachineEvent { } return event } + + var isSignUpEvent: SignUpEvent.EventType? { + guard let event = (self as? SignUpEvent)?.eventType else { + return nil + } + return event + } var isChallengeEvent: SignInChallengeEvent.EventType? { guard let event = (self as? SignInChallengeEvent)?.eventType else { @@ -79,4 +86,13 @@ extension StateMachineEvent { return event } +#if os(iOS) || os(macOS) || os(visionOS) + var isWebAuthnEvent: WebAuthnEvent.EventType? { + guard let event = (self as? WebAuthnEvent)?.eventType else { + return nil + } + return event + } +#endif + } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/ConfirmSignUpInput+Amplify.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/ConfirmSignUpInput+Amplify.swift index 6d3fea6612..88acf4eab4 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/ConfirmSignUpInput+Amplify.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/ConfirmSignUpInput+Amplify.swift @@ -14,6 +14,7 @@ extension ConfirmSignUpInput { clientMetadata: [String: String]?, asfDeviceId: String?, forceAliasCreation: Bool?, + session: String?, environment: UserPoolEnvironment ) async { @@ -40,6 +41,7 @@ extension ConfirmSignUpInput { confirmationCode: confirmationCode, forceAliasCreation: forceAliasCreation, secretHash: secretHash, + session: session, userContextData: userContextData, username: username) } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/InitiateAuthInput+Amplify.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/InitiateAuthInput+Amplify.swift index fbda1ffbc0..166bec220c 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/InitiateAuthInput+Amplify.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/InitiateAuthInput+Amplify.swift @@ -55,6 +55,24 @@ extension InitiateAuthInput { environment: environment) } + static func userAuth(username: String, + preferredChallengeAuthParams: [String: String], + clientMetadata: [String: String], + asfDeviceId: String, + deviceMetadata: DeviceMetadata, + environment: UserPoolEnvironment) async -> InitiateAuthInput { + var authParameters = preferredChallengeAuthParams + authParameters["USERNAME"] = username + + return await buildInput(username: username, + authFlowType: .userAuth, + authParameters: authParameters, + clientMetadata: clientMetadata, + asfDeviceId: asfDeviceId, + deviceMetadata: deviceMetadata, + environment: environment) + } + static func migrateAuth(username: String, password: String, clientMetadata: [String: String], diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/RespondToAuthChallengeInput+Amplify.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/RespondToAuthChallengeInput+Amplify.swift index 8132c97e28..7c3f733809 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/RespondToAuthChallengeInput+Amplify.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/RespondToAuthChallengeInput+Amplify.swift @@ -10,6 +10,77 @@ import AWSCognitoIdentityProvider extension RespondToAuthChallengeInput { + static func srpInputForUserAuth(username: String, + publicSRPAHexValue: String, + session: String, + clientMetadata: [String: String], + asfDeviceId: String, + deviceMetadata: DeviceMetadata, + environment: UserPoolEnvironment) async -> RespondToAuthChallengeInput { + let challengeResponses = [ + "USERNAME": username, + "SRP_A": publicSRPAHexValue, + "ANSWER": "PASSWORD_SRP" + ] + + return await buildInput( + username: username, + challengeType: .selectChallenge, + challengeResponses: challengeResponses, + session: session, + clientMetadata: clientMetadata, + asfDeviceId: asfDeviceId, + deviceMetadata: deviceMetadata, + environment: environment) + } + + static func userPasswordInputForUserAuth(username: String, + password: String, + session: String, + clientMetadata: [String: String], + asfDeviceId: String, + deviceMetadata: DeviceMetadata, + environment: UserPoolEnvironment) async -> RespondToAuthChallengeInput { + let challengeResponses = [ + "USERNAME": username, + "PASSWORD": password, + "ANSWER": "PASSWORD" + ] + + return await buildInput( + username: username, + challengeType: .selectChallenge, + challengeResponses: challengeResponses, + session: session, + clientMetadata: clientMetadata, + asfDeviceId: asfDeviceId, + deviceMetadata: deviceMetadata, + environment: environment) + } + + static func webAuthnInput(username: String, + session: String?, + asfDeviceId: String, + deviceMetadata: DeviceMetadata, + environment: UserPoolEnvironment + ) async -> RespondToAuthChallengeInput { + let challengeResponses = [ + "USERNAME": username, + "ANSWER": "WEB_AUTHN" + ] + + return await buildInput( + username: username, + challengeType: .selectChallenge, + challengeResponses: challengeResponses, + session: session, + clientMetadata: [:], + asfDeviceId: asfDeviceId, + deviceMetadata: deviceMetadata, + environment: environment + ) + } + // swiftlint:disable:next function_parameter_count static func passwordVerifier(username: String, stateData: SRPStateData, @@ -118,6 +189,30 @@ extension RespondToAuthChallengeInput { environment: environment) } + static func verifyWebauthCredential( + username: String, + credential: String, + session: String?, + asfDeviceId: String, + environment: UserPoolEnvironment + ) async -> RespondToAuthChallengeInput { + let challengeResponses = [ + "USERNAME": username, + "CREDENTIAL": credential + ] + + return await buildInput( + username: username, + challengeType: .webAuthn, + challengeResponses: challengeResponses, + session: session, + clientMetadata: [:], + asfDeviceId: asfDeviceId, + deviceMetadata: .noData, + environment: environment + ) + } + static func buildInput( username: String, challengeType: CognitoIdentityProviderClientTypes.ChallengeNameType, diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/SignUpInput+Amplify.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/SignUpInput+Amplify.swift index bec03bab9b..4161c890c7 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/SignUpInput+Amplify.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Support/Utils/SignUpInput+Amplify.swift @@ -16,7 +16,7 @@ import UIKit extension SignUpInput { typealias CognitoAttributeType = CognitoIdentityProviderClientTypes.AttributeType init(username: String, - password: String, + password: String?, clientMetadata: [String: String]?, validationData: [String: String]?, attributes: [String: String], diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthAutoSignInTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthAutoSignInTask.swift new file mode 100644 index 0000000000..a7704e4621 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthAutoSignInTask.swift @@ -0,0 +1,163 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import AWSCognitoIdentityProvider +import Amplify + +class AWSAuthAutoSignInTask: AuthAutoSignInTask, DefaultLogger { + private let request: AuthAutoSignInRequest + private let authStateMachine: AuthStateMachine + private let taskHelper: AWSAuthTaskHelper + private let authEnvironment: AuthEnvironment + + var eventName: HubPayloadEventName { + HubPayload.EventName.Auth.autoSignInAPI + } + + init(_ request: AuthAutoSignInRequest, + authStateMachine: AuthStateMachine, + authEnvironment: AuthEnvironment) { + self.request = request + self.authStateMachine = authStateMachine + self.authEnvironment = authEnvironment + self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) + } + + func execute() async throws -> AuthSignInResult { + await taskHelper.didStateMachineConfigured() + + // Check if we have a user pool configuration + let authConfiguration = authEnvironment.configuration + guard let _ = authConfiguration.getUserPoolConfiguration() else { + let message = AuthPluginErrorConstants.configurationError + let authError = AuthError.configuration( + "Could not find user pool configuration", + message) + throw authError + } + + try await validateCurrentState() + + do { + log.verbose("Auto signing in") + let result = try await doAutoSignIn() + 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, _, let signUpState) = state else { + continue + } + + guard case .signedUp = signUpState else { + let error = AuthError.invalidState( + "Not in a signed up state. Please call signUp() and confirmSignUp() before calling autoSignIn()", + AuthPluginErrorConstants.invalidStateError, nil) + throw error + } + + 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 .configured, .signedOut: + return + default: continue + } + } + } + + private func doAutoSignIn() async throws -> AuthSignInResult { + log.verbose("Sending autoSignIn event") + try await sendAutoSignInEvent() + + log.verbose("Waiting for autoSignIn to complete") + let stateSequences = await authStateMachine.listen() + 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): + guard let result = try UserPoolSignInHelper.checkNextStep(signInState) else { + continue + } + return result + default: + continue + } + } + throw AuthError.unknown("Sign in reached an error state") + } + + 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 + } + } + } + + private func sendAutoSignInEvent() async throws { + let currentState = await authStateMachine.currentState + guard case .configured(_, _, let signUpState) = currentState else { + let message = "Auth state machine not in configured state: \(currentState)" + let error = AuthError.invalidState(message, "", nil) + throw error + } + + guard case .signedUp(let data, _) = signUpState else { + throw AuthError.invalidState("Auth state machine not in signed up state: \(currentState)", "", nil) + } + + let signInEventData = SignInEventData( + username: data.username, + password: nil, + clientMetadata: data.clientMetadata ?? [:], + signInMethod: .apiBased(.userAuth), + session: data.session) + + let event = AuthenticationEvent(eventType: .signInRequested(signInEventData, true)) + await authStateMachine.send(event) + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInTask.swift index 9b6ed906e3..6d5e95ab64 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignInTask.swift @@ -27,7 +27,7 @@ class AWSAuthConfirmSignInTask: AuthConfirmSignInTask, DefaultLogger { self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) self.authConfiguration = configuration } - + func execute() async throws -> AuthSignInResult { await taskHelper.didStateMachineConfigured() @@ -47,39 +47,17 @@ class AWSAuthConfirmSignInTask: AuthConfirmSignInTask, DefaultLogger { "User is not attempting signIn operation", AuthPluginErrorConstants.invalidStateError, nil) - guard case .configured(let authNState, _) = await authStateMachine.currentState, + guard case .configured(let authNState, _, _) = await authStateMachine.currentState, case .signingIn(let signInState) = authNState else { throw invalidStateError } - if case .resolvingChallenge(let challengeState, let challengeType, _) = signInState { - - // Validate if request valid MFA selection - if challengeType == .selectMFAType { - try validateRequestForMFASelection() - } - - switch challengeState { - case .waitingForAnswer, .error: - log.verbose("Sending confirm signIn event: \(challengeState)") - await sendConfirmSignInEvent() - default: - throw invalidStateError - } - } else if case .resolvingTOTPSetup(let resolvingSetupTokenState, _) = signInState { - switch resolvingSetupTokenState { - case .waitingForAnswer, .error: - log.verbose("Sending confirm signIn event: \(resolvingSetupTokenState)") - await sendConfirmTOTPSetupEvent() - default: - throw invalidStateError - } - } + try await analyzeCurrentStateAndCreateEvent(signInState, 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 { + guard case .configured(let authNState, let authZState, _) = state else { continue } switch authNState { @@ -108,6 +86,65 @@ class AWSAuthConfirmSignInTask: AuthConfirmSignInTask, DefaultLogger { throw invalidStateError } + fileprivate func analyzeCurrentStateAndCreateEvent(_ signInState: (SignInState), _ invalidStateError: AuthError) async throws { + switch signInState { + case .resolvingChallenge(let challengeState, let challengeType, _): + // Validate if request valid MFA selection + if challengeType == .selectMFAType { + try validateRequestForMFASelection() + } + + // Validate if request has valid factor selection + if challengeType == .selectAuthFactor { + try validateRequestForFactorSelection() + } + + switch challengeState { + case .waitingForAnswer, .error: + log.verbose("Sending confirm signIn event: \(challengeState)") + await sendConfirmSignInEvent() + default: + throw invalidStateError + } + case .resolvingTOTPSetup(let resolvingSetupTokenState, _): + switch resolvingSetupTokenState { + case .waitingForAnswer, .error: + log.verbose("Sending confirm signIn event: \(resolvingSetupTokenState)") + await sendConfirmTOTPSetupEvent() + default: + throw invalidStateError + } + case .signingInWithWebAuthn(let webAuthnState): + switch webAuthnState { + case .error: + log.verbose("Sending initiate webAuthn signIn event: \(webAuthnState)") + await sendConfirmSignInEvent() + default: + throw invalidStateError + } + case .signingInViaMigrateAuth(let migratedAuthState, _): + switch migratedAuthState { + case .error: + throw AuthError.invalidState( + "Cannot use Auth.confirmSignIn in the current state. Please use Auth.signIn to reinitiate the sign-in process.", + AuthPluginErrorConstants.invalidStateError, nil) + default: + throw invalidStateError + } + case .signingInWithSRP(let srpState, _): + switch srpState { + case .error: + throw AuthError.invalidState( + "Cannot use Auth.confirmSignIn in the current state. Please use Auth.signIn to reinitiate the sign-in process.", + AuthPluginErrorConstants.invalidStateError, nil) + default: + throw invalidStateError + } + default: + throw invalidStateError + } + } + func validateRequestForMFASelection() throws { let challengeResponse = request.challengeResponse @@ -119,6 +156,17 @@ class AWSAuthConfirmSignInTask: AuthConfirmSignInTask, DefaultLogger { } } + func validateRequestForFactorSelection() throws { + let challengeResponse = request.challengeResponse + + guard let _ = AuthFactorType(rawValue: challengeResponse) else { + throw AuthError.validation( + AuthPluginErrorConstants.confirmSignInFactorSelectionResponseError.field, + AuthPluginErrorConstants.confirmSignInFactorSelectionResponseError.errorDescription, + AuthPluginErrorConstants.confirmSignInFactorSelectionResponseError.recoverySuggestion) + } + } + func sendConfirmSignInEvent() async { let event = SignInChallengeEvent( eventType: .verifyChallengeAnswer(createConfirmSignInEventData())) @@ -140,11 +188,20 @@ class AWSAuthConfirmSignInTask: AuthConfirmSignInTask, DefaultLogger { into: [String: String]()) { $0[attributePrefix + $1.key.rawValue] = $1.value } ?? [:] + let presentationAnchor: AuthUIPresentationAnchor? + #if os(iOS) || os(macOS) || os(visionOS) + presentationAnchor = request.options.presentationAnchorForWebAuthn + #else + presentationAnchor = nil + #endif + return ConfirmSignInEventData( answer: self.request.challengeResponse, attributes: attributes, metadata: pluginOptions?.metadata, - friendlyDeviceName: pluginOptions?.friendlyDeviceName) + friendlyDeviceName: pluginOptions?.friendlyDeviceName, + presentationAnchor: presentationAnchor + ) } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignUpTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignUpTask.swift index a7b7124245..b28ba1619d 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignUpTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthConfirmSignUpTask.swift @@ -12,45 +12,106 @@ import AWSCognitoIdentityProvider class AWSAuthConfirmSignUpTask: AuthConfirmSignUpTask, DefaultLogger { private let request: AuthConfirmSignUpRequest + private let authStateMachine: AuthStateMachine + private let taskHelper: AWSAuthTaskHelper private let authEnvironment: AuthEnvironment var eventName: HubPayloadEventName { HubPayload.EventName.Auth.confirmSignUpAPI } - init(_ request: AuthConfirmSignUpRequest, authEnvironment: AuthEnvironment) { + init(_ request: AuthConfirmSignUpRequest, + authStateMachine: AuthStateMachine, + authEnvironment: AuthEnvironment) { self.request = request + self.authStateMachine = authStateMachine self.authEnvironment = authEnvironment + self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) } func execute() async throws -> AuthSignUpResult { + await taskHelper.didStateMachineConfigured() try request.hasError() - let userPoolEnvironment = authEnvironment.userPoolEnvironment + try await validateCurrentState() + do { - - let asfDeviceId = try await CognitoUserPoolASF.asfDeviceID( - for: request.username, - credentialStoreClient: authEnvironment.credentialsClient) - let metadata = (request.options.pluginOptions as? AWSAuthConfirmSignUpOptions)?.metadata - let forceAliasCreation = (request.options.pluginOptions as? AWSAuthConfirmSignUpOptions)?.forceAliasCreation - let client = try userPoolEnvironment.cognitoUserPoolFactory() - let input = await ConfirmSignUpInput(username: request.username, - confirmationCode: request.code, - clientMetadata: metadata, - asfDeviceId: asfDeviceId, - forceAliasCreation: forceAliasCreation, - environment: userPoolEnvironment) - _ = try await client.confirmSignUp(input: input) - log.verbose("Received success") - return AuthSignUpResult(.done, userID: nil) - } catch let error as AuthErrorConvertible { - throw error.authError + log.verbose("Confirm sign up") + let result = try await doConfirmSignUp() + log.verbose("Confirm sign up received result") + return result } catch { - throw AuthError.configuration( - "Unable to create a Swift SDK user pool service", - AuthPluginErrorConstants.configurationError, - error - ) + throw error + } + } + + private func doConfirmSignUp() async throws -> AuthSignUpResult { + log.verbose("Sending confirmSignUp event") + try await sendConfirmSignUpEvent() + log.verbose("Waiting for confirm signup to complete") + + let stateSequences = await authStateMachine.listen() + for await state in stateSequences { + guard case .configured(_, _, let signUpState) = state else { continue } + + switch signUpState { + case .signedUp(_, let result): + return result + case .error(let signUpError): + throw signUpError.authError + default: + continue + } + } + throw AuthError.unknown("Confirm sign up reached an error state") + } + + private func sendConfirmSignUpEvent() async throws { + let currentState = await authStateMachine.currentState + guard case .configured(_, _, let signUpState) = currentState else { + let message = "Auth state machine not in configured state: \(currentState)" + let error = AuthError.invalidState(message, "", nil) + throw error + } + + var session: String? + if case .awaitingUserConfirmation(let data, _) = signUpState, + request.username == data.username { + // only include session if the cached username matches + // the username in confirmSignUp() call + session = data.session + } + + let pluginOptions = request.options.pluginOptions as? AWSAuthConfirmSignUpOptions + let metaData = pluginOptions?.metadata + let forceAliasCreation = pluginOptions?.forceAliasCreation + + let signUpEventData = SignUpEventData( + username: request.username, + clientMetadata: metaData, + session: session) + let event = SignUpEvent( + eventType: .confirmSignUp( + signUpEventData, + request.code, + forceAliasCreation) + ) + await authStateMachine.send(event) + } + + private func validateCurrentState() async throws { + let stateSequences = await authStateMachine.listen() + log.verbose("Validating current state") + for await state in stateSequences { + guard case .configured(_, _, let signUpState) = state else { + continue + } + + switch signUpState { + case .notStarted, .awaitingUserConfirmation, .error, .signedUp: + return + default: + continue + } } } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthDeleteUserTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthDeleteUserTask.swift index 2a0fbf7323..e0739fbdf1 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthDeleteUserTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthDeleteUserTask.swift @@ -46,7 +46,7 @@ class AWSAuthDeleteUserTask: AuthDeleteUserTask, DefaultLogger { await authStateMachine.send(deleteUserEvent) log.verbose("Waiting for delete user to complete") for await state in stateSequences { - guard case .configured(let authNState, _) = state else { + guard case .configured(let authNState, _, _) = state else { let error = AuthError.invalidState( "Auth state should be in configured state and authentication state should be in deleting user state", AuthPluginErrorConstants.invalidStateError, diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthFederateToIdentityPoolTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthFederateToIdentityPoolTask.swift index 1770b532c9..8952dfd866 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthFederateToIdentityPoolTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthFederateToIdentityPoolTask.swift @@ -36,7 +36,7 @@ public class AWSAuthFederateToIdentityPoolTask: AuthFederateToIdentityPoolTask, public func execute() async throws -> FederateToIdentityPoolResult { await taskHelper.didStateMachineConfigured() let state = await authStateMachine.currentState - guard case .configured(let authNState, let authZState) = state else { + guard case .configured(let authNState, let authZState, _) = state else { throw AuthError.invalidState( "Federation could not be completed.", AuthPluginErrorConstants.invalidStateError, nil) @@ -57,7 +57,7 @@ public class AWSAuthFederateToIdentityPoolTask: AuthFederateToIdentityPoolTask, let stateSequences = await authStateMachine.listen() log.verbose("Waiting for federation to complete") for await state in stateSequences { - guard case .configured(let authNState, let authZState) = state else { + guard case .configured(let authNState, let authZState, _) = state else { continue } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignInTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignInTask.swift index abfe5ec41b..07718973dd 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignInTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignInTask.swift @@ -58,7 +58,7 @@ class AWSAuthSignInTask: AuthSignInTask, DefaultLogger { let stateSequences = await authStateMachine.listen() log.verbose("Validating current state") for await state in stateSequences { - guard case .configured(let authenticationState, _) = state else { + guard case .configured(let authenticationState, _, _) = state else { continue } @@ -79,13 +79,13 @@ class AWSAuthSignInTask: AuthSignInTask, DefaultLogger { } private func doSignIn(authflowType: AuthFlowType) async throws -> AuthSignInResult { - let stateSequences = await authStateMachine.listen() log.verbose("Sending signIn event") await sendSignInEvent(authflowType: authflowType) + log.verbose("Waiting for signin to complete") + let stateSequences = await authStateMachine.listen() 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 { @@ -121,7 +121,7 @@ class AWSAuthSignInTask: AuthSignInTask, DefaultLogger { let stateSequences = await authStateMachine.listen() log.verbose("Wait for signIn to cancel") for await state in stateSequences { - guard case .configured(let authenticationState, _) = state else { + guard case .configured(let authenticationState, _, _) = state else { continue } switch authenticationState { @@ -133,11 +133,16 @@ class AWSAuthSignInTask: AuthSignInTask, DefaultLogger { } private func sendSignInEvent(authflowType: AuthFlowType) async { + var presentationAnchor: AuthUIPresentationAnchor? = nil + #if os(iOS) || os(macOS) || os(visionOS) + presentationAnchor = request.options.presentationAnchorForWebAuthn + #endif let signInData = SignInEventData( username: request.username, password: request.password, clientMetadata: clientMetadata(), - signInMethod: .apiBased(authflowType) + signInMethod: .apiBased(authflowType), + presentationAnchor: presentationAnchor ) let event = AuthenticationEvent.init(eventType: .signInRequested(signInData)) await authStateMachine.send(event) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignOutTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignOutTask.swift index 5ac5e39295..10aac30779 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignOutTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignOutTask.swift @@ -27,7 +27,7 @@ class AWSAuthSignOutTask: AuthSignOutTask, DefaultLogger { func execute() async -> AuthSignOutResult { await taskHelper.didStateMachineConfigured() - guard case .configured(let authNState, _) = await authStateMachine.currentState else { + guard case .configured(let authNState, _, _) = await authStateMachine.currentState else { return invalidStateResult() } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignUpTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignUpTask.swift index 63194bcea9..e58ec257ea 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignUpTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AWSAuthSignUpTask.swift @@ -12,53 +12,95 @@ import Amplify class AWSAuthSignUpTask: AuthSignUpTask, DefaultLogger { private let request: AuthSignUpRequest - + private let authStateMachine: AuthStateMachine + private let taskHelper: AWSAuthTaskHelper private let authEnvironment: AuthEnvironment var eventName: HubPayloadEventName { HubPayload.EventName.Auth.signUpAPI } - init(_ request: AuthSignUpRequest, authEnvironment: AuthEnvironment) { + init(_ request: AuthSignUpRequest, + authStateMachine: AuthStateMachine, + authEnvironment: AuthEnvironment) { self.request = request + self.authStateMachine = authStateMachine self.authEnvironment = authEnvironment + self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) } func execute() async throws -> AuthSignUpResult { - let userPoolEnvironment = authEnvironment.userPoolEnvironment + await taskHelper.didStateMachineConfigured() try request.hasError() - + try await validateCurrentState() + + do { + log.verbose("Sign up") + let result = try await doSignUp() + log.verbose("Sign up received result") + return result + } catch { + throw error + } + + } + + private func doSignUp() async throws -> AuthSignUpResult { + log.verbose("Sending sign up event") + await sendSignUpEvent() + log.verbose("Waiting for sign up to complete") + + let stateSequences = await authStateMachine.listen() + for await state in stateSequences { + guard case .configured(_, _, let signUpState) = state else { continue } + + switch signUpState { + case .awaitingUserConfirmation(_, let result): + return result + case .signedUp(_, let result): + return result + case .error(let signUpError): + throw signUpError.authError + default: + continue + } + } + throw AuthError.unknown("Sign up reached an error state") + } + + private func sendSignUpEvent() async { let pluginOptions = request.options.pluginOptions as? AWSAuthSignUpOptions let metaData = pluginOptions?.metadata let validationData = pluginOptions?.validationData - do { - let client = try userPoolEnvironment.cognitoUserPoolFactory() - let asfDeviceId = try await CognitoUserPoolASF.asfDeviceID( - for: request.username, - credentialStoreClient: authEnvironment.credentialsClient) - let attributes = request.options.userAttributes?.reduce( - into: [String: String]()) { - $0[$1.key.rawValue] = $1.value - } ?? [:] - let input = await SignUpInput(username: request.username, - password: request.password!, - clientMetadata: metaData, - validationData: validationData, - attributes: attributes, - asfDeviceId: asfDeviceId, - environment: userPoolEnvironment) - - let response = try await client.signUp(input: input) - log.verbose("Received result") - return response.authResponse - } catch let error as AuthErrorConvertible { - throw error.authError - } catch { - throw AuthError.configuration( - "Unable to create a Swift SDK user pool service", - AuthPluginErrorConstants.configurationError, - error - ) + let attributes = request.options.userAttributes + + let signUpEventData = SignUpEventData( + username: request.username, + clientMetadata: metaData, + validationData: validationData + ) + let event = SignUpEvent(eventType: .initiateSignUp( + signUpEventData, + request.password, + attributes) + ) + await authStateMachine.send(event) + } + + private func validateCurrentState() async throws { + let stateSequences = await authStateMachine.listen() + log.verbose("Validating current state") + for await state in stateSequences { + guard case .configured(_, _, let signUpState) = state else { + continue + } + + switch signUpState { + case .notStarted, .awaitingUserConfirmation, .error, .signedUp: + return + default: + continue + } } } } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AssociateWebAuthnCredentialTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AssociateWebAuthnCredentialTask.swift new file mode 100644 index 0000000000..3b64679f82 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/AssociateWebAuthnCredentialTask.swift @@ -0,0 +1,91 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) +import Amplify +import AuthenticationServices +import AWSCognitoIdentityProvider +import Foundation + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +class AssociateWebAuthnCredentialTask: NSObject, AuthAssociateWebAuthnCredentialTask, DefaultLogger { + private let request: AuthAssociateWebAuthnCredentialRequest + private let authStateMachine: AuthStateMachine + private let userPoolFactory: UserPoolEnvironment.CognitoUserPoolFactory + private let taskHelper: AWSAuthTaskHelper + private let credentialRegistrant: CredentialRegistrantProtocol + + let eventName: HubPayloadEventName = HubPayload.EventName.Auth.associateWebAuthnCredentialAPI + + init( + request: AuthAssociateWebAuthnCredentialRequest, + authStateMachine: AuthStateMachine, + userPoolFactory: @escaping UserPoolEnvironment.CognitoUserPoolFactory, + registrantFactory: (AuthUIPresentationAnchor?) -> CredentialRegistrantProtocol = { anchor in + PlatformWebAuthnCredentials(presentationAnchor: anchor) + } + ) { + self.request = request + self.authStateMachine = authStateMachine + self.userPoolFactory = userPoolFactory + self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) + self.credentialRegistrant = registrantFactory(request.presentationAnchor) + } + + func execute() async throws { + do { + await taskHelper.didStateMachineConfigured() + let credential = try await createWebAuthnCredential( + accessToken: taskHelper.getAccessToken(), + userPoolService: userPoolFactory() + ) + try await associateWebAuthCredential( + credential: credential, + accessToken: taskHelper.getAccessToken(), + userPoolService: userPoolFactory() + ) + } catch let error as AuthErrorConvertible { + throw error.authError + } catch { + let webAuthnError = WebAuthnError.unknown( + message: "Unable to associate WebAuthn credential", + error: error + ) + throw webAuthnError.authError + } + } + + private func createWebAuthnCredential( + accessToken: String, + userPoolService: CognitoUserPoolBehavior + ) async throws -> Data { + let result = try await userPoolService.startWebAuthnRegistration( + input: .init(accessToken: accessToken) + ) + + let options = try CredentialCreationOptions( + from: result.credentialCreationOptions?.asStringMap() + ) + + let credential = try await credentialRegistrant.create(with: options) + return try credential.asData() + } + + private func associateWebAuthCredential( + credential: Data, + accessToken: String, + userPoolService: CognitoUserPoolBehavior + ) async throws { + _ = try await userPoolService.completeWebAuthnRegistration( + input: .init( + accessToken: accessToken, + credential: .make(from: credential) + ) + ) + } +} +#endif diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/DeleteWebAuthnCredentialTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/DeleteWebAuthnCredentialTask.swift new file mode 100644 index 0000000000..93707673ff --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/DeleteWebAuthnCredentialTask.swift @@ -0,0 +1,63 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import AWSCognitoIdentityProvider +import AWSPluginsCore +import Foundation + +class DeleteWebAuthnCredentialTask: AuthDeleteWebAuthnCredentialTask, DefaultLogger { + private let request: AuthDeleteWebAuthnCredentialRequest + private let authStateMachine: AuthStateMachine + private let userPoolFactory: UserPoolEnvironment.CognitoUserPoolFactory + private let taskHelper: AWSAuthTaskHelper + + let eventName: HubPayloadEventName = HubPayload.EventName.Auth.deleteWebAuthnCredentialAPI + + init( + request: AuthDeleteWebAuthnCredentialRequest, + authStateMachine: AuthStateMachine, + userPoolFactory: @escaping UserPoolEnvironment.CognitoUserPoolFactory + ) { + self.request = request + self.authStateMachine = authStateMachine + self.userPoolFactory = userPoolFactory + self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) + } + + func execute() async throws { + do { + await taskHelper.didStateMachineConfigured() + try await deleteWebAuthnCredential( + credentialId: request.credentialId, + accessToken: taskHelper.getAccessToken(), + userPoolService: userPoolFactory() + ) + } catch let error as AuthErrorConvertible { + throw error.authError + } catch { + let webAuthnError = WebAuthnError.unknown( + message: "Unable to delete WebAuthn credential", + error: error + ) + throw webAuthnError.authError + } + } + + private func deleteWebAuthnCredential( + credentialId: String, + accessToken: String, + userPoolService: CognitoUserPoolBehavior + ) async throws { + _ = try await userPoolService.deleteWebAuthnCredential( + input: .init( + accessToken: accessToken, + credentialId: credentialId + ) + ) + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/DeviceTasks/AWSAuthForgetDeviceTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/DeviceTasks/AWSAuthForgetDeviceTask.swift index 239b4f9eac..d66f6e214d 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/DeviceTasks/AWSAuthForgetDeviceTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/DeviceTasks/AWSAuthForgetDeviceTask.swift @@ -49,7 +49,7 @@ class AWSAuthForgetDeviceTask: AuthForgetDeviceTask, DefaultLogger { func getCurrentUsername() async throws -> String { let authState = await authStateMachine.currentState - if case .configured(let authenticationState, _) = authState, + if case .configured(let authenticationState, _, _) = authState, case .signedIn(let signInData) = authenticationState { return signInData.username } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/DeviceTasks/AWSAuthRememberDeviceTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/DeviceTasks/AWSAuthRememberDeviceTask.swift index 71eb26f3f7..048a412ff5 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/DeviceTasks/AWSAuthRememberDeviceTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/DeviceTasks/AWSAuthRememberDeviceTask.swift @@ -48,7 +48,7 @@ class AWSAuthRememberDeviceTask: AuthRememberDeviceTask, DefaultLogger { func getCurrentUsername() async throws -> String { let authState = await authStateMachine.currentState - if case .configured(let authenticationState, _) = authState, + if case .configured(let authenticationState, _, _) = authState, case .signedIn(let signInData) = authenticationState { return signInData.username } diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Helpers/AWSAuthTaskHelper.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Helpers/AWSAuthTaskHelper.swift index 44267909b1..ba2e34c8a3 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Helpers/AWSAuthTaskHelper.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Helpers/AWSAuthTaskHelper.swift @@ -35,7 +35,7 @@ class AWSAuthTaskHelper: DefaultLogger { let stateSequences = await authStateMachine.listen() log.verbose("Waiting for signOut completion") for await state in stateSequences { - guard case .configured(let authNState, _) = state else { + guard case .configured(let authNState, _, _) = state else { let error = AuthError.invalidState("Auth State not in a valid state", AuthPluginErrorConstants.invalidStateError, nil) return AWSCognitoSignOutResult.failed(error) } @@ -86,7 +86,7 @@ class AWSAuthTaskHelper: DefaultLogger { await didStateMachineConfigured() let authState = await authStateMachine.currentState - guard case .configured(let authenticationState, _) = authState else { + guard case .configured(let authenticationState, _, _) = authState else { throw AuthError.configuration( "Plugin not configured", AuthPluginErrorConstants.configurationError) diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/ListWebAuthnCredentialsTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/ListWebAuthnCredentialsTask.swift new file mode 100644 index 0000000000..715ea0e189 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/ListWebAuthnCredentialsTask.swift @@ -0,0 +1,94 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import AWSCognitoIdentityProvider +import AWSPluginsCore +import Foundation + +class ListWebAuthnCredentialsTask: AuthListWebAuthnCredentialsTask, DefaultLogger { + private let request: AuthListWebAuthnCredentialsRequest + private let authStateMachine: AuthStateMachine + private let userPoolFactory: UserPoolEnvironment.CognitoUserPoolFactory + private let taskHelper: AWSAuthTaskHelper + + let eventName: HubPayloadEventName = HubPayload.EventName.Auth.listWebAuthnCredentialsAPI + + init( + request: AuthListWebAuthnCredentialsRequest, + authStateMachine: AuthStateMachine, + userPoolFactory: @escaping UserPoolEnvironment.CognitoUserPoolFactory + ) { + self.request = request + self.authStateMachine = authStateMachine + self.userPoolFactory = userPoolFactory + self.taskHelper = AWSAuthTaskHelper(authStateMachine: authStateMachine) + } + + func execute() async throws -> AuthListWebAuthnCredentialsResult { + do { + await taskHelper.didStateMachineConfigured() + return try await listWebAuthnCredentials( + accessToken: taskHelper.getAccessToken(), + userPoolService: userPoolFactory() + ) + } catch let error as AuthErrorConvertible { + throw error.authError + } catch { + let webAuthnError = WebAuthnError.unknown( + message: "Unable to list WebAuthn credentials", + error: error + ) + throw webAuthnError.authError + } + } + + private func listWebAuthnCredentials( + accessToken: String, + userPoolService: CognitoUserPoolBehavior + ) async throws -> AuthListWebAuthnCredentialsResult { + let result = try await userPoolService.listWebAuthnCredentials( + input: .init( + accessToken: accessToken, + maxResults: Int(request.options.pageSize), + nextToken: request.options.nextToken + ) + ) + + let credentialDescriptions = result.credentials ?? [] + let webAuthnCredentials: [AuthWebAuthnCredential] = credentialDescriptions.compactMap { credential in + // All of these are marked as required but the Swift SDK doesn't respect that and maps them to Optionals + guard let createdAt = credential.createdAt, + let credentialId = credential.credentialId, + let relyingPartyId = credential.relyingPartyId else { + return nil + } + + return AWSCognitoWebAuthnCredential( + credentialId: credentialId, + createdAt: createdAt, + relyingPartyId: relyingPartyId, + friendlyName: friendlyName(from: credential) + ) + } + + return .init( + credentials: webAuthnCredentials, + nextToken: result.nextToken + ) + } + + private func friendlyName( + from credential: CognitoIdentityProviderClientTypes.WebAuthnCredentialDescription + ) -> String? { + guard let friendlyName = credential.friendlyCredentialName, !friendlyName.isEmpty else { + return nil + } + + return friendlyName + } +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Models/AWSWebAuthCredentialsModels.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Models/AWSWebAuthCredentialsModels.swift new file mode 100644 index 0000000000..00ea4348a7 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Models/AWSWebAuthCredentialsModels.swift @@ -0,0 +1,251 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) +import AuthenticationServices +import Foundation +import Smithy + +enum WebAuthnCredentialError: Error { + case missingValue(_ value: String, type: T.Type) + case decodingError(_ error: Error, type: T.Type) +} + +struct CredentialAssertionOptions: Codable, Equatable { + private enum CodingKeys: String, CodingKey { + case challengeString = "challenge", relyingPartyId = "rpId" + } + private let challengeString: String + let relyingPartyId: String + + init(from string: String?) throws { + guard let options = string?.data(using: .utf8) else { + throw WebAuthnCredentialError.missingValue("CredentialOptions", type: Self.self) + } + + do { + self = try JSONDecoder().decode(Self.self, from: options) + } catch { + throw WebAuthnCredentialError.decodingError(error, type: Self.self) + } + } + + var challenge: Data { + get throws { + guard let challenge = challengeString.decodeBase64Url() else { + throw WebAuthnCredentialError.missingValue("challenge", type: Self.self) + } + return challenge + } + } + + var debugDictionary: [String: Any] { + return [ + "challenge": challengeString.masked(), + "relyingPartyId": relyingPartyId + ] + } +} + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +struct CredentialAssertionPayload: Codable { + private struct Response: Codable { + let authenticatorData: String + let clientDataJSON: String + let signature: String + let userHandle: String + } + + private let id: String + private let rawId: String + private let type: String + private let authenticatorAttachment: String + private let response: Response + + private init( + credentialId: String, + authenticatorData: String, + clientDataJSON: String, + signature: String, + userHandle: String + ) { + id = credentialId + rawId = credentialId + authenticatorAttachment = "platform" + type = "public-key" + response = Response( + authenticatorData: authenticatorData, + clientDataJSON: clientDataJSON, + signature: signature, + userHandle: userHandle + ) + } + + init( + from credential: ASAuthorizationPublicKeyCredentialAssertion + ) throws { + self.init( + credentialId: credential.credentialID.toBase64Url(), + authenticatorData: credential.rawAuthenticatorData.toBase64Url(), + clientDataJSON: credential.rawClientDataJSON.toBase64Url(), + signature: credential.signature.toBase64Url(), + userHandle: credential.userID.toBase64Url() + ) + } + + func stringify() throws -> String { + let data = try JSONEncoder().encode(self) + return String(decoding: data, as: UTF8.self) + } +} + +struct CredentialCreationOptions { + struct User { + let id: Data + let name: String + + fileprivate init(from dictionary: [String: SmithyDocument]) throws { + guard let idString = try? dictionary["id"]?.asString(), + let id = idString.decodeBase64Url() else { + throw WebAuthnCredentialError.missingValue("user.id", type: Self.self) + } + + guard let name = try? dictionary["name"]?.asString() else { + throw WebAuthnCredentialError.missingValue("user.name", type: Self.self) + } + + self.id = id + self.name = name + } + } + + struct RelyingParty { + let id: String + + fileprivate init(from dictionary: [String: SmithyDocument]) throws { + guard let id = try? dictionary["id"]?.asString() else { + throw WebAuthnCredentialError.missingValue("rp.id", type: Self.self) + } + self.id = id + } + } + + struct Credential { + let id: Data + + fileprivate init(from dictionary: [String: SmithyDocument]) throws { + guard let idString = try? dictionary["id"]?.asString(), + let id = idString.decodeBase64Url() else { + throw WebAuthnCredentialError.missingValue("credential.id", type: Self.self) + } + self.id = id + } + } + + let challenge: Data + let relyingParty: RelyingParty + let user: User + let excludeCredentials: [Credential] + + init(from dictionary: [String: SmithyDocument]?) throws { + guard let challengeString = try? dictionary?["challenge"]?.asString(), + let challenge = challengeString.decodeBase64Url() else { + throw WebAuthnCredentialError.missingValue("challenge", type: Self.self) + } + + guard let relyingParty = try? dictionary?["rp"]?.asStringMap() else { + throw WebAuthnCredentialError.missingValue("rp", type: Self.self) + } + + guard let excludeCredentials = try? dictionary?["excludeCredentials"]?.asList() else { + throw WebAuthnCredentialError.missingValue("excludeCredentials", type: Self.self) + } + + guard let user = try? dictionary?["user"]?.asStringMap() else { + throw WebAuthnCredentialError.missingValue("user", type: Self.self) + } + + self.challenge = challenge + self.relyingParty = try RelyingParty(from: relyingParty) + self.user = try User(from: user) + self.excludeCredentials = try excludeCredentials.map { try Credential(from: $0.asStringMap()) } + } +} + +@available(iOS 17.4, macOS 13.5, visionOS 1.0, *) +struct CredentialRegistrationPayload: Codable { + private struct Response: Codable { + let attestationObject: String + let clientDataJSON: String + let transports: [String] + } + + private let id: String + private let rawId: String + private let type: String + private let authenticatorAttachment: String + private let response: Response + + /// For testing purposes only + init( + credentialId: String, + attestationObject: String, + clientDataJSON: String + ) { + id = credentialId + rawId = credentialId + authenticatorAttachment = "platform" + type = "public-key" + response = Response( + attestationObject: attestationObject, + clientDataJSON: clientDataJSON, + transports: ["internal"] + ) + } + + init( + from credential: ASAuthorizationPublicKeyCredentialRegistration + ) throws { + guard let attestationObject = credential.rawAttestationObject?.toBase64Url() else { + throw WebAuthnCredentialError.missingValue("attestationObject", type: Self.self) + } + + self.init( + credentialId: credential.credentialID.toBase64Url(), + attestationObject: attestationObject, + clientDataJSON: credential.rawClientDataJSON.toBase64Url() + ) + } + + func asData() throws -> Data { + return try JSONEncoder().encode(self) + } +} + +private extension Data { + func toBase64Url() -> String { + return self.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + + +private extension String { + func decodeBase64Url() -> Data? { + var base64 = self + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + if base64.count % 4 != 0 { + base64.append(String(repeating: "=", count: 4 - base64.count % 4)) + } + + return Data(base64Encoded: base64) + } +} +#endif diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthAssociateWebAuthnCredentialTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthAssociateWebAuthnCredentialTask.swift new file mode 100644 index 0000000000..48a7ea0cd5 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthAssociateWebAuthnCredentialTask.swift @@ -0,0 +1,21 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) +import Amplify +import Foundation + +protocol AuthAssociateWebAuthnCredentialTask: AmplifyAuthTask where + Request == AuthAssociateWebAuthnCredentialRequest, + Success == Void, + Failure == AuthError { +} + +public extension HubPayload.EventName.Auth { + static let associateWebAuthnCredentialAPI = "Auth.associateWebAuthnCredentialAPI" +} +#endif diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthAutoSignInTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthAutoSignInTask.swift new file mode 100644 index 0000000000..5f36c9415b --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthAutoSignInTask.swift @@ -0,0 +1,17 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation +import Amplify + +protocol AuthAutoSignInTask: AmplifyAuthTask where Request == AuthAutoSignInRequest, Success == AuthSignInResult, Failure == AuthError {} + +public extension HubPayload.EventName.Auth { + + /// eventName for HubPayloads emitted by this operation + static let autoSignInAPI = "Auth.autoSignInAPI" +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthDeleteWebAuthnCredentialTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthDeleteWebAuthnCredentialTask.swift new file mode 100644 index 0000000000..d377b99cdb --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthDeleteWebAuthnCredentialTask.swift @@ -0,0 +1,19 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import Foundation + +protocol AuthDeleteWebAuthnCredentialTask: AmplifyAuthTask where + Request == AuthDeleteWebAuthnCredentialRequest, + Success == Void, + Failure == AuthError { +} + +public extension HubPayload.EventName.Auth { + static let deleteWebAuthnCredentialAPI = "Auth.deleteWebAuthnCredentialAPI" +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthListWebAuthnCredentialsTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthListWebAuthnCredentialsTask.swift new file mode 100644 index 0000000000..16163ecaf9 --- /dev/null +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/Protocols/AuthListWebAuthnCredentialsTask.swift @@ -0,0 +1,19 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import Foundation + +protocol AuthListWebAuthnCredentialsTask: AmplifyAuthTask where + Request == AuthListWebAuthnCredentialsRequest, + Success == AuthListWebAuthnCredentialsResult, + Failure == AuthError { +} + +public extension HubPayload.EventName.Auth { + static let listWebAuthnCredentialsAPI = "Auth.listWebAuthnCredentialsAPI" +} diff --git a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/SetUpTOTPTask.swift b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/SetUpTOTPTask.swift index 6a7ab2b6ea..96c59dc9f3 100644 --- a/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/SetUpTOTPTask.swift +++ b/AmplifyPlugins/Auth/Sources/AWSCognitoAuthPlugin/Task/SetUpTOTPTask.swift @@ -55,7 +55,7 @@ class SetUpTOTPTask: AuthSetUpTOTPTask, DefaultLogger { let authUser: AuthUser let currentState = await authStateMachine.currentState - if case .configured(let authNState, _) = currentState, + if case .configured(let authNState, _, _) = currentState, case .signedIn(let signInData) = authNState { authUser = AWSAuthUser(username: signInData.username, userId: signInData.userId) } else { diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/InitiateAuthSRP/InitiateAuthSRPTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/InitiateAuthSRP/InitiateAuthSRPTests.swift index c3ef575d95..ff07d2182b 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/InitiateAuthSRP/InitiateAuthSRPTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/InitiateAuthSRP/InitiateAuthSRPTests.swift @@ -25,7 +25,7 @@ class InitiateAuthSRPTests: XCTestCase { let environment = Defaults.makeDefaultAuthEnvironment( userPoolFactory: identityProviderFactory) - let action = InitiateAuthSRP(username: "testUser", password: "testPassword") + let action = InitiateAuthSRP(username: "testUser", password: "testPassword", respondToAuthChallenge: nil) await action.execute( withDispatcher: MockDispatcher { _ in }, @@ -51,7 +51,7 @@ class InitiateAuthSRPTests: XCTestCase { let environment = Defaults.makeDefaultAuthEnvironment( userPoolFactory: identityProviderFactory) - let action = InitiateAuthSRP(username: "testUser", password: "testPassword") + let action = InitiateAuthSRP(username: "testUser", password: "testPassword", respondToAuthChallenge: nil) let errorEventSent = expectation(description: "errorEventSent") let dispatcher = MockDispatcher { event in @@ -91,7 +91,7 @@ class InitiateAuthSRPTests: XCTestCase { let environment = Defaults.makeDefaultAuthEnvironment( userPoolFactory: identityProviderFactory) - let action = InitiateAuthSRP(username: "testUser", password: "testPassword") + let action = InitiateAuthSRP(username: "testUser", password: "testPassword", respondToAuthChallenge: nil) let successEventSent = expectation(description: "successEventSent") diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/VerifySignInChallenge/VerifySignInChallengeTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/VerifySignInChallenge/VerifySignInChallengeTests.swift index 7b05cdddab..12a8d1cb2f 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/VerifySignInChallenge/VerifySignInChallengeTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ActionTests/VerifySignInChallenge/VerifySignInChallengeTests.swift @@ -16,6 +16,7 @@ class VerifySignInChallengeTests: XCTestCase { typealias CognitoFactory = BasicSRPAuthEnvironment.CognitoUserPoolFactory let mockRespondAuthChallenge = RespondToAuthChallenge(challenge: .smsMfa, + availableChallenges: [], username: "usernameMock", session: "mockSession", parameters: [:]) @@ -23,7 +24,8 @@ class VerifySignInChallengeTests: XCTestCase { answer: "1233", attributes: [:], metadata: [:], - friendlyDeviceName: nil) + friendlyDeviceName: nil, + presentationAnchor: nil) /// Test if valid input are given the service call is made /// diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ClientBehaviorTests/AuthGetCurrentUserTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ClientBehaviorTests/AuthGetCurrentUserTests.swift index 4b18e2071d..0071a408b3 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ClientBehaviorTests/AuthGetCurrentUserTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ClientBehaviorTests/AuthGetCurrentUserTests.swift @@ -27,7 +27,7 @@ class AuthGetCurrentUserTests: XCTestCase { func testGetCurrentUserWhileSignedOut() async throws { - let authState = AuthState.configured(.signedOut(.testData), .notConfigured) + let authState = AuthState.configured(.signedOut(.testData), .notConfigured, .notStarted) let plugin = try createPlugin(authState: authState) do { @@ -43,7 +43,7 @@ class AuthGetCurrentUserTests: XCTestCase { func testGetCurrentUserWhileNotConfigured() async throws { - let authState = AuthState.configured(.notConfigured, .notConfigured) + let authState = AuthState.configured(.notConfigured, .notConfigured, .notStarted) let plugin = try createPlugin(authState: authState) do { @@ -59,7 +59,7 @@ class AuthGetCurrentUserTests: XCTestCase { func testGetCurrentUserWithInvalidState() async throws { - let authState = AuthState.configured(.signingIn(.notStarted), .notConfigured) + let authState = AuthState.configured(.signingIn(.notStarted), .notConfigured, .notStarted) let plugin = try createPlugin(authState: authState) do { diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ConfigurationTests/ClientSecretConfigurationTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ConfigurationTests/ClientSecretConfigurationTests.swift index 47c0b7c6f1..7848d12c5b 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ConfigurationTests/ClientSecretConfigurationTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ConfigurationTests/ClientSecretConfigurationTests.swift @@ -27,7 +27,8 @@ class ClientSecretConfigurationTests: XCTestCase { signedInDate: Date(), signInMethod: .apiBased(.userSRP), cognitoUserPoolTokens: AWSCognitoUserPoolTokens.testData)), - AuthorizationState.sessionEstablished(AmplifyCredentials.testData)) + AuthorizationState.sessionEstablished(AmplifyCredentials.testData), + .notStarted) } override func setUp() { diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/HubEventTests/AuthHubEventHandlerTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/HubEventTests/AuthHubEventHandlerTests.swift index 2cb4c64dd9..dda11d4521 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/HubEventTests/AuthHubEventHandlerTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/HubEventTests/AuthHubEventHandlerTests.swift @@ -192,7 +192,7 @@ class AuthHubEventHandlerTests: XCTestCase { func testWebUISignedInHubEvent() async { let mockIdentityProvider = MockIdentityProvider() - let initialState = AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + let initialState = AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured, .notStarted) configurePlugin(initialState: initialState, userPoolFactory: mockIdentityProvider) let hubEventExpectation = expectation(description: "Should receive the hub event") @@ -225,7 +225,7 @@ class AuthHubEventHandlerTests: XCTestCase { func testSocialWebUISignedInHubEvent() async { let mockIdentityProvider = MockIdentityProvider() - let initialState = AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + let initialState = AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured, .notStarted) configurePlugin(initialState: initialState, userPoolFactory: mockIdentityProvider) let hubEventExpectation = expectation(description: "Should receive the hub event") @@ -327,7 +327,7 @@ class AuthHubEventHandlerTests: XCTestCase { session: "session") }) - let initialState = AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + let initialState = AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured, .notStarted) configurePlugin(initialState: initialState, userPoolFactory: mockIdentityProvider) } @@ -338,7 +338,8 @@ class AuthHubEventHandlerTests: XCTestCase { .waitingForAnswer(.testData(), .apiBased(.userSRP), .confirmSignInWithTOTPCode), .smsMfa, .apiBased(.userSRP))), - AuthorizationState.sessionEstablished(.testData)) + AuthorizationState.sessionEstablished(.testData), + .notStarted) let mockIdentityProvider = MockIdentityProvider( mockRespondToAuthChallengeResponse: { _ in @@ -354,7 +355,8 @@ class AuthHubEventHandlerTests: XCTestCase { SignedInData(signedInDate: Date(), signInMethod: .apiBased(.userSRP), cognitoUserPoolTokens: AWSCognitoUserPoolTokens.testData)), - AuthorizationState.sessionEstablished(AmplifyCredentials.testData)) + AuthorizationState.sessionEstablished(AmplifyCredentials.testData), + .notStarted) let mockIdentityProvider = MockIdentityProvider( mockRevokeTokenResponse: { _ in @@ -376,7 +378,8 @@ class AuthHubEventHandlerTests: XCTestCase { SignedInData(signedInDate: Date(), signInMethod: .apiBased(.userSRP), cognitoUserPoolTokens: AWSCognitoUserPoolTokens.testData)), - AuthorizationState.sessionEstablished(AmplifyCredentials.testData)) + AuthorizationState.sessionEstablished(AmplifyCredentials.testData), + .notStarted) let mockIdentityProvider = MockIdentityProvider( mockRevokeTokenResponse: { _ in @@ -393,7 +396,8 @@ class AuthHubEventHandlerTests: XCTestCase { private func configurePluginForFederationEvent() { let initialState = AuthState.configured( AuthenticationState.signedOut(.testData), - AuthorizationState.configured) + AuthorizationState.configured, + .notStarted) let mockIdentityProvider = MockIdentityProvider() @@ -403,7 +407,8 @@ class AuthHubEventHandlerTests: XCTestCase { private func configurePluginForClearedFederationEvent() { let initialState = AuthState.configured( AuthenticationState.federatedToIdentityPool, - AuthorizationState.sessionEstablished(AmplifyCredentials.testData)) + AuthorizationState.sessionEstablished(AmplifyCredentials.testData), + .notStarted) let mockIdentityProvider = MockIdentityProvider() @@ -414,7 +419,8 @@ class AuthHubEventHandlerTests: XCTestCase { let initialState = AuthState.configured( AuthenticationState.signedIn(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataWithExpiredTokens)) + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted) let mockIdentityProvider = MockIdentityProvider( mockInitiateAuthResponse: { _ in diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Mocks/MockCredentialRegistrant.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Mocks/MockCredentialRegistrant.swift new file mode 100644 index 0000000000..27e16941ef --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Mocks/MockCredentialRegistrant.swift @@ -0,0 +1,29 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) + +import Amplify +@testable import AWSCognitoAuthPlugin +import Foundation + +@available(iOS 17.4, macOS 13.5, *) +class MockCredentialRegistrant: CredentialRegistrantProtocol { + var presentationAnchor: AuthUIPresentationAnchor? + + var mockedCreateResponse: Result? + var createCallCount = 0 + func create(with options: CredentialCreationOptions) async throws -> CredentialRegistrationPayload { + createCallCount += 1 + if let mockedCreateResponse { + return try mockedCreateResponse.get() + } + + fatalError("Response for MockCredentialRegistrant.create(with:) not mocked.") + } +} +#endif diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/AuthState/AuthStateConfiguringAuthorization.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/AuthState/AuthStateConfiguringAuthorization.swift index 484853c384..b8062913e0 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/AuthState/AuthStateConfiguringAuthorization.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/AuthState/AuthStateConfiguringAuthorization.swift @@ -16,7 +16,7 @@ class AuthStateConfiguringAuthorization: XCTestCase { let oldState = AuthState.configuringAuthorization(.notConfigured, .notConfigured) func testAuthorizationConfiguredReceived() { - let expected = AuthState.configured(.notConfigured, .notConfigured) + let expected = AuthState.configured(.notConfigured, .notConfigured, .notStarted) let resolution = resolver.resolve(oldState: oldState, byApplying: AuthEvent.authorizationConfigured) XCTAssertEqual(resolution.newState, expected) } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SRPSignInState/SRPTestData.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SRPSignInState/SRPTestData.swift index c34a53b83d..469bddb54d 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SRPSignInState/SRPTestData.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SRPSignInState/SRPTestData.swift @@ -130,6 +130,7 @@ extension RespondToAuthChallenge { parameters: [String: String] = [:]) -> RespondToAuthChallenge { RespondToAuthChallenge( challenge: challenge, + availableChallenges: [], username: username, session: session, parameters: parameters) @@ -149,7 +150,7 @@ extension SignInEvent { static let initiateSRPEvent = SignInEvent( id: "initiateSRPEvent", - eventType: .initiateSignInWithSRP(.testData, .noData) + eventType: .initiateSignInWithSRP(.testData, .noData, nil) ) static let respondPasswordVerifierEvent = SignInEvent( diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SignUpState/ConfirmSignUpInputTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SignUpState/ConfirmSignUpInputTests.swift index 406b48a76c..29bad5efa0 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SignUpState/ConfirmSignUpInputTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/ResolverTests/SignUpState/ConfirmSignUpInputTests.swift @@ -32,6 +32,7 @@ class ConfirmSignUpInputTests: XCTestCase { clientMetadata: [:], asfDeviceId: "asdfDeviceId", forceAliasCreation: nil, + session: nil, environment: environment) XCTAssertNotNil(confirmSignUpInput.secretHash) @@ -57,6 +58,7 @@ class ConfirmSignUpInputTests: XCTestCase { clientMetadata: [:], asfDeviceId: nil, forceAliasCreation: nil, + session: nil, environment: environment) XCTAssertNil(confirmSignUpInput.secretHash) diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/DefaultConfig.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/DefaultConfig.swift index 13e5a8781e..9beaf029c3 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/DefaultConfig.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/DefaultConfig.swift @@ -207,7 +207,7 @@ enum Defaults { let authNState: AuthenticationState = .signedIn(signedInData) let authZState: AuthorizationState = .configured - let authState: AuthState = .configured(authNState, authZState) + let authState: AuthState = .configured(authNState, authZState, .notStarted) return authState } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/MockIdentityProvider.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/MockIdentityProvider.swift index 5558466828..b229705b56 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/MockIdentityProvider.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/Support/MockIdentityProvider.swift @@ -77,6 +77,14 @@ struct MockIdentityProvider: CognitoUserPoolBehavior { typealias MockVerifySoftwareTokenResponse = (VerifySoftwareTokenInput) async throws -> VerifySoftwareTokenOutput + typealias MockListWebAuthnCredentialsResponse = (ListWebAuthnCredentialsInput) async throws -> ListWebAuthnCredentialsOutput + + typealias MockDeleteWebAuthnCredentialResponse = (DeleteWebAuthnCredentialInput) async throws -> DeleteWebAuthnCredentialOutput + + typealias MockStartWebAuthnRegistrationResponse = (StartWebAuthnRegistrationInput) async throws -> StartWebAuthnRegistrationOutput + + typealias MockCompletetWebAuthnRegistrationResponse = (CompleteWebAuthnRegistrationInput) async throws -> CompleteWebAuthnRegistrationOutput + let mockSignUpResponse: MockSignUpResponse? let mockRevokeTokenResponse: MockRevokeTokenResponse? let mockInitiateAuthResponse: MockInitiateAuthResponse? @@ -99,6 +107,10 @@ struct MockIdentityProvider: CognitoUserPoolBehavior { let mockSetUserMFAPreferenceResponse: MockSetUserMFAPreferenceResponse? let mockAssociateSoftwareTokenResponse: MockAssociateSoftwareTokenResponse? let mockVerifySoftwareTokenResponse: MockVerifySoftwareTokenResponse? + var mockListWebAuthnCredentialsResponse: MockListWebAuthnCredentialsResponse? + var mockDeleteWebAuthnCredentialResponse: MockDeleteWebAuthnCredentialResponse? + var mockStartWebAuthnRegistrationResponse: MockStartWebAuthnRegistrationResponse? + var mockCompleteWebAuthnRegistrationResponse: MockCompletetWebAuthnRegistrationResponse? init( mockSignUpResponse: MockSignUpResponse? = nil, @@ -243,4 +255,21 @@ struct MockIdentityProvider: CognitoUserPoolBehavior { func setUserMFAPreference(input: SetUserMFAPreferenceInput) async throws -> SetUserMFAPreferenceOutput { return try await mockSetUserMFAPreferenceResponse!(input) } + + func listWebAuthnCredentials(input: AWSCognitoIdentityProvider.ListWebAuthnCredentialsInput) async throws -> AWSCognitoIdentityProvider.ListWebAuthnCredentialsOutput { + return try await mockListWebAuthnCredentialsResponse!(input) + } + + func deleteWebAuthnCredential(input: AWSCognitoIdentityProvider.DeleteWebAuthnCredentialInput) async throws -> AWSCognitoIdentityProvider.DeleteWebAuthnCredentialOutput { + return try await mockDeleteWebAuthnCredentialResponse!(input) + } + + func startWebAuthnRegistration(input: AWSCognitoIdentityProvider.StartWebAuthnRegistrationInput) async throws -> AWSCognitoIdentityProvider.StartWebAuthnRegistrationOutput { + return try await mockStartWebAuthnRegistrationResponse!(input) + } + + func completeWebAuthnRegistration(input: AWSCognitoIdentityProvider.CompleteWebAuthnRegistrationInput) async throws -> AWSCognitoIdentityProvider.CompleteWebAuthnRegistrationOutput { + return try await mockCompleteWebAuthnRegistrationResponse!(input) + } + } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFederationToIdentityPoolTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFederationToIdentityPoolTests.swift index 604c0b24d8..03b6669c6f 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFederationToIdentityPoolTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFederationToIdentityPoolTests.swift @@ -68,25 +68,31 @@ class AWSAuthFederationToIdentityPoolTests: BaseAuthorizationTests { AuthState.configured( AuthenticationState.signedOut(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataIdentityPoolWithExpiredTokens)), + AmplifyCredentials.testDataIdentityPoolWithExpiredTokens), + .notStarted), AuthState.configured( AuthenticationState.notConfigured, AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataIdentityPoolWithExpiredTokens)), + AmplifyCredentials.testDataIdentityPoolWithExpiredTokens), + .notStarted), AuthState.configured( AuthenticationState.federatedToIdentityPool, AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataWithExpiredAWSCredentials)), + AmplifyCredentials.testDataWithExpiredAWSCredentials), + .notStarted), AuthState.configured( AuthenticationState.notConfigured, - AuthorizationState.configured), + AuthorizationState.configured, + .notStarted), AuthState.configured( AuthenticationState.error(.testData), - AuthorizationState.configured), + AuthorizationState.configured, + .notStarted), AuthState.configured( AuthenticationState.signedOut(.testData), AuthorizationState.error(.sessionExpired( - error: NotAuthorizedException(message: "message")))) + error: NotAuthorizedException(message: "message"))), + .notStarted) ] for initialState in statesToTest { @@ -160,7 +166,8 @@ class AWSAuthFederationToIdentityPoolTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signedOut(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataIdentityPoolWithExpiredTokens)) + AmplifyCredentials.testDataIdentityPoolWithExpiredTokens), + .notStarted) let plugin = configurePluginWith( identityPool: { MockIdentity( @@ -220,10 +227,12 @@ class AWSAuthFederationToIdentityPoolTests: BaseAuthorizationTests { let statesToTest = [ AuthState.configured( AuthenticationState.signedOut(.testData), - AuthorizationState.notConfigured), + AuthorizationState.notConfigured, + .notStarted), AuthState.configured( AuthenticationState.signedIn(.testData), - AuthorizationState.configured) + AuthorizationState.configured, + .notStarted) ] for initialState in statesToTest { @@ -283,7 +292,8 @@ class AWSAuthFederationToIdentityPoolTests: BaseAuthorizationTests { }, initialState: AuthState.configured( AuthenticationState.error(.testData), - AuthorizationState.error(.invalidState(message: "")))) + AuthorizationState.error(.invalidState(message: "")), + .notStarted)) do { // Should setup the plugin with a token shouldThrowError = false @@ -334,10 +344,12 @@ class AWSAuthFederationToIdentityPoolTests: BaseAuthorizationTests { AuthorizationState.sessionEstablished(.identityPoolWithFederation( federatedToken: .testData, identityID: "identityId", - credentials: .testData))), + credentials: .testData)), + .notStarted), AuthState.configured( AuthenticationState.error(.testData), - AuthorizationState.error(.invalidState(message: ""))) + AuthorizationState.error(.invalidState(message: "")), + .notStarted) ] for initialState in statesToTest { @@ -382,13 +394,15 @@ class AWSAuthFederationToIdentityPoolTests: BaseAuthorizationTests { let statesToTest = [ AuthState.configured( AuthenticationState.federatedToIdentityPool, - AuthorizationState.configured), + AuthorizationState.configured, + .notStarted), AuthState.configured( AuthenticationState.configured, AuthorizationState.sessionEstablished(.identityPoolWithFederation( federatedToken: .testData, identityID: "identityId", - credentials: .testData))) + credentials: .testData)), + .notStarted) ] for initialState in statesToTest { @@ -464,7 +478,8 @@ class AWSAuthFederationToIdentityPoolTests: BaseAuthorizationTests { .identityPoolWithFederation( federatedToken: federatedToken, identityID: mockIdentityId, - credentials: .expiredTestData))) + credentials: .expiredTestData)), + .notStarted) let plugin = configurePluginWith( identityPool: { @@ -544,7 +559,8 @@ class AWSAuthFederationToIdentityPoolTests: BaseAuthorizationTests { .identityPoolWithFederation( federatedToken: federatedToken, identityID: mockIdentityId, - credentials: .testData))) + credentials: .testData)), + .notStarted) let plugin = configurePluginWith( identityPool: { @@ -635,7 +651,8 @@ class AWSAuthFederationToIdentityPoolTests: BaseAuthorizationTests { .identityPoolWithFederation( federatedToken: federatedToken, identityID: mockIdentityId, - credentials: .expiredTestData))) + credentials: .expiredTestData)), + .notStarted) let plugin = configurePluginWith( identityPool: { @@ -719,18 +736,22 @@ class AWSAuthFederationToIdentityPoolTests: BaseAuthorizationTests { AuthState.configured( AuthenticationState.signedOut(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataIdentityPoolWithExpiredTokens)), + AmplifyCredentials.testDataIdentityPoolWithExpiredTokens), + .notStarted), AuthState.configured( AuthenticationState.notConfigured, AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataIdentityPoolWithExpiredTokens)), + AmplifyCredentials.testDataIdentityPoolWithExpiredTokens), + .notStarted), AuthState.configured( AuthenticationState.federatedToIdentityPool, AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataWithExpiredAWSCredentials)), + AmplifyCredentials.testDataWithExpiredAWSCredentials), + .notStarted), AuthState.configured( AuthenticationState.notConfigured, - AuthorizationState.configured) + AuthorizationState.configured, + .notStarted) ] for initialState in statesToTest { @@ -797,7 +818,8 @@ class AWSAuthFederationToIdentityPoolTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signedOut(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataIdentityPoolWithExpiredTokens)) + AmplifyCredentials.testDataIdentityPoolWithExpiredTokens), + .notStarted) let plugin = configurePluginWith( identityPool: { @@ -877,7 +899,8 @@ class AWSAuthFederationToIdentityPoolTests: BaseAuthorizationTests { .identityPoolWithFederation( federatedToken: federatedToken, identityID: mockIdentityId, - credentials: .expiredTestData))) + credentials: .expiredTestData)), + .notStarted) let plugin = configurePluginWith( identityPool: { diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFetchSignInSessionOperationTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFetchSignInSessionOperationTests.swift index 6106a4717b..7373a81917 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFetchSignInSessionOperationTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/AuthorizationTests/AWSAuthFetchSignInSessionOperationTests.swift @@ -37,7 +37,8 @@ class AWSAuthFetchSignInSessionOperationTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signedIn(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testData)) + AmplifyCredentials.testData), + .notStarted) let getId: MockIdentity.MockGetIdResponse = { _ in return .init(identityId: "mockIdentityId") @@ -92,7 +93,8 @@ class AWSAuthFetchSignInSessionOperationTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signedIn(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testData)) + AmplifyCredentials.testData), + .notStarted) let initAuth: MockIdentityProvider.MockInitiateAuthResponse = { _ in resultExpectation.fulfill() return InitiateAuthOutput(authenticationResult: .init( @@ -152,7 +154,8 @@ class AWSAuthFetchSignInSessionOperationTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signedOut(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataIdentityPoolWithExpiredTokens)) + AmplifyCredentials.testDataIdentityPoolWithExpiredTokens), + .notStarted) let getId: MockIdentity.MockGetIdResponse = { _ in return .init(identityId: "mockIdentityId") @@ -206,7 +209,8 @@ class AWSAuthFetchSignInSessionOperationTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signedIn(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataWithExpiredTokens)) + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted) let initAuth: MockIdentityProvider.MockInitiateAuthResponse = { _ in throw AWSCognitoIdentityProvider.NotAuthorizedException() @@ -254,7 +258,8 @@ class AWSAuthFetchSignInSessionOperationTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signedIn(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataWithExpiredTokens)) + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted) let initAuth: MockIdentityProvider.MockInitiateAuthResponse = { _ in return InitiateAuthOutput(authenticationResult: .init(accessToken: "accessToken", @@ -486,7 +491,8 @@ class AWSAuthFetchSignInSessionOperationTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signedIn(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataWithExpiredTokens)) + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted) let initAuth: MockIdentityProvider.MockInitiateAuthResponse = { _ in return InitiateAuthOutput(authenticationResult: .init(accessToken: nil, @@ -539,7 +545,8 @@ class AWSAuthFetchSignInSessionOperationTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signedIn(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataWithExpiredTokens)) + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted) let initAuth: MockIdentityProvider.MockInitiateAuthResponse = { _ in return InitiateAuthOutput(authenticationResult: .init(accessToken: "accessToken", @@ -596,7 +603,8 @@ class AWSAuthFetchSignInSessionOperationTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signedOut(.testData), - AuthorizationState.error(.sessionError(.service(AuthError.unknown("error")), .noCredentials))) + AuthorizationState.error(.sessionError(.service(AuthError.unknown("error")), .noCredentials)), + .notStarted) let getId: MockIdentity.MockGetIdResponse = { _ in return .init(identityId: "mockIdentityId") @@ -650,7 +658,8 @@ class AWSAuthFetchSignInSessionOperationTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signedOut(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataIdentityPoolWithExpiredTokens)) + AmplifyCredentials.testDataIdentityPoolWithExpiredTokens), + .notStarted) let awsCredentials: MockIdentity.MockGetCredentialsResponse = { _ in let credentials = CognitoIdentityClientTypes.Credentials(accessKeyId: "accessKey", @@ -702,7 +711,8 @@ class AWSAuthFetchSignInSessionOperationTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signedIn(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataWithExpiredTokens)) + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted) let initAuth: MockIdentityProvider.MockInitiateAuthResponse = { _ in throw AWSCognitoIdentityProvider.NotAuthorizedException(message: "NotAuthorized") @@ -755,7 +765,8 @@ class AWSAuthFetchSignInSessionOperationTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signingIn( .resolvingChallenge(challenge, .smsMfa, signInMethod)), - AuthorizationState.configured + AuthorizationState.configured, + .notStarted ) let getId: MockIdentity.MockGetIdResponse = { _ in @@ -802,7 +813,8 @@ class AWSAuthFetchSignInSessionOperationTests: BaseAuthorizationTests { let initialState = AuthState.configured( AuthenticationState.signedIn(.testData), AuthorizationState.sessionEstablished( - AmplifyCredentials.testDataWithExpiredTokens)) + AmplifyCredentials.testDataWithExpiredTokens), + .notStarted) let initAuth: MockIdentityProvider.MockInitiateAuthResponse = { _ in return InitiateAuthOutput(authenticationResult: .init(accessToken: "accessToken", diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthSignOutTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthSignOutTaskTests.swift index e940246c43..769b3e3ef4 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthSignOutTaskTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSAuthSignOutTaskTests.swift @@ -19,7 +19,8 @@ class AWSAuthSignOutTaskTests: BasePluginTest { override var initialState: AuthState { AuthState.configured( AuthenticationState.signedIn(.testData), - AuthorizationState.sessionEstablished(.testData)) + AuthorizationState.sessionEstablished(.testData), + .notStarted) } func testSuccessfullSignOut() async { @@ -79,7 +80,8 @@ class AWSAuthSignOutTaskTests: BasePluginTest { let initialState = AuthState.configured( AuthenticationState.federatedToIdentityPool, - AuthorizationState.sessionEstablished(.testData)) + AuthorizationState.sessionEstablished(.testData), + .notStarted) let authPlugin = configureCustomPluginWith(initialState: initialState) @@ -98,7 +100,8 @@ class AWSAuthSignOutTaskTests: BasePluginTest { let initialState = AuthState.configured( AuthenticationState.signedIn(.hostedUISignInData), - AuthorizationState.sessionEstablished(.hostedUITestData)) + AuthorizationState.sessionEstablished(.hostedUITestData), + .notStarted) let authPlugin = configureCustomPluginWith(initialState: initialState) @@ -118,7 +121,8 @@ class AWSAuthSignOutTaskTests: BasePluginTest { let initialState = AuthState.configured( AuthenticationState.signedOut(.init()), - AuthorizationState.sessionEstablished(.testDataIdentityPool)) + AuthorizationState.sessionEstablished(.testDataIdentityPool), + .notStarted) let authPlugin = configureCustomPluginWith(initialState: initialState) diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSCognitoAuthClientBehaviorTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSCognitoAuthClientBehaviorTests.swift index 9b17dbf1dc..86772e7b4f 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSCognitoAuthClientBehaviorTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AWSCognitoAuthClientBehaviorTests.swift @@ -24,7 +24,8 @@ class AWSCognitoAuthClientBehaviorTests: XCTestCase { signedInDate: Date(), signInMethod: .apiBased(.userSRP), cognitoUserPoolTokens: AWSCognitoUserPoolTokens.testData)), - AuthorizationState.sessionEstablished(AmplifyCredentials.testData)) + AuthorizationState.sessionEstablished(AmplifyCredentials.testData), + .notStarted) } override func setUp() { diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AuthenticationProviderDeleteUserTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AuthenticationProviderDeleteUserTests.swift index fcdd702657..c59b47db29 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AuthenticationProviderDeleteUserTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/AuthenticationProviderDeleteUserTests.swift @@ -428,7 +428,7 @@ class AuthenticationProviderDeleteUserTests: BasePluginTest { } switch await plugin.authStateMachine.currentState { - case .configured(let authNState, let authZState): + case .configured(let authNState, let authZState, _): switch (authNState, authZState) { case (.signedOut, .configured): print("AuthN and AuthZ are in a valid state") diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthAutoSignInTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthAutoSignInTests.swift new file mode 100644 index 0000000000..48d5cc6970 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthAutoSignInTests.swift @@ -0,0 +1,657 @@ +// +// 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 +@_spi(UnknownAWSHTTPServiceError) import AWSClientRuntime + +class AWSAuthAutoSignInTests: BasePluginTest { + + override var initialState: AuthState { + AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .signedUp( + .init(username: "jeffb", session: "session"), + .init(.completeAutoSignIn("session")))) + } + + /// Test successful auto sign in + /// + /// - Given: Given an auth plugin with mocked service and in signed up state + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a successful result with tokens + /// + func testSuccessfulAutoSignIn() async { + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { input in + return InitiateAuthOutput( + authenticationResult: .init( + accessToken: Defaults.validAccessToken, + expiresIn: 300, + idToken: "idToken", + newDeviceMetadata: nil, + refreshToken: "refreshToken", + tokenType: "")) + }) + + do { + let result = try await plugin.autoSignIn() + guard case .done = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignedIn, "Signin result should be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + + /// Test auto sign in success + /// + /// - Given: Given an auth plugin with mocked service and in `.signingIn` authentication state and + /// `.signedUp` sign up state + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a successful result with tokens + /// + func testAutoSignInSuccessFromSigningInAuthenticationState() async { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init( + codeDeliveryDetails: .init( + attributeName: "some attribute", + deliveryMedium: .email, + destination: "jeffb@amazon.com" + ), + userConfirmed: false, + userSub: "userSub" + ) + }, + mockInitiateAuthResponse: { input in + return InitiateAuthOutput( + authenticationResult: .init( + accessToken: Defaults.validAccessToken, + expiresIn: 300, + idToken: "idToken", + newDeviceMetadata: nil, + refreshToken: "refreshToken", + tokenType: "")) + }, + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init(session: "session") + } + ) + + let initialStateSigningIn = AuthState.configured( + .signingIn(.resolvingChallenge( + .waitingForAnswer( + .init( + challenge: .emailOtp, + availableChallenges: [.emailOtp], + username: "jeffb", + session: nil, + parameters: nil), + .apiBased(.userAuth), + .confirmSignInWithOTP(.init(destination: .email("jeffb@amazon.com")))), + .emailOTP, + .apiBased(.userAuth))), + .configured, + .signedUp( + .init(username: "jeffb", session: "session"), + .init(.completeAutoSignIn("session")))) + + let authPluginSigningIn = configureCustomPluginWith(userPool: { mockIdentityProvider }, + initialState: initialStateSigningIn) + + do { + let result = try await authPluginSigningIn.autoSignIn() + guard case .done = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignedIn, "Signin result should be complete") + } catch { + XCTFail("Received failure with error \(error)") + } + } + + /// Test auto sign in failure + /// + /// - Given: Given an auth plugin with mocked service and in `.notStarted` sign up state + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a failure with error response + /// + func testAutoSignInFailureFromNotStartedState() async { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init( + codeDeliveryDetails: .init( + attributeName: "some attribute", + deliveryMedium: .email, + destination: "" + ), + userConfirmed: false, + userSub: "userSub" + ) + }, + mockInitiateAuthResponse: { input in + return InitiateAuthOutput( + authenticationResult: .init( + accessToken: Defaults.validAccessToken, + expiresIn: 300, + idToken: "idToken", + newDeviceMetadata: nil, + refreshToken: "refreshToken", + tokenType: "")) + }, + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init(session: "session") + } + ) + + let initialStateNotStarted = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .notStarted) + + + let authPluginNotStarted = configureCustomPluginWith(userPool: { mockIdentityProvider }, + initialState: initialStateNotStarted) + do { + let _ = try await authPluginNotStarted.autoSignIn() + XCTFail("Auto sign in should not be successful from .notStarted state") + } catch { + XCTAssertNotNil(error) + } + } + + /// Test auto sign in failure + /// + /// - Given: Given an auth plugin with mocked service and in `.initiatingSignUp` sign up state + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a failure with error response + /// + func testAutoSignInFailureFromInitiatingSignUpState() async { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init( + codeDeliveryDetails: .init( + attributeName: "some attribute", + deliveryMedium: .email, + destination: "" + ), + userConfirmed: false, + userSub: "userSub" + ) + }, + mockInitiateAuthResponse: { input in + return InitiateAuthOutput( + authenticationResult: .init( + accessToken: Defaults.validAccessToken, + expiresIn: 300, + idToken: "idToken", + newDeviceMetadata: nil, + refreshToken: "refreshToken", + tokenType: "")) + }, + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init(session: "session") + } + ) + + let initialStateInitiatingSignUp = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .initiatingSignUp(.init(username: "user"))) + + let authPluginInitiatingSignUp = configureCustomPluginWith(userPool: { mockIdentityProvider }, + initialState: initialStateInitiatingSignUp) + + do { + let _ = try await authPluginInitiatingSignUp.autoSignIn() + XCTFail("Auto sign in should not be successful from .initiatingSignUp state") + } catch { + XCTAssertNotNil(error) + } + } + + /// Test auto sign in failure + /// + /// - Given: Given an auth plugin with mocked service and in `.awaitingUserConfirmation` sign up state + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a failure with error response + /// + func testAutoSignInFailureFromAwaitingUserConfirmationState() async { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init( + codeDeliveryDetails: .init( + attributeName: "some attribute", + deliveryMedium: .email, + destination: "" + ), + userConfirmed: false, + userSub: "userSub" + ) + }, + mockInitiateAuthResponse: { input in + return InitiateAuthOutput( + authenticationResult: .init( + accessToken: Defaults.validAccessToken, + expiresIn: 300, + idToken: "idToken", + newDeviceMetadata: nil, + refreshToken: "refreshToken", + tokenType: "")) + }, + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init(session: "session") + } + ) + + let initialStateAwaitingUserConfirmation = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .awaitingUserConfirmation(.init(username: "user"), .init(.completeAutoSignIn("session")))) + + let authPluginAwaitingUserConfirmation = configureCustomPluginWith(userPool: { mockIdentityProvider }, + initialState: initialStateAwaitingUserConfirmation) + + do { + let _ = try await authPluginAwaitingUserConfirmation.autoSignIn() + XCTFail("Auto sign in should not be successful from .awaitingUserConfirmation state") + } catch { + XCTAssertNotNil(error) + } + } + + /// Test auto sign in failure + /// + /// - Given: Given an auth plugin with mocked service and in `.confirmingSignUp` sign up state + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a failure with error response + /// + func testAutoSignInFailureFromConfirmingSignUpState() async { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init( + codeDeliveryDetails: .init( + attributeName: "some attribute", + deliveryMedium: .email, + destination: "" + ), + userConfirmed: false, + userSub: "userSub" + ) + }, + mockInitiateAuthResponse: { input in + return InitiateAuthOutput( + authenticationResult: .init( + accessToken: Defaults.validAccessToken, + expiresIn: 300, + idToken: "idToken", + newDeviceMetadata: nil, + refreshToken: "refreshToken", + tokenType: "")) + }, + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init(session: "session") + } + ) + + let initialStateConfirmingSignUp = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .confirmingSignUp(.init(username: "user"))) + + let authPluginConfirmingSignUp = configureCustomPluginWith(userPool: { mockIdentityProvider }, + initialState: initialStateConfirmingSignUp) + + do { + let _ = try await authPluginConfirmingSignUp.autoSignIn() + XCTFail("Auto sign in should not be successful from .confirmingSignUp state") + } catch { + XCTAssertNotNil(error) + } + } + + /// Test auto sign in failure + /// + /// - Given: Given an auth plugin with mocked service and in `.error` sign up state + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a failure with error response + /// + func testAutoSignInFailureFromErrorState() async { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init( + codeDeliveryDetails: .init( + attributeName: "some attribute", + deliveryMedium: .email, + destination: "" + ), + userConfirmed: false, + userSub: "userSub" + ) + }, + mockInitiateAuthResponse: { input in + return InitiateAuthOutput( + authenticationResult: .init( + accessToken: Defaults.validAccessToken, + expiresIn: 300, + idToken: "idToken", + newDeviceMetadata: nil, + refreshToken: "refreshToken", + tokenType: "")) + }, + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init(session: "session") + } + ) + + let initialStateError = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .error(.service(error: AuthError.service("Unknown error", "Unknown error")))) + + let authPluginError = configureCustomPluginWith(userPool: { mockIdentityProvider }, + initialState: initialStateError) + + do { + let _ = try await authPluginError.autoSignIn() + XCTFail("Auto sign in should not be successful from .error state") + } catch { + XCTAssertNotNil(error) + } + } + + // MARK: - Service error for initiateAuth + + /// Test a autoSignIn with an `InternalErrorException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock an + /// InternalErrorException response for autoSignIn + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get an error of .service type + /// + func testAutoSignInWithInternalErrorException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + throw AWSCognitoIdentityProvider.InternalErrorException() + }) + + do { + let result = try await plugin.autoSignIn() + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.unknown = error else { + XCTFail("Should produce unknown error") + return + } + } + } + + /// Test a autoSignIn with `InvalidLambdaResponseException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// InvalidLambdaResponseException response for autoSignIn + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a .service error with .lambda error + /// + func testSignInWithInvalidLambdaResponseException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + throw AWSCognitoIdentityProvider.InvalidLambdaResponseException() + }) + + do { + let result = try await plugin.autoSignIn() + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error, + case .lambda = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Should produce lambda error but instead produced \(error)") + return + } + } + } + + /// Test a autoSignIn with `InvalidParameterException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// InvalidParameterException response for autoSignIn + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a .service error with .invalidParameter error + /// + func testSignInWithInvalidParameterException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + throw AWSCognitoIdentityProvider.InvalidParameterException() + }) + + do { + let result = try await plugin.autoSignIn() + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error, + case .invalidParameter = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Should produce invalidParameter error but instead produced \(error)") + return + } + } + } + + /// Test a autoSignIn with `InvalidUserPoolConfigurationException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// InvalidUserPoolConfigurationException response for autoSignIn + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a .configuration error + /// + func testSignInWithInvalidUserPoolConfigurationException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + throw AWSCognitoIdentityProvider.InvalidUserPoolConfigurationException() + }) + + do { + let result = try await plugin.autoSignIn() + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.configuration = error else { + XCTFail("Should produce configuration intead produced \(error)") + return + } + } + } + + /// Test a autoSignIn with `NotAuthorizedException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// NotAuthorizedException response for autoSignIn + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a .notAuthorized error + /// + func testSignInWithNotAuthorizedException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + throw AWSCognitoIdentityProvider.NotAuthorizedException() + }) + + do { + let result = try await plugin.autoSignIn() + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.notAuthorized = error else { + XCTFail("Should produce notAuthorized error but instead produced \(error)") + return + } + } + } + + /// Test a autoSignIn with `ResourceNotFoundException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// ResourceNotFoundException response for autoSignIn + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a .service error with .resourceNotFound error + /// + func testSignInWithResourceNotFoundException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + throw AWSCognitoIdentityProvider.ResourceNotFoundException() + }) + + do { + let result = try await plugin.autoSignIn() + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error, + case .resourceNotFound = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Should produce resourceNotFound error but instead produced \(error)") + return + } + } + } + + /// Test a autoSignIn with `TooManyRequestsException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// TooManyRequestsException response for autoSignIn + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a .service error with .requestLimitExceeded error + /// + func testSignInWithTooManyRequestsException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + throw AWSCognitoIdentityProvider.TooManyRequestsException() + }) + + do { + let result = try await plugin.autoSignIn() + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error, + case .requestLimitExceeded = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Should produce requestLimitExceeded error but instead produced \(error)") + return + } + } + } + + /// Test a autoSignIn with `UnexpectedLambdaException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// UnexpectedLambdaException response for autoSignIn + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a .service error with .lambda error + /// + func testSignInWithUnexpectedLambdaException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + throw AWSCognitoIdentityProvider.UnexpectedLambdaException() + }) + + do { + let result = try await plugin.autoSignIn() + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error, + case .lambda = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Should produce lambda error but instead produced \(error)") + return + } + } + } + + /// Test a autoSignIn with `UserLambdaValidationException` from service + /// + /// - Given: Given an auth plugin with mocked service. Mocked service should mock a + /// UserLambdaValidationException response for autoSignIn + /// + /// - When: + /// - I invoke autoSignIn + /// - Then: + /// - I should get a .service error with .lambda error + /// + func testSignInWithUserLambdaValidationException() async { + + self.mockIdentityProvider = MockIdentityProvider(mockInitiateAuthResponse: { _ in + throw AWSCognitoIdentityProvider.UserLambdaValidationException() + }) + + do { + let result = try await plugin.autoSignIn() + XCTFail("Should not produce result - \(result)") + } catch { + guard case AuthError.service(_, _, let underlyingError) = error, + case .lambda = (underlyingError as? AWSCognitoAuthError) else { + XCTFail("Should produce lambda error but instead produced \(error)") + return + } + } + } +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInTaskTests.swift index 7e1a3ee6d1..b4427b8f5c 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInTaskTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthConfirmSignInTaskTests.swift @@ -21,7 +21,8 @@ class AuthenticationProviderConfirmSigninTests: BasePluginTest { AuthenticationState.signingIn( .resolvingChallenge(.waitingForAnswer(.testData(), .apiBased(.userSRP), .confirmSignInWithTOTPCode), .smsMfa, .apiBased(.userSRP))), - AuthorizationState.sessionEstablished(.testData)) + AuthorizationState.sessionEstablished(.testData), + .notStarted) } /// Test a successful confirmSignIn call with .done as next step diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthMigrationSignInTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthMigrationSignInTaskTests.swift index de2caa7054..1ff57378a0 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthMigrationSignInTaskTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthMigrationSignInTaskTests.swift @@ -18,7 +18,7 @@ class AWSAuthMigrationSignInTaskTests: XCTestCase { let networkTimeout = TimeInterval(5) var mockIdentityProvider: CognitoUserPoolBehavior! - let initialState = AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + let initialState = AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured, .notStarted) var plugin: AWSCognitoAuthPlugin! override func setUp() { diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInOptionsTestCase.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInOptionsTestCase.swift index 3968b20914..b0e28dabd8 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInOptionsTestCase.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInOptionsTestCase.swift @@ -14,7 +14,7 @@ import ClientRuntime class AWSAuthSignInOptionsTestCase: BasePluginTest { override var initialState: AuthState { - AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured, .notStarted) } override func setUp() { @@ -176,7 +176,7 @@ class AWSAuthSignInOptionsTestCase: BasePluginTest { fileprivate extension AuthState { var authenticationState: AuthenticationState? { - if case .configured(let authenticationState, _) = self { + if case .configured(let authenticationState, _, _) = self { return authenticationState } return nil diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInPluginTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInPluginTests.swift index 22ae40ce2c..5056ffb143 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInPluginTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/AWSAuthSignInPluginTests.swift @@ -15,7 +15,7 @@ import ClientRuntime class AWSAuthSignInPluginTests: BasePluginTest { override var initialState: AuthState { - AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured, .notStarted) } /// Test a signIn with valid inputs diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInTOTPTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInTOTPTaskTests.swift index 473d673a4a..e52db26e62 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInTOTPTaskTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInTOTPTaskTests.swift @@ -27,7 +27,8 @@ class ConfirmSignInTOTPTaskTests: BasePluginTest { ), .totpMFA, .apiBased(.userSRP))), - AuthorizationState.sessionEstablished(.testData)) + AuthorizationState.sessionEstablished(.testData), + .notStarted) } /// Test a successful confirmSignIn call with .done as next step diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithMFASelectionTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithMFASelectionTaskTests.swift index edb5b8687e..df11e79ca3 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithMFASelectionTaskTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithMFASelectionTaskTests.swift @@ -27,7 +27,8 @@ class ConfirmSignInWithMFASelectionTaskTests: BasePluginTest { ), .selectMFAType, .apiBased(.userSRP))), - AuthorizationState.sessionEstablished(.testData)) + AuthorizationState.sessionEstablished(.testData), + .notStarted) } /// Test a successful confirmSignIn call with .confirmSignInWithSMSMFACode as next step diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithSetUpMFATaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithSetUpMFATaskTests.swift index b0ab9ad69a..fad2317352 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithSetUpMFATaskTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/ConfirmSignInWithSetUpMFATaskTests.swift @@ -25,7 +25,8 @@ class ConfirmSignInWithSetUpMFATaskTests: BasePluginTest { session: "session", username: "username")), .testData)), - AuthorizationState.sessionEstablished(.testData)) + AuthorizationState.sessionEstablished(.testData), + .notStarted) } /// Test a successful confirmSignIn call with .done as next step diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/EmailMFATests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/EmailMFATests.swift index 92e34f6898..72838e7a08 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/EmailMFATests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/EmailMFATests.swift @@ -15,7 +15,7 @@ import AWSClientRuntime class EmailMFATests: BasePluginTest { override var initialState: AuthState { - AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured, .notStarted) } /// Test a signIn with valid inputs getting continueSignInWithMFASetupSelection challenge diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/SignInSetUpTOTPTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/SignInSetUpTOTPTests.swift index e834f3b2ee..2e09570de8 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/SignInSetUpTOTPTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignIn/SignInSetUpTOTPTests.swift @@ -15,7 +15,7 @@ import AWSCognitoIdentityProvider class SignInSetUpTOTPTests: BasePluginTest { override var initialState: AuthState { - AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured, .notStarted) } /// Test a signIn with valid inputs getting continueSignInWithTOTPSetup challenge diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpAPITests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpAPITests.swift index 0dea576291..749ae40bde 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpAPITests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpAPITests.swift @@ -18,10 +18,16 @@ class AWSAuthConfirmSignUpAPITests: BasePluginTest { let options = AuthConfirmSignUpRequest.Options() override var initialState: AuthState { - AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .awaitingUserConfirmation(SignUpEventData(username: "jeffb"), .init(.confirmUser()))) } - func testSuccessfulSignUp() async throws { + /// Given: Configured auth machine in `.awaitingUserConfirmation` sign up state and a mocked success response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: Confirm Sign up is complete with `.done` as the next step + func testSuccessfulConfirmSignUp() async throws { self.mockIdentityProvider = MockIdentityProvider( mockConfirmSignUpResponse: { request in @@ -42,8 +48,271 @@ class AWSAuthConfirmSignUpAPITests: BasePluginTest { } XCTAssertTrue(result.isSignUpComplete, "Signin result should be complete") } + + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked success response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: Confirm Sign up is complete with `.done` as the next step + func testSuccessfulConfirmSignUpFromNotStartedState() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init() + } + ) + + let initialStateAwaitingNotStarted = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .notStarted) + + + let authPluginNotStarted = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialStateAwaitingNotStarted + ) + + let result = try await authPluginNotStarted.confirmSignUp( + for: "jeffb", + confirmationCode: "123456", + options: options) + + guard case .done = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Signin result should be complete") + } + + /// Given: Configured auth machine in `.signedUp` sign up state and a mocked success response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: Confirm Sign up is complete with `.done` as the next step + func testSuccessfulConfirmSignUpFromSignedUpState() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init() + } + ) + + let initialStateSignedUp = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .signedUp(.init(username: "user1"), .init(.done))) + + + let authPluginSignedUp = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialStateSignedUp + ) + + let result2 = try await authPluginSignedUp.confirmSignUp( + for: "jeffb", + confirmationCode: "123456", + options: options) + + guard case .done = result2.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result2.isSignUpComplete, "Signin result should be complete") + } + + /// Given: Configured auth machine in `.error` sign up state and a mocked success response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: Confirm Sign up is complete with `.done` as the next step + func testSuccessfulConfirmSignUpFromErrorState() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init() + } + ) + + let initialStateError = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .error(.service(error: AuthError.service("Unknown error", "Unknown error")))) + + let authPluginError = configureCustomPluginWith(userPool: { mockIdentityProvider }, + initialState: initialStateError) + + let result = try await authPluginError.confirmSignUp( + for: "jeffb", + confirmationCode: "123456", + options: options) + + guard case .done = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Signin result should be complete") + } + + /// Given: Configured auth machine in `.awaitingUserConfirmation` sign up state and a mocked success response + /// with a `session` string + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: Confirm Sign up is complete with `.completeAutoSignIn` as the next step + func testSuccessfulPasswordlessConfirmSignUp() async throws { + + self.mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init(session: "session") + } + ) + + let result = try await self.plugin.confirmSignUp( + for: "jeffb", + confirmationCode: "123456", + options: options) + + guard case .completeAutoSignIn(let session) = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Signin result should be complete") + XCTAssertEqual(session, "session") + } + + /// Given: Configured auth machine in `.notStarted` and a mocked success response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: Confirm Sign up is complete with `.completeAutoSignIn` as the next step + func testSuccessfulPasswordlessConfirmSignUpFromNotStartedState() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init(session: "session") + } + ) + + let initialStateAwaitingNotStarted = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .notStarted) + + + let authPluginNotStarted = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialStateAwaitingNotStarted + ) + + let result = try await authPluginNotStarted.confirmSignUp( + for: "jeffb", + confirmationCode: "123456", + options: options) + + guard case .completeAutoSignIn(let session) = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Signin result should be complete") + XCTAssertEqual(session, "session") + } + + /// Given: Configured auth machine in `.notStarted` and a mocked success response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: Confirm Sign up is complete with `.completeAutoSignIn` as the next step + func testSuccessfulPasswordlessConfirmSignUpFromSignedUpState() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init(session: "session") + } + ) + + let initialStateSignedUp = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .signedUp(.init(username: "user1"), .init(.done))) + + + let authPluginSignedUp = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialStateSignedUp + ) + + let result = try await authPluginSignedUp.confirmSignUp( + for: "jeffb", + confirmationCode: "123456", + options: options) + + guard case .completeAutoSignIn(let session) = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Signin result should be complete") + XCTAssertEqual(session, "session") + } + + /// Given: Configured auth machine in `.notStarted` and a mocked success response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: Confirm Sign up is complete with `.completeAutoSignIn` as the next step + func testSuccessfulPasswordlessConfirmSignUpFromErrorState() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init(session: "session") + } + ) + + let initialStateError = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .error(.service(error: AuthError.service("Unknown error", "Unknown error")))) + + let authPluginError = configureCustomPluginWith(userPool: { mockIdentityProvider }, + initialState: initialStateError) + + let result = try await authPluginError.confirmSignUp( + for: "jeffb", + confirmationCode: "123456", + options: options) + + guard case .completeAutoSignIn(let session) = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Signin result should be complete") + XCTAssertEqual(session, "session") + } + + /// Given: Configured auth machine in `.awaitingUserConfirmation` sign up state and a mocked success response + /// with a `nil` session string + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: Confirm Sign up is complete with `.done` as the next step + func testSuccessfulPasswordlessConfirmSignUpWithNilSession() async throws { + + self.mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init(session: nil) + } + ) + + let result = try await self.plugin.confirmSignUp( + for: "jeffb", + confirmationCode: "123456", + options: options) + + guard case .done = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Signin result should be complete") + } - func testSuccessfulSignUpWithOptions() async throws { + /// Given: Configured auth machine in `.awaitingUserConfirmation` sign up state and a mocked success response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked with options + /// Then: Confirm Sign up is complete with `.done` as the next step + func testSuccessfulConfirmSignUpWithOptions() async throws { self.mockIdentityProvider = MockIdentityProvider( mockConfirmSignUpResponse: { request in @@ -70,7 +339,10 @@ class AWSAuthConfirmSignUpAPITests: BasePluginTest { XCTAssertTrue(result.isSignUpComplete, "Signin result should be complete") } - func testSignUpWithEmptyUsername() async { + /// Given: Configured auth machine in `.awaitingUserConfirmation` sign up state and a mocked success response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked with empty username + /// Then: Confirm Sign up fails with error + func testConfirmSignUpWithEmptyUsername() async { self.mockIdentityProvider = MockIdentityProvider( mockConfirmSignUpResponse: { _ in @@ -94,7 +366,10 @@ class AWSAuthConfirmSignUpAPITests: BasePluginTest { } } - func testSignUpWithEmptyConfirmationCode() async { + /// Given: Configured auth machine in `.awaitingUserConfirmation` sign up state and a mocked success response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked with empty confirmation code + /// Then: Confirm Sign up fails with error + func testConfirmSignUpWithEmptyConfirmationCode() async { self.mockIdentityProvider = MockIdentityProvider( mockConfirmSignUpResponse: { _ in @@ -118,7 +393,10 @@ class AWSAuthConfirmSignUpAPITests: BasePluginTest { } } - func testSignUpServiceError() async { + /// Given: Configured auth machine in `.awaitingUserConfirmation` sign up state and a mocked service error response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: Confirm Sign up fails with appropriate error returned + func testConfirmSignUpServiceError() async { let errorsToTest: [(confirmSignUpOutputError: Error, cognitoError: AWSCognitoAuthError)] = [ (AWSCognitoIdentityProvider.AliasExistsException(), .aliasExists), @@ -141,7 +419,11 @@ class AWSAuthConfirmSignUpAPITests: BasePluginTest { } } - func testSignUpWithNotAuthorizedException() async { + /// Given: Configured auth machine in `.awaitingUserConfirmation` sign up state + /// and a mocked `NotAuthorizedException` response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: Confirm Sign up fails with `.notAuthorized` error + func testConfirmSignUpWithNotAuthorizedException() async { self.mockIdentityProvider = MockIdentityProvider( mockConfirmSignUpResponse: { _ in @@ -173,7 +455,11 @@ class AWSAuthConfirmSignUpAPITests: BasePluginTest { } } - func testSignUpWithInternalErrorException() async { + /// Given: Configured auth machine in `.awaitingUserConfirmation` sign up state + /// and a mocked `InternalErrorException` response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: Confirm Sign up fails with `.unknown` error + func testConfirmSignUpWithInternalErrorException() async { self.mockIdentityProvider = MockIdentityProvider( mockConfirmSignUpResponse: { _ in @@ -201,7 +487,11 @@ class AWSAuthConfirmSignUpAPITests: BasePluginTest { } } - func testSignUpWithUnknownErrorException() async { + /// Given: Configured auth machine in `.awaitingUserConfirmation` sign up state + /// and a mocked unknown error response + /// When: `Auth.confirmSignUp(for:confirmationCode:options:)` is invoked + /// Then: Confirm Sign up fails with `.unknown` error + func testConfirmSignUpWithUnknownErrorException() async { self.mockIdentityProvider = MockIdentityProvider( mockConfirmSignUpResponse: { _ in diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpTaskTests.swift index 74eed8cfb8..9a491d1d62 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpTaskTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthConfirmSignUpTaskTests.swift @@ -19,60 +19,54 @@ import ClientRuntime @_spi(UnknownAWSHTTPServiceError) import AWSClientRuntime import AWSCognitoIdentityProvider -class AWSAuthConfirmSignUpTaskTests: XCTestCase { +class AWSAuthConfirmSignUpTaskTests: BasePluginTest { - var queue: OperationQueue? - - override func setUp() { - super.setUp() - queue = OperationQueue() - queue?.maxConcurrentOperationCount = 1 + let signUpData = SignUpEventData(username: "jeffb") + let signUpResult = AuthSignUpResult(.confirmUser()) + + override var initialState: AuthState { + AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .awaitingUserConfirmation(signUpData, signUpResult)) } func testConfirmSignUpOperationSuccess() async throws { - let functionExpectation = expectation(description: "API call should be invoked") - let confirmSignUp: MockIdentityProvider.MockConfirmSignUpResponse = { _ in - functionExpectation.fulfill() - return .init() + self.mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { _ in + return .init() + } + ) + + let confirmSignUpResult = try await plugin.confirmSignUp(for: "jeffb", + confirmationCode: "213", + options: AuthConfirmSignUpRequest.Options()) + XCTAssertTrue(confirmSignUpResult.isSignUpComplete) + guard case .done = confirmSignUpResult.nextStep else { + XCTFail("Next step should be done") + return } - - let authEnvironment = Defaults.makeDefaultAuthEnvironment( - userPoolFactory: {MockIdentityProvider(mockConfirmSignUpResponse: confirmSignUp)}) - - let request = AuthConfirmSignUpRequest(username: "jeffb", - code: "213", - options: AuthConfirmSignUpRequest.Options()) - let task = AWSAuthConfirmSignUpTask(request, authEnvironment: authEnvironment) - let confirmSignUpResult = try await task.value - print("Confirm Sign Up Result: \(confirmSignUpResult)") - await fulfillment(of: [functionExpectation], timeout: 1) } func testConfirmSignUpOperationFailure() async throws { - let functionExpectation = expectation(description: "API call should be invoked") - let confirmSignUp: MockIdentityProvider.MockConfirmSignUpResponse = { _ in - functionExpectation.fulfill() - throw AWSClientRuntime.UnknownAWSHTTPServiceError( - httpResponse: MockHttpResponse.ok, - message: nil, - requestID: nil, - typeName: nil - ) - } - - let authEnvironment = Defaults.makeDefaultAuthEnvironment( - userPoolFactory: {MockIdentityProvider(mockConfirmSignUpResponse: confirmSignUp)}) - - let request = AuthConfirmSignUpRequest(username: "jeffb", - code: "213", - options: AuthConfirmSignUpRequest.Options()) - + self.mockIdentityProvider = MockIdentityProvider( + mockConfirmSignUpResponse: { _ in + throw AWSClientRuntime.UnknownAWSHTTPServiceError( + httpResponse: MockHttpResponse.ok, + message: nil, + requestID: nil, + typeName: nil + ) + } + ) + do { - let task = AWSAuthConfirmSignUpTask(request, authEnvironment: authEnvironment) - _ = try await task.value - XCTFail("Should not produce success response") - } catch { + let _ = try await plugin.confirmSignUp(for: "jeffb", + confirmationCode: "213", + options: AuthConfirmSignUpRequest.Options()) + XCTFail("Should result in failure") + } catch(let error) { + XCTAssertNotNil(error) } - await fulfillment(of: [functionExpectation], timeout: 1) } } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpAPITests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpAPITests.swift index bd44caa089..9502c69ced 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpAPITests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpAPITests.swift @@ -19,9 +19,12 @@ class AWSAuthSignUpAPITests: BasePluginTest { ]) override var initialState: AuthState { - AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured, .notStarted) } + /// Given: Configured auth machine in `.notStarted` sign up states and a mocked success response + /// When: `Auth.signUp(username:password:options:)` is invoked + /// Then: Sign up is successful with `.done` as the next step func testSuccessfulSignUp() async throws { self.mockIdentityProvider = MockIdentityProvider( @@ -43,7 +46,235 @@ class AWSAuthSignUpAPITests: BasePluginTest { } XCTAssertTrue(result.isSignUpComplete, "Signin result should be complete") } + + /// Given: Configured auth machine in `.awaitingUserConfirmation` sign up state and a mocked success response + /// When: `Auth.signUp(username:password:options:)` is invoked + /// Then: Sign up is successful with `.done` as the next step + func testSuccessfulSignUpFromAwaitingUserConfirmationState() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init(codeDeliveryDetails: nil, + userConfirmed: true, + userSub: UUID().uuidString) + } + ) + + let initialStateAwaitingUserConfirmation = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .awaitingUserConfirmation(.init(username: "user1"), .init(.confirmUser()))) + + + let authPluginAwaitingUserConfirmation = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialStateAwaitingUserConfirmation + ) + + let result = try await authPluginAwaitingUserConfirmation.signUp( + username: "user2", + password: "Valid&99", + options: options) + + guard case .done = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Signup result should be complete") + } + + /// Given: Configured auth machine in `.signedUp` sign up state and a mocked success response + /// When: `Auth.signUp(username:password:options:)` is invoked + /// Then: Sign up is successful with `.done` as the next step + func testSuccessfulSignUpFromSignedUpState() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init(codeDeliveryDetails: nil, + userConfirmed: true, + userSub: UUID().uuidString) + } + ) + + let initialStateSignedUp = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .signedUp(.init(username: "user1"), .init(.done))) + + + let authPluginSignedUp = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialStateSignedUp + ) + + let result = try await authPluginSignedUp.signUp( + username: "user2", + password: "Valid&99", + options: options) + + guard case .done = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Signup result should be complete") + } + + /// Given: Configured auth machine in `.error` sign up state and a mocked success response + /// When: `Auth.signUp(username:password:options:)` is invoked + /// Then: Sign up is successful with `.done` as the next step + func testSuccessfulSignUpFromErrorState() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init(codeDeliveryDetails: nil, + userConfirmed: true, + userSub: UUID().uuidString) + } + ) + + let initialStateError = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .error(.service(error: AuthError.service("Unknown error", "Unknown error")))) + + let authPluginError = configureCustomPluginWith(userPool: { mockIdentityProvider }, + initialState: initialStateError) + + let result = try await authPluginError.signUp( + username: "user2", + password: "Valid&99", + options: options) + + guard case .done = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Signup result should be complete") + } + + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked success response + /// When: `Auth.signUp(username:password:options:)` is invoked without a password + /// Then: Sign up is successful with `.done` as the next step + func testSuccessfulPasswordlessSignUp() async throws { + + self.mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init(codeDeliveryDetails: nil, + userConfirmed: true, + userSub: UUID().uuidString) + } + ) + + let result = try await self.plugin.signUp( + username: "jeffb", + options: options) + + guard case .done = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Signin result should be complete") + } + + /// Given: Configured auth machine in `.awaitingUserConfirmation` sign up state and a mocked success response + /// When: `Auth.signUp(username:password:options:)` is invoked without password + /// Then: Sign up is successful with `.done` as the next step + func testSuccessfulPasswordlessSignUpFromAwaitingUserConfirmationState() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init(codeDeliveryDetails: nil, + userConfirmed: true, + userSub: UUID().uuidString) + } + ) + + let initialStateAwaitingUserConfirmation = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .awaitingUserConfirmation(.init(username: "user1"), .init(.confirmUser()))) + + + let authPluginAwaitingUserConfirmation = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialStateAwaitingUserConfirmation + ) + let result1 = try await authPluginAwaitingUserConfirmation.signUp( + username: "user2", + options: options) + + guard case .done = result1.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result1.isSignUpComplete, "Signup result should be complete") + } + + /// Given: Configured auth machine in `.signedUp` sign up state and a mocked success response + /// When: `Auth.signUp(username:password:options:)` is invoked without password + /// Then: Sign up is successful with `.done` as the next step + func testSuccessfulPasswordlessSignUpFromSignedUpState() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init(codeDeliveryDetails: nil, + userConfirmed: true, + userSub: UUID().uuidString) + } + ) + + let initialStateSignedUp = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .signedUp(.init(username: "user1"), .init(.done))) + + + let authPluginSignedUp = configureCustomPluginWith( + userPool: { mockIdentityProvider }, + initialState: initialStateSignedUp + ) + + let result = try await authPluginSignedUp.signUp( + username: "user2", + options: options) + + guard case .done = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Signup result should be complete") + } + + /// Given: Configured auth machine in `.error` sign up state and a mocked success response + /// When: `Auth.signUp(username:password:options:)` is invoked without password + /// Then: Sign up is successful with `.done` as the next step + func testSuccessfulPasswordlessSignUpFromErrorState() async throws { + let mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init(codeDeliveryDetails: nil, + userConfirmed: true, + userSub: UUID().uuidString) + } + ) + + let initialStateError = AuthState.configured( + .signedOut(.init(lastKnownUserName: nil)), + .configured, + .error(.service(error: AuthError.service("Unknown error", "Unknown error")))) + + let authPluginError = configureCustomPluginWith(userPool: { mockIdentityProvider }, + initialState: initialStateError) + + let result = try await authPluginError.signUp( + username: "user2", + options: options) + + guard case .done = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(result.isSignUpComplete, "Signup result should be complete") + } + + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked success response + /// When: `Auth.signUp(username:password:options:)` is invoked with empty username + /// Then: Sign up fails with error func testSignUpWithEmptyUsername() async { self.mockIdentityProvider = MockIdentityProvider( @@ -66,7 +297,36 @@ class AWSAuthSignUpAPITests: BasePluginTest { XCTAssertEqual(authError, AuthError.validation("Username", "", "", nil)) } } + + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked success response + /// When: `Auth.signUp(username:password:options:)` is invoked with empty username and no password + /// Then: Sign up fails with error + func testSignUpPasswordlessWithEmptyUsername() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + XCTFail("Sign up API should not be called") + return .init(codeDeliveryDetails: nil, userConfirmed: true, userSub: nil) + } + ) + + do { _ = try await self.plugin.signUp( + username: "", + options: options) + + } catch { + guard let authError = error as? AuthError else { + XCTFail("Result should not be nil") + return + } + XCTAssertEqual(authError, AuthError.validation("Username", "", "", nil)) + } + } + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked success response with + /// code delivery details + /// When: `Auth.signUp(username:password:options:)` is invoked + /// Then: Sign up is successful with `.confirmUser` as next step func testSignUpWithUserConfirmationRequired() async throws { self.mockIdentityProvider = MockIdentityProvider( @@ -97,7 +357,45 @@ class AWSAuthSignUpAPITests: BasePluginTest { XCTAssertEqual(userId, "userId") XCTAssertFalse(result.isSignUpComplete, "Signin result should be complete") } + + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked success response with + /// code delivery details + /// When: `Auth.signUp(username:password:options:)` is invoked without a password + /// Then: Sign up is successful with `.confirmUser` as next step + func testSignUpPasswordlessWithUserConfirmationRequired() async throws { + + self.mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init( + codeDeliveryDetails: .init( + attributeName: "some attribute", + deliveryMedium: .email, + destination: "random@random.com"), + userConfirmed: false, + userSub: "userId") + } + ) + + let result = try await self.plugin.signUp( + username: "jeffb", + options: options) + guard case .confirmUser(let deliveryDetails, let additionalInfo, let userId) = result.nextStep else { + XCTFail("Result should be .confirmUser for next step") + return + } + + XCTAssertNotNil(deliveryDetails?.destination) + XCTAssertEqual(deliveryDetails?.attributeKey, .unknown("some attribute")) + XCTAssertEqual(additionalInfo, nil) + XCTAssertEqual(userId, "userId") + XCTAssertFalse(result.isSignUpComplete, "Signin result should be complete") + } + + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked success response with + /// with `nil` responses + /// When: `Auth.signUp(username:password:options:)` is invoked + /// Then: Sign up is successful with `.confirmUser` as next step func testSignUpNilCognitoResponse() async throws { self.mockIdentityProvider = MockIdentityProvider( @@ -125,12 +423,43 @@ class AWSAuthSignUpAPITests: BasePluginTest { XCTAssertEqual(userId, nil) XCTAssertFalse(result.isSignUpComplete, "Signin result should be complete") } + + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked success response with + /// with `nil` responses + /// When: `Auth.signUp(username:password:options:)` is invoked without a password + /// Then: Sign up is successful with `.confirmUser` as next step + func testSignUpPasswordlessNilCognitoResponse() async throws { + + self.mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init( + codeDeliveryDetails: nil, + userConfirmed: false, + userSub: nil) + } + ) + + let result = try await self.plugin.signUp( + username: "jeffb", + options: options) + + guard case .confirmUser(let deliveryDetails, let additionalInfo, let userId) = result.nextStep else { + XCTFail("Result should be .done for next step") + return + } + + XCTAssertNil(deliveryDetails?.destination) + XCTAssertNil(deliveryDetails?.attributeKey) + XCTAssertEqual(additionalInfo, nil) + XCTAssertEqual(userId, nil) + XCTAssertFalse(result.isSignUpComplete, "Signin result should be complete") + } /// Given: A response from Cognito SignUp when `userConfirmed == true` and a present `userSub` /// When: Invoking `signUp(username:password:options:)` /// Then: The caller should receive an `AuthSignUpResult` where `nextStep == .done` and /// `userID` is the `userSub` returned by the service. - func test_signUp_done_withUserSub() async throws { + func testSignUpDoneWithUserSub() async throws { let sub = UUID().uuidString mockIdentityProvider = MockIdentityProvider( mockSignUpResponse: { _ in @@ -152,12 +481,39 @@ class AWSAuthSignUpAPITests: BasePluginTest { XCTAssertEqual(result.userID, sub) XCTAssertTrue(result.isSignUpComplete) } + + /// Given: A response from Cognito SignUp when `userConfirmed == true` and a present `userSub` + /// When: Invoking `signUp(username:password:options:)` without a password + /// Then: The caller should receive an `AuthSignUpResult` where `nextStep == .done` and + /// `userID` is the `userSub` returned by the service. + func testSignUpPasswordlessDoneWithUserSub() async throws { + let sub = UUID().uuidString + mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init( + codeDeliveryDetails: nil, + userConfirmed: true, + userSub: sub + ) + } + ) + + let result = try await plugin.signUp( + username: "foo", + password: "bar", + options: nil + ) + + XCTAssertEqual(result.nextStep, .done) + XCTAssertEqual(result.userID, sub) + XCTAssertTrue(result.isSignUpComplete) + } /// Given: A response from Cognito SignUp that includes `codeDeliveryDetails` where `userConfirmed == false` /// When: Invoking `signUp(username:password:options:)` /// Then: The caller should receive an `AuthSignUpResult` where `nextStep == .confirmUser` and /// the applicable associated value of that case and the `userID` both equal the `userSub` returned by the service. - func test_signUp_confirmUser_userIDsMatch() async throws { + func testSignUpConfirmUserUserIDsMatch() async throws { let sub = UUID().uuidString mockIdentityProvider = MockIdentityProvider( mockSignUpResponse: { _ in @@ -184,31 +540,42 @@ class AWSAuthSignUpAPITests: BasePluginTest { } XCTAssertEqual(result.userID, userID) } + + /// Given: A response from Cognito SignUp that includes `codeDeliveryDetails` where `userConfirmed == false` + /// When: Invoking `signUp(username:password:options:)` without a password + /// Then: The caller should receive an `AuthSignUpResult` where `nextStep == .confirmUser` and + /// the applicable associated value of that case and the `userID` both equal the `userSub` returned by the service. + func testSignUpPasswordlessConfirmUserUserIDsMatch() async throws { + let sub = UUID().uuidString + mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init( + codeDeliveryDetails: .init( + attributeName: "some attribute", + deliveryMedium: .email, + destination: "" + ), + userConfirmed: false, + userSub: sub + ) + } + ) - func testSignUpServiceError() async { - - let errorsToTest: [(signUpOutputError: Error, cognitoError: AWSCognitoAuthError)] = [ - (AWSCognitoIdentityProvider.CodeDeliveryFailureException(), .codeDelivery), - (AWSCognitoIdentityProvider.InvalidEmailRoleAccessPolicyException(), .emailRole), - (AWSCognitoIdentityProvider.InvalidLambdaResponseException(), .lambda), - (AWSCognitoIdentityProvider.InvalidParameterException(), .invalidParameter), - (AWSCognitoIdentityProvider.InvalidPasswordException(), .invalidPassword), - (AWSCognitoIdentityProvider.InvalidSmsRoleAccessPolicyException(), .smsRole), - (AWSCognitoIdentityProvider.InvalidSmsRoleTrustRelationshipException(), .smsRole), - (AWSCognitoIdentityProvider.ResourceNotFoundException(), .resourceNotFound), - (AWSCognitoIdentityProvider.TooManyRequestsException(), .requestLimitExceeded), - (AWSCognitoIdentityProvider.UnexpectedLambdaException(), .lambda), - (AWSCognitoIdentityProvider.UserLambdaValidationException(), .lambda), - (AWSCognitoIdentityProvider.UsernameExistsException(), .usernameExists), - ] + let result = try await plugin.signUp( + username: "foo", + options: nil + ) - for errorToTest in errorsToTest { - await validateSignUpServiceErrors( - signUpOutputError: errorToTest.signUpOutputError, - expectedCognitoError: errorToTest.cognitoError) + guard case .confirmUser(_, _, let userID) = result.nextStep else { + return XCTFail("expected .confirmUser nextStep") } + XCTAssertEqual(result.userID, userID) } + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked failure response + /// with `NotAuthorizedException` + /// When: `Auth.signUp(username:password:options:)` is invoked + /// Then: Sign up fails with error as `.notAuthorized` func testSignUpWithNotAuthorizedException() async { self.mockIdentityProvider = MockIdentityProvider( @@ -240,7 +607,46 @@ class AWSAuthSignUpAPITests: BasePluginTest { XCTAssertNil(notAuthorizedError) } } + + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked failure response + /// with `NotAuthorizedException` + /// When: `Auth.signUp(username:password:options:)` is invoked without a password + /// Then: Sign up fails with error as `.notAuthorized` + func testSignUpPasswordlessWithNotAuthorizedException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + throw AWSCognitoIdentityProvider.NotAuthorizedException() + } + ) + + do { + _ = try await self.plugin.signUp( + username: "username", + options: options) + } catch { + guard let authError = error as? AuthError else { + XCTFail("Should throw Auth error") + return + } + + guard case .notAuthorized(let errorDescription, + let recoverySuggestion, + let notAuthorizedError) = authError else { + XCTFail("Auth error should be of type notAuthorized") + return + } + XCTAssertNotNil(errorDescription) + XCTAssertNotNil(recoverySuggestion) + XCTAssertNil(notAuthorizedError) + } + } + + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked failure response + /// with `InternalErrorException` + /// When: `Auth.signUp(username:password:options:)` is invoked + /// Then: Sign up fails with error as `.unknown` func testSignUpWithInternalErrorException() async { self.mockIdentityProvider = MockIdentityProvider( @@ -268,6 +674,137 @@ class AWSAuthSignUpAPITests: BasePluginTest { XCTAssertNotNil(errorMessage) } } + + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked failure response + /// with `InternalErrorException` + /// When: `Auth.signUp(username:password:options:)` is invoked without a password + /// Then: Sign up fails with error as `.unknown` + func testSignUpPasswordlessWithInternalErrorException() async { + + self.mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + throw AWSCognitoIdentityProvider.InternalErrorException() + } + ) + + do { + _ = try await self.plugin.signUp( + username: "username", + options: options) + } catch { + guard let authError = error as? AuthError else { + XCTFail("Should throw Auth error") + return + } + + guard case .unknown(let errorMessage, _) = authError else { + XCTFail("Auth error should be of type unknown") + return + } + + XCTAssertNotNil(errorMessage) + } + } + + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked success response + /// for Sign Up, Confirm Sign Up and Auto Sign In + /// When: `Auth.signUp(username:password:options:)` is invoked followed by + /// `Auth.signUp(for:confirmationCode:options:)` and `Auth.autoSignIn()` + /// Then: Sign up, Confirm Sign up and Auto sign in are complete + func testSuccessfulSignUpAndAutoSignInEndToEnd() async throws { + let userSub = UUID().uuidString + self.mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init( + codeDeliveryDetails: .init( + attributeName: "some attribute", + deliveryMedium: .email, + destination: "" + ), + userConfirmed: false, + userSub: userSub + ) + }, + mockInitiateAuthResponse: { input in + return InitiateAuthOutput( + authenticationResult: .init( + accessToken: Defaults.validAccessToken, + expiresIn: 300, + idToken: "idToken", + newDeviceMetadata: nil, + refreshToken: "refreshToken", + tokenType: "")) + }, + mockConfirmSignUpResponse: { request in + XCTAssertNil(request.clientMetadata) + XCTAssertNil(request.forceAliasCreation) + return .init(session: "session") + } + ) + + // sign up + let signUpResult = try await self.plugin.signUp( + username: "jeffb", + options: options) + + guard case .confirmUser(_, _, let userID) = signUpResult.nextStep else { + return XCTFail("expected .confirmUser nextStep") + } + XCTAssertEqual(signUpResult.userID, userID) + XCTAssertFalse(signUpResult.isSignUpComplete) + + // confirm sign up + let confirmSignUpResult = try await plugin.confirmSignUp(for: "jeffb", + confirmationCode: "123456", + options: AuthConfirmSignUpRequest.Options()) + guard case .completeAutoSignIn(let session) = confirmSignUpResult.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(confirmSignUpResult.isSignUpComplete, "Confirm Sign up result should be complete") + XCTAssertEqual(session, "session") + + // auto sign in + let autoSignInResult = try await plugin.autoSignIn() + guard case .done = autoSignInResult.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(autoSignInResult.isSignedIn, "Signin result should be complete") + } + + /// Given: Configured auth machine in `.notStarted` sign up state and a mocked failure response + /// with different service errors + /// When: `Auth.signUp(username:password:options:)` is invoked with/without a password + /// Then: Sign up fails with appropriate error type returned + func testSignUpServiceError() async { + + let errorsToTest: [(signUpOutputError: Error, cognitoError: AWSCognitoAuthError)] = [ + (AWSCognitoIdentityProvider.CodeDeliveryFailureException(), .codeDelivery), + (AWSCognitoIdentityProvider.InvalidEmailRoleAccessPolicyException(), .emailRole), + (AWSCognitoIdentityProvider.InvalidLambdaResponseException(), .lambda), + (AWSCognitoIdentityProvider.InvalidParameterException(), .invalidParameter), + (AWSCognitoIdentityProvider.InvalidPasswordException(), .invalidPassword), + (AWSCognitoIdentityProvider.InvalidSmsRoleAccessPolicyException(), .smsRole), + (AWSCognitoIdentityProvider.InvalidSmsRoleTrustRelationshipException(), .smsRole), + (AWSCognitoIdentityProvider.ResourceNotFoundException(), .resourceNotFound), + (AWSCognitoIdentityProvider.TooManyRequestsException(), .requestLimitExceeded), + (AWSCognitoIdentityProvider.UnexpectedLambdaException(), .lambda), + (AWSCognitoIdentityProvider.UserLambdaValidationException(), .lambda), + (AWSCognitoIdentityProvider.UsernameExistsException(), .usernameExists), + ] + + for errorToTest in errorsToTest { + await validateSignUpServiceErrors( + signUpOutputError: errorToTest.signUpOutputError, + expectedCognitoError: errorToTest.cognitoError) + + await validateSignUpPasswordlessServiceErrors( + signUpOutputError: errorToTest.signUpOutputError, + expectedCognitoError: errorToTest.cognitoError) + } + + } func validateSignUpServiceErrors( signUpOutputError: Error, @@ -277,7 +814,7 @@ class AWSAuthSignUpAPITests: BasePluginTest { throw signUpOutputError } ) - + do { _ = try await self.plugin.signUp( username: "username", @@ -288,6 +825,43 @@ class AWSAuthSignUpAPITests: BasePluginTest { XCTFail("Should throw Auth error") return } + + guard case .service(let errorMessage, + let recovery, + let serviceError) = authError else { + XCTFail("Auth error should be of type service error") + return + } + + XCTAssertNotNil(errorMessage) + XCTAssertNotNil(recovery) + + guard let awsCognitoAuthError = serviceError as? AWSCognitoAuthError else { + XCTFail("Service error wrapped should be of type AWSCognitoAuthError") + return + } + XCTAssertEqual(awsCognitoAuthError, expectedCognitoError) + } + } + + func validateSignUpPasswordlessServiceErrors( + signUpOutputError: Error, + expectedCognitoError: AWSCognitoAuthError) async { + self.mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + throw signUpOutputError + } + ) + + do { + _ = try await self.plugin.signUp( + username: "username", + options: options) + } catch { + guard let authError = error as? AuthError else { + XCTFail("Should throw Auth error") + return + } guard case .service(let errorMessage, let recovery, diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpTaskTests.swift index b2192c1357..35a4e7de39 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpTaskTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/ClientBehaviorTests/SignUp/AWSAuthSignUpTaskTests.swift @@ -14,35 +14,32 @@ import ClientRuntime import AWSCognitoIdentityProvider -class AWSAuthSignUpTaskTests: XCTestCase { +class AWSAuthSignUpTaskTests: BasePluginTest { - var queue: OperationQueue? - - let initialState = AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) - - override func setUp() { - super.setUp() - queue = OperationQueue() - queue?.maxConcurrentOperationCount = 1 + override var initialState: AuthState { + AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured, .notStarted) } + /// Given: Configured AuthState machine + /// When: A new SignUp operation is added to the queue and mock a success failure + /// Then: Should complete the signUp flow + /// func testSignUpOperationSuccess() async throws { - let functionExpectation = expectation(description: "API call should be invoked") - - let signUp: MockIdentityProvider.MockSignUpResponse = { _ in - functionExpectation.fulfill() - return .init(codeDeliveryDetails: nil, userConfirmed: true, userSub: UUID().uuidString) + self.mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + return .init(codeDeliveryDetails: nil, + userConfirmed: true, + userSub: UUID().uuidString) + } + ) + let signUpResult = try await plugin.signUp(username: "jeffb", + password: "Valid&99", + options: AuthSignUpRequest.Options()) + XCTAssertTrue(signUpResult.isSignUpComplete) + guard case .done = signUpResult.nextStep else { + XCTFail("Next step should be done") + return } - - let request = AuthSignUpRequest(username: "jeffb", - password: "Valid&99", - options: AuthSignUpRequest.Options()) - let authEnvironment = Defaults.makeDefaultAuthEnvironment( - userPoolFactory: {MockIdentityProvider(mockSignUpResponse: signUp)}) - let task = AWSAuthSignUpTask(request, authEnvironment: authEnvironment) - let signUpResult = try await task.value - print("Sign Up Result: \(signUpResult)") - await fulfillment(of: [functionExpectation], timeout: 1) } /// Given: Configured AuthState machine @@ -50,49 +47,21 @@ class AWSAuthSignUpTaskTests: XCTestCase { /// Then: Should complete the signUp flow with an error /// func testSignUpOperationFailure() async throws { - let functionExpectation = expectation(description: "API call should be invoked") - let signUp: MockIdentityProvider.MockSignUpResponse = { _ in - functionExpectation.fulfill() - throw AWSClientRuntime.UnknownAWSHTTPServiceError( - httpResponse: MockHttpResponse.ok, message: nil, requestID: nil, typeName: nil - ) - } - - let request = AuthSignUpRequest(username: "jeffb", - password: "Valid&99", - options: AuthSignUpRequest.Options()) - - let authEnvironment = Defaults.makeDefaultAuthEnvironment( - userPoolFactory: {MockIdentityProvider(mockSignUpResponse: signUp)}) - let task = AWSAuthSignUpTask(request, authEnvironment: authEnvironment) + self.mockIdentityProvider = MockIdentityProvider( + mockSignUpResponse: { _ in + throw AWSClientRuntime.UnknownAWSHTTPServiceError( + httpResponse: MockHttpResponse.ok, message: nil, requestID: nil, typeName: nil + ) + } + ) + do { - _ = try await task.value - XCTFail("Should not produce success response") - } catch { + let _ = try await plugin.signUp(username: "jeffb", + password: "Valid&99", + options: AuthSignUpRequest.Options()) + XCTFail("Should result in failure") + } catch (let error) { + XCTAssertNotNil(error) } - await fulfillment(of: [functionExpectation], timeout: 1) - } - - /// Given: Configured AuthState machine with existing signUp flow - /// When: A new SignUp operation is added to the queue - /// Then: Should cancel the existing signUp flow and start a new flow and complete - /// - func testCancelExistingSignUp() async throws { - Amplify.Logging.logLevel = .verbose - let functionExpectation = expectation(description: "API call should be invoked") - let signUp: MockIdentityProvider.MockSignUpResponse = { _ in - functionExpectation.fulfill() - return .init(codeDeliveryDetails: nil, userConfirmed: true, userSub: UUID().uuidString) - } - - let request = AuthSignUpRequest(username: "jeffb", - password: "Valid&99", - options: AuthSignUpRequest.Options()) - - let authEnvironment = Defaults.makeDefaultAuthEnvironment( - userPoolFactory: {MockIdentityProvider(mockSignUpResponse: signUp)}) - let task = AWSAuthSignUpTask(request, authEnvironment: authEnvironment) - _ = try await task.value - await fulfillment(of: [functionExpectation], timeout: 1) } } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/HostedUITests/AWSAuthHostedUISignInTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/HostedUITests/AWSAuthHostedUISignInTests.swift index 7ce5a0b15c..c5bdfbde3a 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/HostedUITests/AWSAuthHostedUISignInTests.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/HostedUITests/AWSAuthHostedUISignInTests.swift @@ -33,7 +33,7 @@ class AWSAuthHostedUISignInTests: XCTestCase { scopes: ["name"], signInRedirectURI: "myapp://", signOutRedirectURI: "myapp://")) - let initialState = AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured) + let initialState = AuthState.configured(.signedOut(.init(lastKnownUserName: nil)), .configured, .notStarted) func urlSessionMock() -> URLSession { let configuration = URLSessionConfiguration.ephemeral diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/BasePluginTest.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/BasePluginTest.swift index ea6171b1b9..33ee308e7d 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/BasePluginTest.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/UserBehaviourTests/BasePluginTest.swift @@ -39,7 +39,8 @@ class BasePluginTest: XCTestCase { SignedInData(signedInDate: Date(), signInMethod: .apiBased(.userSRP), cognitoUserPoolTokens: AWSCognitoUserPoolTokens.testData)), - AuthorizationState.sessionEstablished(AmplifyCredentials.testData)) + AuthorizationState.sessionEstablished(AmplifyCredentials.testData), + .notStarted) } override func setUp() { diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/WebAuthnBehaviourTests/AssociateWebAuthnCredentialTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/WebAuthnBehaviourTests/AssociateWebAuthnCredentialTaskTests.swift new file mode 100644 index 0000000000..e8a5d0ec6d --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/WebAuthnBehaviourTests/AssociateWebAuthnCredentialTaskTests.swift @@ -0,0 +1,271 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +#if os(iOS) || os(macOS) || os(visionOS) +@testable import AWSCognitoAuthPlugin +import enum Amplify.AuthError +import enum AWSCognitoIdentity.CognitoIdentityClientTypes +import struct AWSCognitoIdentityProvider.WebAuthnNotEnabledException +import struct AWSCognitoIdentityProvider.StartWebAuthnRegistrationOutput +import XCTest + +@available(iOS 17.4, macOS 13.5, *) +class AssociateWebAuthnCredentialTaskTests: XCTestCase { + private var task: AssociateWebAuthnCredentialTask! + private var identityProvider: MockIdentityProvider! + private var credentialRegistrant: MockCredentialRegistrant! + + override func setUp() { + let identity = MockIdentity( + mockGetIdResponse: { _ in + return .init(identityId: "mockIdentityId") + }, + mockGetCredentialsResponse: { _ in + let credentials = CognitoIdentityClientTypes.Credentials( + accessKeyId: "accessKey", + expiration: Date(), + secretKey: "secret", + sessionToken: "session" + ) + return .init( + credentials: credentials, + identityId: "responseIdentityID" + ) + } + ) + identityProvider = MockIdentityProvider() + let initialState = AuthState.configured( + .signedIn( + SignedInData( + signedInDate: Date(), + signInMethod: .apiBased(.userSRP), + cognitoUserPoolTokens: AWSCognitoUserPoolTokens.testData + ) + ), + .sessionEstablished(AmplifyCredentials.testData), + .notStarted + ) + + let stateMachine = Defaults.makeDefaultAuthStateMachine( + initialState: initialState, + identityPoolFactory: { + return identity + }, + userPoolFactory: { + return self.identityProvider + } + ) + + credentialRegistrant = MockCredentialRegistrant() + credentialRegistrant.mockedCreateResponse = .success( + .init( + credentialId: "credentialId", + attestationObject: "attestationObject", + clientDataJSON: "clientDataJSON" + ) + ) + + task = AssociateWebAuthnCredentialTask( + request: .init(presentationAnchor: nil, options: .init()), + authStateMachine: stateMachine, + userPoolFactory: { + return self.identityProvider + }, + registrantFactory: { _ in + return self.credentialRegistrant + } + ) + } + + override func tearDown() { + credentialRegistrant = nil + identityProvider = nil + task = nil + } + + func testExecute_withSuccess_shouldSucceed() async throws { + var startWebAuthnRegistrationCallCount = 0 + identityProvider.mockStartWebAuthnRegistrationResponse = { _ in + startWebAuthnRegistrationCallCount += 1 + return self.startWebAuthnRegistrationResponse() + } + + var completeWebAuthnRegistrationCallCount = 0 + identityProvider.mockCompleteWebAuthnRegistrationResponse = { _ in + completeWebAuthnRegistrationCallCount += 1 + return .init() + } + + try await task.execute() + XCTAssertEqual(startWebAuthnRegistrationCallCount, 1) + XCTAssertEqual(credentialRegistrant.createCallCount, 1) + XCTAssertEqual(completeWebAuthnRegistrationCallCount, 1) + } + + func testExecute_withRegistrationFailed_shouldFail() async { + var startWebAuthnRegistrationCallCount = 0 + identityProvider.mockStartWebAuthnRegistrationResponse = { _ in + startWebAuthnRegistrationCallCount += 1 + return self.startWebAuthnRegistrationResponse() + } + + var completeWebAuthnRegistrationCallCount = 0 + identityProvider.mockCompleteWebAuthnRegistrationResponse = { _ in + completeWebAuthnRegistrationCallCount += 1 + return .init() + } + + credentialRegistrant.mockedCreateResponse = .failure( + WebAuthnError.creationFailed(error: .init(.failed)) + ) + + do { + try await task.execute() + XCTFail("Task should have failed") + } catch let error as AuthError { + guard case .service = error else { + XCTFail("Expected AuthError.service error, got \(error)") + return + } + + XCTAssertEqual(startWebAuthnRegistrationCallCount, 1) + XCTAssertEqual(credentialRegistrant.createCallCount, 1) + XCTAssertEqual(completeWebAuthnRegistrationCallCount, 0) + } catch { + XCTFail("Expected AuthError error, got \(error)") + } + } + + func testExecute_withServiceErrorOnStart_shouldFailWithServiceError() async { + identityProvider.mockStartWebAuthnRegistrationResponse = { _ in + throw WebAuthnNotEnabledException(message: "WebAuthn is not enabled") + } + + var completeWebAuthnRegistrationCallCount = 0 + identityProvider.mockCompleteWebAuthnRegistrationResponse = { _ in + completeWebAuthnRegistrationCallCount += 1 + return .init() + } + + do { + try await task.execute() + XCTFail("Task should have failed") + } catch let error as AuthError { + guard case .service(_, _, let underlyingError) = error else { + XCTFail("Expected AuthError.service error, got \(error)") + return + } + + XCTAssertEqual(underlyingError as? AWSCognitoAuthError, AWSCognitoAuthError.webAuthnNotEnabled) + XCTAssertEqual(credentialRegistrant.createCallCount, 0) + XCTAssertEqual(completeWebAuthnRegistrationCallCount, 0) + } catch { + XCTFail("Expected AuthError error, got \(error)") + } + } + + func testExecute_withOtherErrorOnStart_shouldFailWithUnknownServiceError() async { + identityProvider.mockStartWebAuthnRegistrationResponse = { _ in + throw CancellationError() + } + + var completeWebAuthnRegistrationCallCount = 0 + identityProvider.mockCompleteWebAuthnRegistrationResponse = { _ in + completeWebAuthnRegistrationCallCount += 1 + return .init() + } + + do { + try await task.execute() + XCTFail("Task should have failed") + } catch let error as AuthError { + guard case .service(let description, _, _) = error else { + XCTFail("Expected AuthError.service error, got \(error)") + return + } + + XCTAssertEqual(description, "An unknown error type was thrown by the service. Unable to associate WebAuthn credential.") + XCTAssertEqual(credentialRegistrant.createCallCount, 0) + XCTAssertEqual(completeWebAuthnRegistrationCallCount, 0) + } catch { + XCTFail("Expected AuthError error, got \(error)") + } + } + + func testExecute_withServiceErrorOnComplete_shouldFailWithServiceError() async { + var startWebAuthnRegistrationCallCount = 0 + identityProvider.mockStartWebAuthnRegistrationResponse = { _ in + startWebAuthnRegistrationCallCount += 1 + return self.startWebAuthnRegistrationResponse() + } + + identityProvider.mockCompleteWebAuthnRegistrationResponse = { _ in + throw WebAuthnNotEnabledException(message: "WebAuthn is not enabled") + } + + do { + try await task.execute() + XCTFail("Task should have failed") + } catch let error as AuthError { + guard case .service(_, _, let underlyingError) = error else { + XCTFail("Expected AuthError.service error, got \(error)") + return + } + + XCTAssertEqual(underlyingError as? AWSCognitoAuthError, AWSCognitoAuthError.webAuthnNotEnabled) + XCTAssertEqual(startWebAuthnRegistrationCallCount, 1) + XCTAssertEqual(credentialRegistrant.createCallCount, 1) + } catch { + XCTFail("Expected AuthError error, got \(error)") + } + } + + func testExecute_withOtherErrorOnComplete_shouldFailWithUnknownServiceError() async { + var startWebAuthnRegistrationCallCount = 0 + identityProvider.mockStartWebAuthnRegistrationResponse = { _ in + startWebAuthnRegistrationCallCount += 1 + return self.startWebAuthnRegistrationResponse() + } + + identityProvider.mockCompleteWebAuthnRegistrationResponse = { _ in + throw CancellationError() + } + + do { + try await task.execute() + XCTFail("Task should have failed") + } catch let error as AuthError { + guard case .service(let description, _, _) = error else { + XCTFail("Expected AuthError.service error, got \(error)") + return + } + + XCTAssertEqual(description, "An unknown error type was thrown by the service. Unable to associate WebAuthn credential.") + XCTAssertEqual(startWebAuthnRegistrationCallCount, 1) + XCTAssertEqual(credentialRegistrant.createCallCount, 1) + } catch { + XCTFail("Expected AuthError error, got \(error)") + } + } + + private func startWebAuthnRegistrationResponse() -> StartWebAuthnRegistrationOutput { + return .init(credentialCreationOptions: [ + "challenge": "Y2hhbGxlbmdl", + "rp": [ + "id": "relyingPartyId" + ], + "user": [ + "id": "dXNlcklk", + "name": "User" + ], + "excludeCredentials": [] + ]) + } + +} + +#endif diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/WebAuthnBehaviourTests/DeleteWebAuthnCredentialTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/WebAuthnBehaviourTests/DeleteWebAuthnCredentialTaskTests.swift new file mode 100644 index 0000000000..c876ea1061 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/WebAuthnBehaviourTests/DeleteWebAuthnCredentialTaskTests.swift @@ -0,0 +1,121 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +@testable import AWSCognitoAuthPlugin +import enum Amplify.AuthError +import enum AWSCognitoIdentity.CognitoIdentityClientTypes +import struct AWSCognitoIdentityProvider.WebAuthnClientMismatchException +import XCTest + +class DeleteWebAuthnCredentialTaskTests: XCTestCase { + private var task: DeleteWebAuthnCredentialTask! + private var identityProvider: MockIdentityProvider! + + override func setUp() { + let identity = MockIdentity( + mockGetIdResponse: { _ in + return .init(identityId: "mockIdentityId") + }, + mockGetCredentialsResponse: { _ in + let credentials = CognitoIdentityClientTypes.Credentials( + accessKeyId: "accessKey", + expiration: Date(), + secretKey: "secret", + sessionToken: "session" + ) + return .init( + credentials: credentials, + identityId: "responseIdentityID" + ) + } + ) + identityProvider = MockIdentityProvider() + let initialState = AuthState.configured( + .signedIn( + SignedInData( + signedInDate: Date(), + signInMethod: .apiBased(.userSRP), + cognitoUserPoolTokens: AWSCognitoUserPoolTokens.testData + ) + ), + .sessionEstablished(AmplifyCredentials.testData), + .notStarted + ) + + let stateMachine = Defaults.makeDefaultAuthStateMachine( + initialState: initialState, + identityPoolFactory: { + return identity + }, + userPoolFactory: { + return self.identityProvider + } + ) + + task = DeleteWebAuthnCredentialTask( + request: .init(credentialId: "credentialId", options: .init()), + authStateMachine: stateMachine, + userPoolFactory: { + return self.identityProvider + } + ) + } + + override func tearDown() { + identityProvider = nil + task = nil + } + + func testExecute_withSuccess_shouldSucceed() async throws { + var deleteWebAuthnCredentialCallCount = 0 + identityProvider.mockDeleteWebAuthnCredentialResponse = { _ in + deleteWebAuthnCredentialCallCount += 1 + return .init() + } + + try await task.execute() + XCTAssertEqual(deleteWebAuthnCredentialCallCount, 1) + } + + func testExecute_withServiceError_shouldFailWithServiceError() async { + identityProvider.mockDeleteWebAuthnCredentialResponse = { _ in + throw WebAuthnClientMismatchException(message: "Client mismatch") + } + + do { + _ = try await task.execute() + XCTFail("Task should have failed") + } catch let error as AuthError { + guard case .service(_, _, let underlyingError) = error else { + XCTFail("Expected AuthError.service error, got \(error)") + return + } + XCTAssertEqual(underlyingError as? AWSCognitoAuthError, AWSCognitoAuthError.webAuthnClientMismatch) + } catch { + XCTFail("Expected AuthError error, got \(error)") + } + } + + func testExecute_withOtherError_shouldFailWithUnknownServiceError() async { + identityProvider.mockDeleteWebAuthnCredentialResponse = { _ in + throw CancellationError() + } + + do { + _ = try await task.execute() + XCTFail("Task should have failed") + } catch let error as AuthError { + guard case .service(let description, _, _) = error else { + XCTFail("Expected AuthError.service error, got \(error)") + return + } + XCTAssertEqual(description, "An unknown error type was thrown by the service. Unable to delete WebAuthn credential.") + } catch { + XCTFail("Expected AuthError error, got \(error)") + } + } +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/WebAuthnBehaviourTests/ListWebAuthnCredentialsTaskTests.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/WebAuthnBehaviourTests/ListWebAuthnCredentialsTaskTests.swift new file mode 100644 index 0000000000..cd57d37756 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TaskTests/WebAuthnBehaviourTests/ListWebAuthnCredentialsTaskTests.swift @@ -0,0 +1,147 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +@testable import AWSCognitoAuthPlugin +import enum Amplify.AuthError +import enum AWSCognitoIdentity.CognitoIdentityClientTypes +import struct AWSCognitoIdentityProvider.WebAuthnRelyingPartyMismatchException +import XCTest + +class ListWebAuthnCredentialsTaskTests: XCTestCase { + private var task: ListWebAuthnCredentialsTask! + private var identityProvider: MockIdentityProvider! + + override func setUp() { + let identity = MockIdentity( + mockGetIdResponse: { _ in + return .init(identityId: "mockIdentityId") + }, + mockGetCredentialsResponse: { _ in + let credentials = CognitoIdentityClientTypes.Credentials( + accessKeyId: "accessKey", + expiration: Date(), + secretKey: "secret", + sessionToken: "session" + ) + return .init( + credentials: credentials, + identityId: "responseIdentityID" + ) + } + ) + identityProvider = MockIdentityProvider() + let initialState = AuthState.configured( + .signedIn( + SignedInData( + signedInDate: Date(), + signInMethod: .apiBased(.userSRP), + cognitoUserPoolTokens: AWSCognitoUserPoolTokens.testData + ) + ), + .sessionEstablished(AmplifyCredentials.testData), + .notStarted + ) + + let stateMachine = Defaults.makeDefaultAuthStateMachine( + initialState: initialState, + identityPoolFactory: { + return identity + }, + userPoolFactory: { + return self.identityProvider + } + ) + + task = ListWebAuthnCredentialsTask( + request: .init(options: .init()), + authStateMachine: stateMachine, + userPoolFactory: { + return self.identityProvider + } + ) + } + + override func tearDown() { + identityProvider = nil + task = nil + } + + func testExecute_withSuccess_shouldSucceed() async throws { + var listWebAuthnCredentialsCallCount = 0 + identityProvider.mockListWebAuthnCredentialsResponse = { _ in + listWebAuthnCredentialsCallCount += 1 + return .init( + credentials: [ + .init( + authenticatorAttachment: "authenticatorAttachment1", + authenticatorTransports: [], + createdAt: Date(), + credentialId: "credentialId1", + friendlyCredentialName: "friendlyCredentialName1", + relyingPartyId: "relyingPartyId" + ), + .init( + authenticatorAttachment: "authenticatorAttachment2", + authenticatorTransports: [], + createdAt: Date(), + credentialId: "credentialId2", + friendlyCredentialName: "friendlyCredentialName2", + relyingPartyId: "relyingPartyId" + ) + ], + nextToken: "nextToken" + ) + } + + let result = try await task.execute() + let credentials = try XCTUnwrap(result.credentials) + let nextToken = try XCTUnwrap(result.nextToken) + XCTAssertEqual(listWebAuthnCredentialsCallCount, 1) + XCTAssertEqual(credentials.count, 2) + XCTAssertEqual(nextToken, "nextToken") + XCTAssertTrue(credentials.contains(where: { $0.credentialId == "credentialId1" })) + XCTAssertTrue(credentials.contains(where: { $0.credentialId == "credentialId2" })) + } + + func testExecute_withServiceError_shouldFailWithServiceError() async { + identityProvider.mockListWebAuthnCredentialsResponse = { _ in + throw WebAuthnRelyingPartyMismatchException(message: "Operation is forbidden") + } + + do { + _ = try await task.execute() + XCTFail("Task should have failed") + } catch let error as AuthError { + guard case .service(_, _, let underlyingError) = error else { + XCTFail("Expected AuthError.service error, got \(error)") + return + } + XCTAssertEqual(underlyingError as? AWSCognitoAuthError, AWSCognitoAuthError.webAuthnRelyingPartyMismatch) + } catch { + XCTFail("Expected AuthError error, got \(error)") + } + } + + func testExecute_withOtherError_shouldFailWithUnknownServiceError() async { + identityProvider.mockListWebAuthnCredentialsResponse = { _ in + throw CancellationError() + } + + do { + _ = try await task.execute() + XCTFail("Task should have failed") + } catch let error as AuthError { + guard case .service(let description, _, _) = error else { + XCTFail("Expected AuthError.service error, got \(error)") + return + } + XCTAssertEqual(description, "An unknown error type was thrown by the service. Unable to list WebAuthn credentials.") + } catch { + XCTFail("Expected AuthError error, got \(error)") + } + } +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/AuthState+Codable.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/AuthState+Codable.swift index 870ef3469b..0b57816f72 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/AuthState+Codable.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/AuthState+Codable.swift @@ -14,6 +14,7 @@ extension AuthState: Codable { case type case authenticationState = "AuthenticationState" case authorizationState = "AuthorizationState" + case signUpState = "SignUpState" } public init(from decoder: Decoder) throws { @@ -24,9 +25,11 @@ extension AuthState: Codable { if type == "AuthState.Configured" { let authenticationState = try values.decode(AuthenticationState.self, forKey: .authenticationState) let authorizationState = try values.decode(AuthorizationState.self, forKey: .authorizationState) + let signUpState = try values.decode(SignUpState.self, forKey: .signUpState) self = .configured( authenticationState, - authorizationState) + authorizationState, + .notStarted) } else { fatalError("Decoding not supported") } @@ -34,10 +37,11 @@ extension AuthState: Codable { public func encode(to encoder: Encoder) throws { switch self { - case .configured(let authenticationState, let authorizationState): + case .configured(let authenticationState, let authorizationState, let signUpState): var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(authenticationState, forKey: .authenticationState) try container.encode(authorizationState, forKey: .authorizationState) + try container.encode(signUpState, forKey: .signUpState) default: fatalError("not implemented") } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/CodableStates.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/CodableStates.swift index 0dc049110d..e5468b40e9 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/CodableStates.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/CodableStates.swift @@ -161,6 +161,9 @@ extension SignInError: Codable { fatalError("service error decoding not supported") case .service(_): fatalError("service error decoding not supported") + case .webAuthn(_): + //TODO: Fix the decoding if needed, or throw fatalError + fatalError("service error decoding not supported") case .unknown(message: let message): try container.encode(message, forKey: .unknown) } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignInChallengeState+Codable.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignInChallengeState+Codable.swift index f2d200309f..5b64b0013a 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignInChallengeState+Codable.swift +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignInChallengeState+Codable.swift @@ -28,6 +28,8 @@ extension SignInChallengeState: Codable { self = .waitingForAnswer( RespondToAuthChallenge( challenge: try nestedContainerValue.decode(CognitoIdentityProviderClientTypes.ChallengeNameType.self, forKey: .challengeName), + // TODO: Fix deocoding + availableChallenges: [], 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)), diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignUpState+Codable.swift b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignUpState+Codable.swift new file mode 100644 index 0000000000..a67ee04503 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestHarness/CodableStates/SignUpState+Codable.swift @@ -0,0 +1,52 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +@testable import AWSCognitoAuthPlugin +import Foundation +import Amplify + +extension SignUpState: Codable { + + enum CodingKeys: String, CodingKey { + case type + case SignUpEventData + case AuthSignUpResult + case SignUpError + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + let type = try values.decode(String.self, forKey: .type) + if type == "SignUpState.notStarted" { + self = .notStarted + } else if type == "SignUpState.initiatingSignUp" { + let eventData = try values.decode(SignUpEventData.self, forKey: .SignUpEventData) + self = .initiatingSignUp(eventData) + } else if type == "SignUpState.awaitingUserConfirmation" { + let eventData = try values.decode(SignUpEventData.self, forKey: .SignUpEventData) + let result = try values.decode(AuthSignUpResult.self, forKey: .AuthSignUpResult) + self = .awaitingUserConfirmation(eventData, result) + } else if type == "SignUpState.confirmingSignUp" { + let eventData = try values.decode(SignUpEventData.self, forKey: .SignUpEventData) + self = .confirmingSignUp(eventData) + } else if type == "SignUpState.signedUp" { + let eventData = try values.decode(SignUpEventData.self, forKey: .SignUpEventData) + let result = try values.decode(AuthSignUpResult.self, forKey: .AuthSignUpResult) + self = .signedUp(eventData, result) + } else if type == "SignUpState.error" { + let eventError = try values.decode(SignUpError.self, forKey: .SignUpError) + self = .error(eventError) + } else { + fatalError("Decoding not supported") + } + } + + public func encode(to encoder: Encoder) throws { + fatalError("Encoding not supported") + } + +} diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestResources/states/SignedIn_SessionEstablished.json b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestResources/states/SignedIn_SessionEstablished.json index 3b95f8e971..52098e1ed7 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestResources/states/SignedIn_SessionEstablished.json +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestResources/states/SignedIn_SessionEstablished.json @@ -45,5 +45,8 @@ "expiration": 10690446268 } } + }, + "SignUpState" : { + "type": "SignUpState.notStarted" } } diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestResources/states/SignedOut_Configured.json b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestResources/states/SignedOut_Configured.json index 7dcef9586b..803148b38e 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestResources/states/SignedOut_Configured.json +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestResources/states/SignedOut_Configured.json @@ -8,5 +8,8 @@ }, "AuthorizationState": { "type": "AuthorizationState.Configured" + }, + "SignUpState" : { + "type": "SignUpState.notStarted" } } \ No newline at end of file diff --git a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestResources/states/SigningIn_SigningIn.json b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestResources/states/SigningIn_SigningIn.json index 00c6cc3910..fc19266749 100644 --- a/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestResources/states/SigningIn_SigningIn.json +++ b/AmplifyPlugins/Auth/Tests/AWSCognitoAuthPluginUnitTests/TestResources/states/SigningIn_SigningIn.json @@ -20,5 +20,8 @@ }, "AuthorizationState": { "type": "AuthorizationState.Configured" + }, + "SignUpState" : { + "type": "SignUpState.notStarted" } } diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/project.pbxproj b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/project.pbxproj index 288dc10c20..89765429fc 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/project.pbxproj +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthHostApp.xcodeproj/project.pbxproj @@ -61,6 +61,12 @@ 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 */; }; + 486D622F2CF23FA6001FD075 /* PasswordlessSignUpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979D8A572CE283CA00E9B28F /* PasswordlessSignUpTests.swift */; }; + 486D62302CF23FA6001FD075 /* PasswordlessConfirmSignUpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979D8A592CE283DD00E9B28F /* PasswordlessConfirmSignUpTests.swift */; }; + 486D62312CF23FA6001FD075 /* PasswordlessAutoSignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979D8A5B2CE283E900E9B28F /* PasswordlessAutoSignInTests.swift */; }; + 486D62322CF23FA6001FD075 /* PasswordlessAutoSignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979D8A5B2CE283E900E9B28F /* PasswordlessAutoSignInTests.swift */; }; + 486D62332CF23FA6001FD075 /* PasswordlessConfirmSignUpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979D8A592CE283DD00E9B28F /* PasswordlessConfirmSignUpTests.swift */; }; + 486D62342CF23FA6001FD075 /* PasswordlessSignUpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979D8A572CE283CA00E9B28F /* PasswordlessSignUpTests.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 */; }; @@ -77,6 +83,9 @@ 48BCE8942A54564C0012C3CD /* MFASignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48599D492A429893009DE21C /* MFASignInTests.swift */; }; 48BCE8952A54564C0012C3CD /* MFAPreferenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48916F3B2A42333E00E3E1B1 /* MFAPreferenceTests.swift */; }; 48BCE8962A5456600012C3CD /* TOTPHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48916F392A412CEE00E3E1B1 /* TOTPHelper.swift */; }; + 48DEF59A2CDB1FF500BDB995 /* PasswordlessSignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48DEF5992CDB1FF500BDB995 /* PasswordlessSignInTests.swift */; }; + 48DEF59B2CDB1FF500BDB995 /* PasswordlessSignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48DEF5992CDB1FF500BDB995 /* PasswordlessSignInTests.swift */; }; + 48DEF59C2CDB1FF500BDB995 /* PasswordlessSignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48DEF5992CDB1FF500BDB995 /* PasswordlessSignInTests.swift */; }; 48E3AB3128E52590004EE395 /* GetCurrentUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48E3AB3028E52590004EE395 /* GetCurrentUserTests.swift */; }; 681B76952A3CB8DA004B59D9 /* AuthHostAppApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485CB53D27B614CE006CCEC7 /* AuthHostAppApp.swift */; }; 681B76962A3CB8DD004B59D9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485CB53F27B614CE006CCEC7 /* ContentView.swift */; }; @@ -124,6 +133,9 @@ 97914B51295509AB002000EA /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 97914AF6295503D4002000EA /* README.md */; }; 97914B5629552D2B002000EA /* AuthSessionHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485CB5B727B61F0F006CCEC7 /* AuthSessionHelper.swift */; }; 97914B5729552D2B002000EA /* AuthSignInHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 485CB5B827B61F0F006CCEC7 /* AuthSignInHelper.swift */; }; + 979D8A582CE283CA00E9B28F /* PasswordlessSignUpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979D8A572CE283CA00E9B28F /* PasswordlessSignUpTests.swift */; }; + 979D8A5A2CE283DD00E9B28F /* PasswordlessConfirmSignUpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979D8A592CE283DD00E9B28F /* PasswordlessConfirmSignUpTests.swift */; }; + 979D8A5C2CE283E900E9B28F /* PasswordlessAutoSignInTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 979D8A5B2CE283E900E9B28F /* PasswordlessAutoSignInTests.swift */; }; 97B370C52878DA5A00F1C088 /* AuthFetchDeviceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97B370C42878DA5A00F1C088 /* AuthFetchDeviceTests.swift */; }; B43C26CA27BC9D54003F3BF7 /* AuthSignUpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43C26C727BC9D54003F3BF7 /* AuthSignUpTests.swift */; }; B43C26CB27BC9D54003F3BF7 /* AuthConfirmSignUpTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43C26C827BC9D54003F3BF7 /* AuthConfirmSignUpTests.swift */; }; @@ -209,6 +221,7 @@ 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 = ""; }; + 48DEF5992CDB1FF500BDB995 /* PasswordlessSignInTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordlessSignInTests.swift; sourceTree = ""; }; 48E3AB3028E52590004EE395 /* GetCurrentUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GetCurrentUserTests.swift; sourceTree = ""; }; 681B76802A3CB86B004B59D9 /* AuthWatchApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AuthWatchApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 681B76C42A3CBBAE004B59D9 /* AuthIntegrationTestsWatch.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AuthIntegrationTestsWatch.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -224,6 +237,9 @@ 97914AD72954FE0A002000EA /* AuthStressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthStressTests.swift; sourceTree = ""; }; 97914AF6295503D4002000EA /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 97914B4B29550988002000EA /* AuthStressTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AuthStressTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 979D8A572CE283CA00E9B28F /* PasswordlessSignUpTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordlessSignUpTests.swift; sourceTree = ""; }; + 979D8A592CE283DD00E9B28F /* PasswordlessConfirmSignUpTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordlessConfirmSignUpTests.swift; sourceTree = ""; }; + 979D8A5B2CE283E900E9B28F /* PasswordlessAutoSignInTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordlessAutoSignInTests.swift; sourceTree = ""; }; 97B370C42878DA5A00F1C088 /* AuthFetchDeviceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthFetchDeviceTests.swift; sourceTree = ""; }; B43C26C727BC9D54003F3BF7 /* AuthSignUpTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthSignUpTests.swift; sourceTree = ""; }; B43C26C827BC9D54003F3BF7 /* AuthConfirmSignUpTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthConfirmSignUpTests.swift; sourceTree = ""; }; @@ -379,6 +395,7 @@ 485CB5A027B61E04006CCEC7 /* AuthIntegrationTests */ = { isa = PBXGroup; children = ( + 979D8A552CE2838600E9B28F /* PasswordlessTests */, 21CFD7C42C75243B0071C70F /* AppSyncSignerTests */, 21F762CC2BD6B1CD0048845A /* AuthGen2IntegrationTests.xctestplan */, 48916F362A412AF800E3E1B1 /* MFATests */, @@ -497,6 +514,17 @@ path = AuthStressTests; sourceTree = ""; }; + 979D8A552CE2838600E9B28F /* PasswordlessTests */ = { + isa = PBXGroup; + children = ( + 979D8A572CE283CA00E9B28F /* PasswordlessSignUpTests.swift */, + 979D8A592CE283DD00E9B28F /* PasswordlessConfirmSignUpTests.swift */, + 979D8A5B2CE283E900E9B28F /* PasswordlessAutoSignInTests.swift */, + 48DEF5992CDB1FF500BDB995 /* PasswordlessSignInTests.swift */, + ); + path = PasswordlessTests; + sourceTree = ""; + }; 97B370C32878DA3500F1C088 /* DeviceTests */ = { isa = PBXGroup; children = ( @@ -830,6 +858,9 @@ buildActionMask = 2147483647; files = ( 21F762A52BD6B1AA0048845A /* AuthSessionHelper.swift in Sources */, + 486D62322CF23FA6001FD075 /* PasswordlessAutoSignInTests.swift in Sources */, + 486D62332CF23FA6001FD075 /* PasswordlessConfirmSignUpTests.swift in Sources */, + 486D62342CF23FA6001FD075 /* PasswordlessSignUpTests.swift in Sources */, 21F762A62BD6B1AA0048845A /* AsyncTesting.swift in Sources */, 21F762A72BD6B1AA0048845A /* AuthSRPSignInTests.swift in Sources */, 21F762A82BD6B1AA0048845A /* AuthForgetDeviceTests.swift in Sources */, @@ -857,6 +888,7 @@ 21F762BD2BD6B1AA0048845A /* AuthResetPasswordTests.swift in Sources */, 21F762BE2BD6B1AA0048845A /* AuthUserAttributesTests.swift in Sources */, 21F762BF2BD6B1AA0048845A /* MFASignInTests.swift in Sources */, + 48DEF59B2CDB1FF500BDB995 /* PasswordlessSignInTests.swift in Sources */, 21F762C02BD6B1AA0048845A /* SignedInAuthSessionTests.swift in Sources */, 21F762C12BD6B1AA0048845A /* AuthSignUpTests.swift in Sources */, 487C40252CACF303009CF221 /* EmailMFAWithAllMFATypesRequiredTests.swift in Sources */, @@ -878,10 +910,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 979D8A5C2CE283E900E9B28F /* PasswordlessAutoSignInTests.swift in Sources */, 485CB5B927B61F10006CCEC7 /* AuthSessionHelper.swift in Sources */, 487C403F2CADE8DA009CF221 /* EmailMFAOnlyTests.swift in Sources */, 681DFEAB28E747B80000C36A /* AsyncTesting.swift in Sources */, 485CB5C227B61F1E006CCEC7 /* AuthSRPSignInTests.swift in Sources */, + 48DEF59A2CDB1FF500BDB995 /* PasswordlessSignInTests.swift in Sources */, 9737C7502880BFD600DA0D2B /* AuthForgetDeviceTests.swift in Sources */, B43C26CB27BC9D54003F3BF7 /* AuthConfirmSignUpTests.swift in Sources */, 48916F3C2A42333E00E3E1B1 /* MFAPreferenceTests.swift in Sources */, @@ -897,9 +931,11 @@ 485CB5C027B61F1E006CCEC7 /* SignedOutAuthSessionTests.swift in Sources */, 485CB5BA27B61F10006CCEC7 /* AuthSignInHelper.swift in Sources */, 4834D7C128B0770800DD564B /* FederatedSessionTests.swift in Sources */, + 979D8A5A2CE283DD00E9B28F /* PasswordlessConfirmSignUpTests.swift in Sources */, 4821B2F428737130000EC1D7 /* AuthCustomSignInTests.swift in Sources */, 484EDEB227F4FFBE000284B4 /* AuthEventIntegrationTests.swift in Sources */, 484834BE27B6FD9B00649D11 /* AuthEnvironmentHelper.swift in Sources */, + 979D8A582CE283CA00E9B28F /* PasswordlessSignUpTests.swift in Sources */, 48916F382A412B2800E3E1B1 /* TOTPSetupWhenAuthenticatedTests.swift in Sources */, 484834BC27B6ED8800649D11 /* CredentialStoreConfigurationTests.swift in Sources */, 9737C74E287E208400DA0D2B /* AuthRememberDeviceTests.swift in Sources */, @@ -937,6 +973,9 @@ 681B76A92A3CBBAE004B59D9 /* AuthFetchDeviceTests.swift in Sources */, 48BCE8922A5456460012C3CD /* TOTPSetupWhenAuthenticatedTests.swift in Sources */, 48BCE8962A5456600012C3CD /* TOTPHelper.swift in Sources */, + 486D622F2CF23FA6001FD075 /* PasswordlessSignUpTests.swift in Sources */, + 486D62302CF23FA6001FD075 /* PasswordlessConfirmSignUpTests.swift in Sources */, + 486D62312CF23FA6001FD075 /* PasswordlessAutoSignInTests.swift in Sources */, 681B76AA2A3CBBAE004B59D9 /* AsyncExpectation.swift in Sources */, 48BCE8932A54564C0012C3CD /* TOTPSetupWhenUnauthenticatedTests.swift in Sources */, 681B76AB2A3CBBAE004B59D9 /* GetCurrentUserTests.swift in Sources */, @@ -956,6 +995,7 @@ 681B76B72A3CBBAE004B59D9 /* AuthResetPasswordTests.swift in Sources */, 681B76B82A3CBBAE004B59D9 /* AuthUserAttributesTests.swift in Sources */, 681B76B92A3CBBAE004B59D9 /* SignedInAuthSessionTests.swift in Sources */, + 48DEF59C2CDB1FF500BDB995 /* PasswordlessSignInTests.swift in Sources */, 681B76BA2A3CBBAE004B59D9 /* AuthSignUpTests.swift in Sources */, 681B76BB2A3CBBAE004B59D9 /* AuthConfirmResetPasswordTests.swift in Sources */, 487C40242CACF303009CF221 /* EmailMFAWithAllMFATypesRequiredTests.swift in Sources */, diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/AWSAuthBaseTest.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/AWSAuthBaseTest.swift index fa4a455bde..0e8ffceb84 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/AWSAuthBaseTest.swift +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/AWSAuthBaseTest.swift @@ -51,6 +51,7 @@ class AWSAuthBaseTest: XCTestCase { override func tearDown() async throws { try await super.tearDown() subscription?.cancel() + usernameOTPDictionary = [:] await Amplify.reset() } @@ -119,8 +120,8 @@ class AWSAuthBaseTest: XCTestCase { } } - // Dictionary to store MFA codes with usernames as keys - var mfaCodeDictionary: [String: String] = [:] + // Dictionary to store OTP with usernames as keys + var usernameOTPDictionary: [String: String] = [:] var subscription: AmplifyAsyncThrowingSequence>? = nil let document: String = """ @@ -133,26 +134,44 @@ class AWSAuthBaseTest: XCTestCase { } """ - /// Function to create a subscription and store MFA codes in a dictionary - func createMFASubscription() { + /// Function to create a subscription and store OTP codes in a dictionary + func subscribeToOTPCreation() async { subscription = Amplify.API.subscribe(request: .init(document: document, responseType: [String: JSONValue].self)) - // Create the subscription and listen for MFA code events + func waitForSubscriptionConnection( + subscription: AmplifyAsyncThrowingSequence> + ) async throws { + for try await subscriptionEvent in subscription { + if case .connection(let subscriptionConnectionState) = subscriptionEvent { + print("Subscription connect state is \(subscriptionConnectionState)") + if subscriptionConnectionState == .connected { + return + } + } + } + } + + guard let subscription = subscription else { return } + + await wait(name: "Subscription Connection Waiter", timeout: 5.0) { + try await waitForSubscriptionConnection(subscription: subscription) + } + + // Create the subscription and listen for OTP 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 { + case .success(let otpResult): + print("Successfully got OTP code from subscription: \(otpResult)") + if let eventUsername = otpResult["onCreateMfaInfo"]?.asObject?["username"]?.stringValue, + let code = otpResult["onCreateMfaInfo"]?.asObject?["code"]?.stringValue { // Store the code in the dictionary for the given username - mfaCodeDictionary[eventUsername] = code + usernameOTPDictionary[eventUsername.lowercased()] = code } case .failure(let error): print("Got failed result with \(error.errorDescription)") @@ -165,16 +184,17 @@ class AWSAuthBaseTest: XCTestCase { } } - /// 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") + /// Test that waits for the OTP code using XCTestExpectation + func otp(for username: String) async throws -> String? { + let lowerCasedUsername = username.lowercased() + let expectation = XCTestExpectation(description: "Wait for OTP") 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 + if let otp = usernameOTPDictionary[lowerCasedUsername] { + code = otp expectation.fulfill() // Fulfill the expectation when the value is found break } @@ -189,11 +209,9 @@ class AWSAuthBaseTest: XCTestCase { if result == .timedOut { // Task cancels if timed out task.cancel() - subscription?.cancel() return nil } - - subscription?.cancel() + usernameOTPDictionary.removeValue(forKey: lowerCasedUsername) return try await task.value } } diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAOnlyTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAOnlyTests.swift index 06c899c2b5..500005d5c5 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAOnlyTests.swift +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAOnlyTests.swift @@ -60,7 +60,7 @@ class EmailMFARequiredTests: AWSAuthBaseTest { func testSuccessfulEmailMFASetupStep() async { do { // Step 1: Set up a subscription to receive MFA codes - createMFASubscription() + await subscribeToOTPCreation() // Step 2: Sign up a new user let uniqueId = UUID().uuidString @@ -102,7 +102,7 @@ class EmailMFARequiredTests: AWSAuthBaseTest { 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 { + guard let mfaCode = try await otp(for: username.lowercased()) else { XCTFail("Failed to retrieve the MFA code") return } @@ -144,7 +144,7 @@ class EmailMFARequiredTests: AWSAuthBaseTest { func testSuccessfulEmailMFAWithIncorrectCodeFirstAndThenValidOne() async { do { // Step 1: Set up a subscription to receive MFA codes - createMFASubscription() + await subscribeToOTPCreation() // Step 2: Sign up a new user let uniqueId = UUID().uuidString @@ -178,7 +178,7 @@ class EmailMFARequiredTests: AWSAuthBaseTest { 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 { + guard let mfaCode = try await otp(for: username.lowercased()) else { XCTFail("Failed to retrieve the MFA code") return } diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAWithAllMFATypesRequiredTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAWithAllMFATypesRequiredTests.swift index fcbfe6d64d..4fc8b08f53 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAWithAllMFATypesRequiredTests.swift +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/EmailMFAWithAllMFATypesRequiredTests.swift @@ -94,7 +94,7 @@ class EmailMFAWithAllMFATypesRequiredTests: AWSAuthBaseTest { func testSuccessfulEmailMFACodeStep() async { do { // Step 1: Set up a subscription to receive MFA codes - createMFASubscription() + await subscribeToOTPCreation() let uniqueId = UUID().uuidString let username = randomEmail let password = "Pp123@\(uniqueId)" @@ -124,7 +124,7 @@ class EmailMFAWithAllMFATypesRequiredTests: AWSAuthBaseTest { 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 { + guard let mfaCode = try await otp(for: username.lowercased()) else { XCTFail("Failed to retrieve the MFA code") return } @@ -152,7 +152,7 @@ class EmailMFAWithAllMFATypesRequiredTests: AWSAuthBaseTest { func testConfirmSignInForEmailMFASetupSelectionStep() async { do { // Step 1: Set up a subscription to receive MFA codes - createMFASubscription() + await subscribeToOTPCreation() let uniqueId = UUID().uuidString let username = "\(uniqueId)" let password = "Pp123@\(uniqueId)" @@ -204,7 +204,7 @@ class EmailMFAWithAllMFATypesRequiredTests: AWSAuthBaseTest { 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 { + guard let mfaCode = try await otp(for: username.lowercased()) else { XCTFail("Failed to retrieve the MFA code") return } diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/README.md b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/README.md index b2a4c2fa45..fe210e3acc 100644 --- a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/README.md +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/MFATests/EmailMFATests/README.md @@ -10,31 +10,14 @@ The following steps demonstrate how to setup the integration tests for auth plug 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" - }, -} -``` +1. From a new folder, run `npm create amplify@latest`. This will create a new amplify project with the latest version of the Amplify CLI. -2. Update `amplify/auth/resource.ts`. The resulting file should look like this +2. Update `amplify/auth/resource.ts`. The resulting file should look like this. Replace `` with your verified email address. ```ts -import { defineAuth, defineFunction } from "@aws-amplify/backend"; +import { defineAuth } from '@aws-amplify/backend'; + +const fromEmail = ''; /** * Define and configure your auth resource @@ -73,6 +56,8 @@ export const auth = defineAuth({ }); ``` +3. Create a file `amplify/functions/cognito-triggers/pre-sign-up-handler.ts` with the following content + ```ts import type { PreSignUpTriggerHandler } from "aws-lambda"; @@ -92,7 +77,8 @@ export const handler: PreSignUpTriggerHandler = async (event) => { return event; }; ``` -Create a file `amplify/data/mfa/index.graphql` with the following content + +4. 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. @@ -123,7 +109,7 @@ type MfaInfo { } ``` -Update `amplify/data/mfa/index.ts`. The resulting file should look like this +5. Update `amplify/data/mfa/index.ts`. The resulting file should look like this ```ts import { Duration, Expiration, RemovalPolicy, Stack } from "aws-cdk-lib"; @@ -311,9 +297,7 @@ cfnUserPool.addPropertyOverride("DeviceConfiguration", { }); ``` -The triggers should look as follows: - -Common +6. Create a file `amplify/functions/cognito-triggers/common.ts` with the following content ```ts // Code adapted from: @@ -390,7 +374,7 @@ export const decryptAndBroadcastCode = async ( }; ``` -custom-email-sender +7. Create a file `amplify/functions/cognito-triggers/custom-email-sender.ts` with the following content ```ts import { CustomEmailSenderTriggerHandler } from "aws-lambda"; @@ -416,7 +400,8 @@ export const handler: CustomEmailSenderTriggerHandler = async (event) => { }; ``` -custom-sms-sender +8. Create a file `amplify/functions/cognito-triggers/custom-sms-sender.ts` with the following content + ```ts import { CustomSMSSenderTriggerHandler } from "aws-lambda"; @@ -439,7 +424,7 @@ export const handler: CustomSMSSenderTriggerHandler = async (event) => { }; ``` -4. Deploy the backend with npx amplify sandbox +9. Deploy the backend with npx amplify sandbox For example, this deploys to a sandbox env and generates the amplify_outputs.json file. @@ -447,7 +432,7 @@ For example, this deploys to a sandbox env and generates the amplify_outputs.jso 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. +10. 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 diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/PasswordlessTests/PasswordlessAutoSignInTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/PasswordlessTests/PasswordlessAutoSignInTests.swift new file mode 100644 index 0000000000..e05a954c7f --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/PasswordlessTests/PasswordlessAutoSignInTests.swift @@ -0,0 +1,171 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import Amplify +import AWSCognitoAuthPlugin +import AWSAPIPlugin + +class PasswordlessAutoSignInTests: AWSAuthBaseTest { + + override func setUp() async throws { + // Only run these tests with Gen2 configuration + onlyUseGen2Configuration = true + + // Use a custom configuration these tests + amplifyOutputsFile = "testconfiguration/AWSCognitoPluginPasswordlessIntegrationTests-amplify_outputs" + + // Add API plugin to Amplify + let awsApiPlugin = AWSAPIPlugin() + try Amplify.add(plugin: awsApiPlugin) + + try await super.setUp() + AuthSessionHelper.clearSession() + } + + override func tearDown() async throws { + try await super.tearDown() + AuthSessionHelper.clearSession() + } + + /// Test for failure when auto sign in is done without sign up + /// + /// - Given: An initialized Amplify backend with auth plugin in `.signedOut` state + /// - When: + /// - I invoke Amplify.Auth.autoSignIn + /// - Then: + /// - I should get an `.invalidState` error + /// + func testFailureAutoSignInWithoutSignUp() async throws { + // auto sign in + do { + let _ = try await Amplify.Auth.autoSignIn() + XCTFail("Auto sign in should not succeed") + } catch (let error) { + XCTAssertNotNil(error) + guard case AuthError.invalidState = error else { + XCTFail("Should return invalidState error") + return + } + } + } + + /// Test successful sign up, confirm sign up and auto sign of a user + /// + /// - Given: A Cognito user pool configured with passwordless user auth + /// - When: + /// - I invoke Amplify.Auth.signUp, Amplify.Auth.confirmSignUp with the username and email + /// followed by Amplify.Auth.autoSignIn + /// - Then: + /// - I should get a completed sign in flow + /// + func testSuccessfulPasswordlessSignUpAndAutoSignInEndtoEnd() async throws { + + await subscribeToOTPCreation() + + let username = "integTest\(UUID().uuidString)" + let options = AuthSignUpRequest.Options( + userAttributes: [ AuthUserAttribute(.email, value: randomEmail)]) + + // sign up + let signUpResult = try await Amplify.Auth.signUp(username: username, options: options) + guard case .confirmUser = signUpResult.nextStep else { + XCTFail("Incorrect next step for sign up confirmation") + return + } + XCTAssertFalse(signUpResult.isSignUpComplete) + + // wait for otp + guard let otp = try await otp(for: username) else { + XCTFail("Failed to retrieve the OTP code") + return + } + + // confirm sign up + let confirmSignUpResult = try await Amplify.Auth.confirmSignUp( + for: username, confirmationCode: otp) + guard case .completeAutoSignIn(let session) = confirmSignUpResult.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(confirmSignUpResult.isSignUpComplete, "Confirm Sign up result should be complete") + XCTAssertFalse(session.isEmpty) + + // auto sign in + let autoSignInResult = try await Amplify.Auth.autoSignIn() + guard case .done = autoSignInResult.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(autoSignInResult.isSignedIn, "Signin result should be complete") + } + + /// Test for failure when auto sign in is invoked multiple times + /// + /// - Given: An initialized Amplify backend with auth plugin in `.signedOut` state + /// - When: + /// - I invoke Amplify.Auth.autoSignIn with a cached auto sign in session + /// - Then: + /// - I should get a `.notAuthorized` error + /// + func testFailureMultipleAutoSignInWithSameSession() async throws { + + await subscribeToOTPCreation() + + let username = "integTest\(UUID().uuidString)" + let options = AuthSignUpRequest.Options( + userAttributes: [ AuthUserAttribute(.email, value: randomEmail)]) + + // sign up + let signUpResult = try await Amplify.Auth.signUp(username: username, options: options) + guard case .confirmUser = signUpResult.nextStep else { + XCTFail("Incorrect next step for sign up confirmation") + return + } + XCTAssertFalse(signUpResult.isSignUpComplete) + + // wait for otp + guard let otp = try await otp(for: username) else { + XCTFail("Failed to retrieve the OTP code") + return + } + + // confirm sign up + let confirmSignUpResult = try await Amplify.Auth.confirmSignUp( + for: username, confirmationCode: otp) + guard case .completeAutoSignIn(let session) = confirmSignUpResult.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(confirmSignUpResult.isSignUpComplete, "Confirm Sign up result should be complete") + XCTAssertFalse(session.isEmpty) + + // auto sign in + let autoSignInResult = try await Amplify.Auth.autoSignIn() + guard case .done = autoSignInResult.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(autoSignInResult.isSignedIn, "Signin result should be complete") + + // sign out + let _ = await Amplify.Auth.signOut(options: .init(globalSignOut: true)) + + // auto sign in again using the same session + do { + let _ = try await Amplify.Auth.autoSignIn() + XCTFail("Multiple auto sign in with same session should not succeed") + } catch (let error) { + XCTAssertNotNil(error) + guard case AuthError.notAuthorized = error else { + XCTFail("Should return .notAuthorized error") + return + } + } + + } +} diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/PasswordlessTests/PasswordlessConfirmSignUpTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/PasswordlessTests/PasswordlessConfirmSignUpTests.swift new file mode 100644 index 0000000000..2086343e7d --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/PasswordlessTests/PasswordlessConfirmSignUpTests.swift @@ -0,0 +1,162 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import Amplify +import AWSCognitoAuthPlugin +import AWSAPIPlugin + +class PasswordlessConfirmSignUpTests: AWSAuthBaseTest { + + override func setUp() async throws { + // Only run these tests with Gen2 configuration + onlyUseGen2Configuration = true + + // Use a custom configuration these tests + amplifyOutputsFile = "testconfiguration/AWSCognitoPluginPasswordlessIntegrationTests-amplify_outputs" + + // Add API plugin to Amplify + let awsApiPlugin = AWSAPIPlugin() + try Amplify.add(plugin: awsApiPlugin) + + try await super.setUp() + AuthSessionHelper.clearSession() + } + + override func tearDown() async throws { + try await super.tearDown() + AuthSessionHelper.clearSession() + } + + /// Test if confirmSignUp returns `.userNotFound` error for a non existing user + /// + /// - Given: A user which is not registered to the configured user pool + /// - When: + /// - I invoke confirmSignUp with the user + /// - Then: + /// - I should get a userNotFound error. (Gen1 - PreventUserExistenceErrors disabled) + /// - I should get a codeMismatch error. (Gen2 - PreventUserExistenceErrors enabled) + /// (https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pool-managing-errors.html#cognito-user-pool-managing-errors-password-reset) + /// + func testFailurePasswordlessConfirmSignUpUserNotFound() async throws { + let username = "integTest\(UUID().uuidString)" + do { + let confirmSignUpResult = try await Amplify.Auth.confirmSignUp( + for: username, + confirmationCode: "123456", + options: AuthConfirmSignUpRequest.Options() + ) + XCTFail("Confirm sign up call should not succeed") + } catch (let error) { + XCTAssertNotNil(error) + guard + let authError = error as? AuthError, + let cognitoError = authError.underlyingError as? AWSCognitoAuthError else { + XCTFail("Should return cognitoAuthError") + return + } + + switch cognitoError { + case .userNotFound, .codeMismatch, .codeExpired: + return + default: + XCTFail("Error should be either `.userNotFound` or `.codeMismatch` or `.codeExpired`") + } + } + } + + /// Test if confirmSignUp returns validation error + /// + /// - Given: An invalid input to confirmSignUp like empty code + /// - When: + /// - I invoke confirmSignUp with empty code + /// - Then: + /// - I should get validation error. + /// + func testFailurePasswordlessConfirmSignUpEmptyCode() async throws { + let username = "integTest\(UUID().uuidString)" + do { + _ = try await Amplify.Auth.confirmSignUp( + for: username, + confirmationCode: "", + options: AuthConfirmSignUpRequest.Options()) + XCTFail("confirmSignUp with validation error should not succeed") + } catch { + guard case AuthError.validation = error else { + XCTFail("Should return validation error") + return + } + } + } + + /// Test if confirmSignUp returns validation error + /// + /// - Given: An invalid input to confirmSignUp like empty username + /// - When: + /// - I invoke confirmSignUp with empty username + /// - Then: + /// - I should get validation error. + /// + func testFailurePasswordlessConfirmSignUpEmptyUsername() async throws { + let username = "" + do { + _ = try await Amplify.Auth.confirmSignUp( + for: username, + confirmationCode: "123456", + options: AuthConfirmSignUpRequest.Options()) + XCTFail("confirmSignUp with validation error should not succeed") + } catch { + guard case AuthError.validation = error else { + XCTFail("Should return validation error") + return + } + } + } + + /// Test successful sign up and confirm sign up of a user + /// + /// - Given: A Cognito user pool configured with passwordless user auth + /// - When: + /// - I invoke Amplify.Auth.signUp, Amplify.Auth.confirmSignUp with the username and email + /// - Then: + /// - I should get a completed sign up flow + /// + func testSuccessfulPasswordlessSignUpAndConfirmSignUpEndtoEnd() async throws { + + await subscribeToOTPCreation() + + let username = "integTest\(UUID().uuidString)" + let options = AuthSignUpRequest.Options( + userAttributes: [ AuthUserAttribute(.email, value: randomEmail)]) + + // sign up + let signUpResult = try await Amplify.Auth.signUp(username: username, options: options) + guard case .confirmUser = signUpResult.nextStep else { + XCTFail("Incorrect next step for sign up confirmation") + return + } + XCTAssertFalse(signUpResult.isSignUpComplete) + + // wait for otp + guard let otp = try await otp(for: username) else { + XCTFail("Failed to retrieve the OTP code") + return + } + + // confirm sign up + let confirmSignUpResult = try await Amplify.Auth.confirmSignUp( + for: username, + confirmationCode: otp, + options: AuthConfirmSignUpRequest.Options()) + guard case .completeAutoSignIn(let session) = confirmSignUpResult.nextStep else { + XCTFail("Result should be .done for next step") + return + } + XCTAssertTrue(confirmSignUpResult.isSignUpComplete, "Confirm Sign up result should be complete") + XCTAssertFalse(session.isEmpty) + } +} diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/PasswordlessTests/PasswordlessSignInTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/PasswordlessTests/PasswordlessSignInTests.swift new file mode 100644 index 0000000000..3d95e20d8b --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/PasswordlessTests/PasswordlessSignInTests.swift @@ -0,0 +1,937 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import Amplify +import AWSCognitoAuthPlugin +import AWSAPIPlugin + +/// This class contains integration tests for the sign-in functionality using various authentication flows. +/// It tests the sign-in process with different preferred factors such as password, passwordSRP, email OTP, and SMS OTP. +/// The tests ensure that the sign-in process completes successfully for valid users registered in the Cognito user pool. +class PasswordlessSignInTests: AWSAuthBaseTest { + + override func setUp() async throws { + + // Only run these tests with Gen2 configuration + onlyUseGen2Configuration = true + + // Use a custom configuration these tests + amplifyOutputsFile = "testconfiguration/AWSCognitoPluginPasswordlessIntegrationTests-amplify_outputs" + + // Add API plugin to Amplify + let awsApiPlugin = AWSAPIPlugin() + try Amplify.add(plugin: awsApiPlugin) + + try await super.setUp() + AuthSessionHelper.clearSession() + + await subscribeToOTPCreation() + } + + override func tearDown() async throws { + try await super.tearDown() + AuthSessionHelper.clearSession() + } + + private func signUp(username: String, password: String) async throws { + + let result = try await AuthSignInHelper.signUpUserReturningResult( + username: username, + password: password, + email: randomEmail, + phoneNumber: randomPhoneNumber + ) + + // Retrieve the OTP sent to the email and confirm the sign-in + guard let otp = try await otp(for: username) else { + XCTFail("Failed to retrieve the OTP code") + return + } + + guard case .confirmUser = result.nextStep else { + XCTFail("Incorrect next step for sign up confirmation") + return + } + + let confirmSignUpResult = try await Amplify.Auth.confirmSignUp( + for: username, confirmationCode: otp) + + guard confirmSignUpResult.isSignUpComplete else { + XCTFail("Failed confirmation of sign up") + return + } + } + + /// Test successful signIn of a valid user + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and password, using userAuth flow by setting `password` as the preferred factor + /// - Then: + /// - I should get a completed signIn flow. + /// + func testSignInWithPasswordAsPreferred_givenValidUser_expectCompletedSignIn() async throws { + + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions( + authFlowType: .userAuth(preferredFirstFactor: .password)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init(pluginOptions: pluginOptions)) + XCTAssertTrue(signInResult.isSignedIn, "SignIn should be complete") + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test successful signIn of a valid user + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and password, using userAuth flow by setting `passwordSRP` as the preferred factor + /// - Then: + /// - I should get a completed signIn flow. + /// + func testSignInWithPasswordSRPAsPreferred_givenValidUser_expectCompletedSignIn() async throws { + + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions( + authFlowType: .userAuth(preferredFirstFactor: .passwordSRP)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init(pluginOptions: pluginOptions)) + XCTAssertTrue(signInResult.isSignedIn, "SignIn should be complete") + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test successful signIn of a valid user + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and password, using userAuth flow + /// - Then: + /// - I should get a completed signIn flow. + /// + func testSignInWithPasswordSRP_givenValidUser_expectCompletedSignIn() async throws { + + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions( + authFlowType: .userAuth) + let signInResult = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init(pluginOptions: pluginOptions)) + guard case .continueSignInWithFirstFactorSelection(let availableFactors) = signInResult.nextStep else { + XCTFail("SignIn should return a .continueSignInWithFirstFactorSelection") + return + } + XCTAssert(availableFactors.contains(.passwordSRP)) + var confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: AuthFactorType.passwordSRP.challengeResponse) + + guard case .confirmSignInWithPassword = confirmSignInResult.nextStep else { + XCTFail("ConfirmSignIn should return a .confirmSignInWithPassword") + return + } + + confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: password) + + XCTAssertTrue(confirmSignInResult.isSignedIn, "SignIn should be complete") + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test successful signIn of a valid user + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and password, using userAuth flow + /// - Then: + /// - I should get a completed signIn flow. + /// + func testSignInWithPassword_givenValidUser_expectCompletedSignIn() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions( + authFlowType: .userAuth) + let signInResult = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init(pluginOptions: pluginOptions)) + guard case .continueSignInWithFirstFactorSelection(let availableFactors) = signInResult.nextStep else { + XCTFail("SignIn should return a .continueSignInWithFirstFactorSelection") + return + } + XCTAssert(availableFactors.contains(.password)) + var confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: AuthFactorType.password.challengeResponse) + + guard case .confirmSignInWithPassword = confirmSignInResult.nextStep else { + XCTFail("ConfirmSignIn should return a .confirmSignInWithPassword") + return + } + + confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: password) + + XCTAssertTrue(confirmSignInResult.isSignedIn, "SignIn should be complete") + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + + /// Test successful signIn of a valid user + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and password, using userAuth flow by setting `emailOTP` as the preferred factor + /// - Then: + /// - I should get a completed signIn flow. + /// + func testSignInWithEmailOTPAsPreferred_givenValidUser_expectCompletedSignIn() async throws { + + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions( + authFlowType: .userAuth(preferredFirstFactor: .emailOTP)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + options: .init(pluginOptions: pluginOptions)) + + // Retrieve the OTP sent to the email and confirm the sign-in + guard let otp = try await otp(for: username) else { + XCTFail("Failed to retrieve the OTP code") + return + } + + guard case .confirmSignInWithOTP(let codeDeliverDetails) = signInResult.nextStep else { + XCTFail("SignIn should return a .confirmSignInWithOTP") + return + } + + guard case .email = codeDeliverDetails.destination else { + XCTFail("destination should be email") + return + } + + let confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: otp) + + XCTAssertTrue(confirmSignInResult.isSignedIn, "SignIn should be complete") + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test successful signIn of a valid user + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and password, using userAuth flow by setting `smsOTP` as the preferred factor + /// - Then: + /// - I should get a completed signIn flow. + /// + func testSignInWithSMSOTPAsPreferred_givenValidUser_expectCompletedSignIn() async throws { + + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions( + authFlowType: .userAuth(preferredFirstFactor: .smsOTP)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + options: .init(pluginOptions: pluginOptions)) + + // Retrieve the OTP sent to the email and confirm the sign-in + guard let otp = try await otp(for: username) else { + XCTFail("Failed to retrieve the OTP code") + return + } + + guard case .confirmSignInWithOTP(let codeDeliverDetails) = signInResult.nextStep else { + XCTFail("SignIn should return a .confirmSignInWithOTP") + return + } + + guard case .sms = codeDeliverDetails.destination else { + XCTFail("destination should be sms") + return + } + + let confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: otp) + + XCTAssertTrue(confirmSignInResult.isSignedIn, "SignIn should be complete") + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test successful signIn of a valid user + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and password, using userAuth flow without setting `emailOTP` as the preferred factor + /// - Then: + /// - I should get a completed signIn flow. + func testSignInWithoutEmailOTPAsPreferred_givenValidUser_expectCompletedSignIn() async throws { + + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions( + authFlowType: .userAuth) + let signInResult = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init(pluginOptions: pluginOptions)) + + guard case .continueSignInWithFirstFactorSelection(let availableFactors) = signInResult.nextStep else { + XCTFail("SignIn should return a .continueSignInWithFirstFactorSelection") + return + } + XCTAssert(availableFactors.contains(.emailOTP)) + + // Select emailOTP as the factor + var confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: AuthFactorType.emailOTP.challengeResponse) + + // Retrieve the OTP sent to the email and confirm the sign-in + guard let otp = try await otp(for: username) else { + XCTFail("Failed to retrieve the OTP code") + return + } + + guard case .confirmSignInWithOTP(let codeDeliverDetails) = confirmSignInResult.nextStep else { + XCTFail("ConfirmSignIn should return a .confirmSignInWithOTP") + return + } + + guard case .email = codeDeliverDetails.destination else { + XCTFail("destination should be email") + return + } + + confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: otp) + + XCTAssertTrue(confirmSignInResult.isSignedIn, "SignIn should be complete") + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test successful signIn of a valid user + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and password, using userAuth flow without setting `smsOTP` as the preferred factor + /// - Then: + /// - I should get a completed signIn flow. + func testSignInWithoutSMSOTPAsPreferred_givenValidUser_expectCompletedSignIn() async throws { + + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions( + authFlowType: .userAuth) + let signInResult = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init(pluginOptions: pluginOptions)) + + guard case .continueSignInWithFirstFactorSelection(let availableFactors) = signInResult.nextStep else { + XCTFail("SignIn should return a .continueSignInWithFirstFactorSelection") + return + } + XCTAssert(availableFactors.contains(.smsOTP)) + + // Select smsOTP as the factor + var confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: AuthFactorType.smsOTP.challengeResponse) + + // Retrieve the OTP sent to the email and confirm the sign-in + guard let otp = try await otp(for: username) else { + XCTFail("Failed to retrieve the OTP code") + return + } + + guard case .confirmSignInWithOTP(let codeDeliverDetails) = confirmSignInResult.nextStep else { + XCTFail("ConfirmSignIn should return a .confirmSignInWithOTP") + return + } + + guard case .sms = codeDeliverDetails.destination else { + XCTFail("destination should be sms") + return + } + + confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: otp) + + XCTAssertTrue(confirmSignInResult.isSignedIn, "SignIn should be complete") + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test signIn with no preferred factor shows SELECT_CHALLENGE + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and password, using userAuth flow without setting any preferred factor + /// - Then: + /// - I should get a SELECT_CHALLENGE step. + func testSignInWithNoPreference_givenValidUser_expectSelectChallenge() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth) + let signInResult = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init(pluginOptions: pluginOptions)) + + guard case .continueSignInWithFirstFactorSelection = signInResult.nextStep else { + XCTFail("SignIn should return a .continueSignInWithFirstFactorSelection") + return + } + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test signIn with unsupported preferred factor shows SELECT_CHALLENGE + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and password, using userAuth flow with an unsupported preferred factor + /// - Then: + /// - I should get a SELECT_CHALLENGE step. +#if os(iOS) || os(macOS) || os(visionOS) + @available(iOS 17.4, macOS 13.5, visionOS 1.0, *) + func testSignInWithUnsupportedPreference_givenValidUser_expectSelectChallenge() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth(preferredFirstFactor: .webAuthn)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init(pluginOptions: pluginOptions)) + + guard case .continueSignInWithFirstFactorSelection = signInResult.nextStep else { + XCTFail("SignIn should return a .continueSignInWithFirstFactorSelection") + return + } + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } +#endif + /// Test signIn with EMAIL_OTP preference triggers Confirm OTP flow + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and password, using userAuth flow with EMAIL_OTP as the preferred factor + /// - Then: + /// - I should get a Confirm OTP flow. + func testSignInWithEmailOTPPreference_givenValidUser_expectConfirmOTPFlow() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth(preferredFirstFactor: .emailOTP)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + options: .init(pluginOptions: pluginOptions)) + + guard case .confirmSignInWithOTP = signInResult.nextStep else { + XCTFail("SignIn should return a .confirmSignInWithOTP") + return + } + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test signIn with SMS_OTP preference triggers Confirm OTP flow + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and password, using userAuth flow with SMS_OTP as the preferred factor + /// - Then: + /// - I should get a Confirm OTP flow. + func testSignInWithSMSOTPPreference_givenValidUser_expectConfirmOTPFlow() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth(preferredFirstFactor: .smsOTP)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + options: .init(pluginOptions: pluginOptions)) + + guard case .confirmSignInWithOTP = signInResult.nextStep else { + XCTFail("SignIn should return a .confirmSignInWithOTP") + return + } + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test signIn with PASSWORD as preferred factor with incorrect password fails + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and incorrect password, using userAuth flow with PASSWORD as the preferred factor + /// - Then: + /// - I should get a failed signIn flow. + func testSignInWithPasswordAsPreferred_givenInvalidPassword_expectFailedSignIn() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + let incorrectPassword = "WrongPassword123" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth(preferredFirstFactor: .password)) + _ = try await Amplify.Auth.signIn( + username: username, + password: incorrectPassword, + options: .init(pluginOptions: pluginOptions)) + XCTFail("SignIn with an incorrect password should fail") + } catch { + // Expected failure + } + } + + /// Test signIn with PASSWORD as preferred factor with incorrect password fails and subsequent sign in with correct password succeeds + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and incorrect password, using userAuth flow with PASSWORD as the preferred factor + /// - Then I invoke Amplify.Auth.signIn with the username and correct password + /// - Then: + /// - I should get a failed signIn flow for the first attempt and a completed signIn flow for the second attempt. + func testSignInWithPasswordAsPreferred_givenInvalidPasswordThenValidPassword_expectFailedThenCompletedSignIn() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + let incorrectPassword = "WrongPassword123" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth(preferredFirstFactor: .password)) + _ = try await Amplify.Auth.signIn( + username: username, + password: incorrectPassword, + options: .init(pluginOptions: pluginOptions)) + XCTFail("SignIn with an incorrect password should fail") + } catch { + // Expected failure + } + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth(preferredFirstFactor: .password)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init(pluginOptions: pluginOptions)) + XCTAssertTrue(signInResult.isSignedIn, "SignIn should be complete") + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test signIn with PASSWORD_SRP as preferred factor with incorrect password fails + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and incorrect password, using userAuth flow with PASSWORD_SRP as the preferred factor + /// - Then: + /// - I should get a failed signIn flow. + func testSignInWithPasswordSRPAsPreferred_givenInvalidPassword_expectFailedSignIn() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + let incorrectPassword = "WrongPassword123" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth(preferredFirstFactor: .passwordSRP)) + _ = try await Amplify.Auth.signIn( + username: username, + password: incorrectPassword, + options: .init(pluginOptions: pluginOptions)) + XCTFail("SignIn with an incorrect password should fail") + } catch { + // Expected failure + } + } + + /// Test signIn with PASSWORD_SRP as preferred factor with incorrect password fails and subsequent sign in with correct password succeeds + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.signIn with the username and incorrect password, using userAuth flow with PASSWORD_SRP as the preferred factor + /// - Then I invoke Amplify.Auth.signIn with the username and correct password + /// - Then: + /// - I should get a failed signIn flow for the first attempt and a completed signIn flow for the second attempt. + func testSignInWithPasswordSRPAsPreferred_givenInvalidPasswordThenValidPassword_expectFailedThenCompletedSignIn() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + let incorrectPassword = "WrongPassword123" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth(preferredFirstFactor: .passwordSRP)) + _ = try await Amplify.Auth.signIn( + username: username, + password: incorrectPassword, + options: .init(pluginOptions: pluginOptions)) + XCTFail("SignIn with an incorrect password should fail") + } catch { + // Expected failure + } + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth(preferredFirstFactor: .passwordSRP)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init(pluginOptions: pluginOptions)) + XCTAssertTrue(signInResult.isSignedIn, "SignIn should be complete") + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test confirm EMAIL_OTP with correct code succeeds + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.confirmSignIn with the correct OTP code for EMAIL_OTP + /// - Then: + /// - I should get a completed signIn flow. + func testConfirmEmailOTPWithCorrectCode_givenValidUser_expectCompletedSignIn() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth(preferredFirstFactor: .emailOTP)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + options: .init(pluginOptions: pluginOptions)) + + // Retrieve the OTP sent to the email and confirm the sign-in + guard let otp = try await otp(for: username) else { + XCTFail("Failed to retrieve the OTP code") + return + } + + guard case .confirmSignInWithOTP = signInResult.nextStep else { + XCTFail("SignIn should return a .confirmSignInWithOTP") + return + } + + let confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: otp) + + XCTAssertTrue(confirmSignInResult.isSignedIn, "SignIn should be complete") + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test confirm EMAIL_OTP with incorrect code fails + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.confirmSignIn with an incorrect OTP code for EMAIL_OTP + /// - Then: + /// - I should get a failed signIn flow. + func testConfirmEmailOTPWithIncorrectCode_givenValidUser_expectFailedSignIn() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth(preferredFirstFactor: .emailOTP)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + options: .init(pluginOptions: pluginOptions)) + + guard case .confirmSignInWithOTP = signInResult.nextStep else { + XCTFail("SignIn should return a .confirmSignInWithOTP") + return + } + + let incorrectOTP = "123456" + _ = try await Amplify.Auth.confirmSignIn( + challengeResponse: incorrectOTP) + XCTFail("ConfirmSignIn with an incorrect OTP should fail") + } catch { + // Expected failure + } + } + + /// Test confirm EMAIL_OTP with incorrect code fails and subsequent confirm with correct code succeeds + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.confirmSignIn with an incorrect OTP code for EMAIL_OTP + /// - Then I invoke Amplify.Auth.confirmSignIn with the correct OTP code + /// - Then: + /// - I should get a failed signIn flow for the first attempt and a completed signIn flow for the second attempt. + func testConfirmEmailOTPWithIncorrectCodeThenCorrectCode_givenValidUser_expectFailedThenCompletedSignIn() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + var otpString: String = "" + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth(preferredFirstFactor: .emailOTP)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + options: .init(pluginOptions: pluginOptions)) + + // Retrieve the correct OTP sent to the email and confirm the sign-in + guard let otp = try await otp(for: username) else { + XCTFail("Failed to retrieve the OTP code") + return + } + otpString = otp + + guard case .confirmSignInWithOTP = signInResult.nextStep else { + XCTFail("SignIn should return a .confirmSignInWithOTP") + return + } + + let incorrectOTP = "123456" + _ = try await Amplify.Auth.confirmSignIn( + challengeResponse: incorrectOTP) + XCTFail("ConfirmSignIn with an incorrect OTP should fail") + } catch { + // Expected failure + } + + do { + let confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: otpString) + XCTAssertTrue(confirmSignInResult.isSignedIn, "SignIn should be complete") + } catch { + XCTFail("ConfirmSignIn with a valid OTP should not fail \(error)") + } + } + + /// Test confirm SMS_OTP with correct code succeeds + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.confirmSignIn with the correct OTP code for SMS_OTP + /// - Then: + /// - I should get a completed signIn flow. + func testConfirmSMSOTPWithCorrectCode_givenValidUser_expectCompletedSignIn() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions(authFlowType: .userAuth(preferredFirstFactor: .smsOTP)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + options: .init(pluginOptions: pluginOptions)) + + // Retrieve the OTP sent to the phone and confirm the sign-in + guard let otp = try await otp(for: username) else { + XCTFail("Failed to retrieve the OTP code") + return + } + + guard case .confirmSignInWithOTP = signInResult.nextStep else { + XCTFail("SignIn should return a .confirmSignInWithOTP") + return + } + + let confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: otp) + + XCTAssertTrue(confirmSignInResult.isSignedIn, "SignIn should be complete") + } catch { + XCTFail("SignIn with a valid username/password should not fail \(error)") + } + } + + /// Test confirm SMS OTP with incorrect code fails + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.confirmSignIn with an incorrect SMS OTP code + /// - Then: + /// - I should get a failed signIn flow. + func testConfirmSMSOTPWithIncorrectCode_givenValidUser_expectFailedSignIn() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + do { + let pluginOptions = AWSAuthSignInOptions( + authFlowType: .userAuth(preferredFirstFactor: .smsOTP)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + options: .init(pluginOptions: pluginOptions)) + + guard case .confirmSignInWithOTP(let codeDeliverDetails) = signInResult.nextStep else { + XCTFail("SignIn should return a .confirmSignInWithOTP") + return + } + + guard case .sms = codeDeliverDetails.destination else { + XCTFail("destination should be sms") + return + } + + // Use an incorrect OTP code + let incorrectOtp = "123456" + + _ = try await Amplify.Auth.confirmSignIn( + challengeResponse: incorrectOtp) + + XCTFail("Should throw an invalid code error") + } catch { + guard case .service(_, _, let underlyingError) = error as? AuthError, + case .codeMismatch = underlyingError as? AWSCognitoAuthError else { + XCTFail("Should throw a service error") + return + } + } + } + + /// Test confirm SMS OTP with incorrect code fails and subsequent confirm with correct code succeeds + /// + /// - Given: A user registered in Cognito user pool + /// - When: + /// - I invoke Amplify.Auth.confirmSignIn with an incorrect SMS OTP code + /// - Then I invoke Amplify.Auth.confirmSignIn with the correct SMS OTP code + /// - Then: + /// - I should get a completed signIn flow. + func testConfirmSMSOTPWithIncorrectCodeThenCorrectCode_givenValidUser_expectCompletedSignIn() async throws { + let username = "integTest\(UUID().uuidString)" + let password = "Pp123@\(UUID().uuidString)" + + try await signUp(username: username, password: password) + + var otpString = "" + + do { + let pluginOptions = AWSAuthSignInOptions( + authFlowType: .userAuth(preferredFirstFactor: .smsOTP)) + let signInResult = try await Amplify.Auth.signIn( + username: username, + options: .init(pluginOptions: pluginOptions)) + + // Retrieve the correct OTP sent to the email and confirm the sign-in + guard let otp = try await otp(for: username) else { + XCTFail("Failed to retrieve the OTP code") + return + } + + otpString = otp + + guard case .confirmSignInWithOTP(let codeDeliverDetails) = signInResult.nextStep else { + XCTFail("SignIn should return a .confirmSignInWithOTP") + return + } + + guard case .sms = codeDeliverDetails.destination else { + XCTFail("destination should be sms") + return + } + + // Use an incorrect OTP code + let incorrectOtp = "123456" + + _ = try await Amplify.Auth.confirmSignIn( + challengeResponse: incorrectOtp) + + XCTFail("Should throw an invalid code error") + + } catch { + guard case .service(_, _, let underlyingError) = error as? AuthError, + case .codeMismatch = underlyingError as? AWSCognitoAuthError else { + XCTFail("Should throw a service error") + return + } + + let confirmSignInResult = try await Amplify.Auth.confirmSignIn( + challengeResponse: otpString) + + XCTAssertTrue(confirmSignInResult.isSignedIn, "SignIn should be complete with correct OTP") + } + } + +} diff --git a/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/PasswordlessTests/PasswordlessSignUpTests.swift b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/PasswordlessTests/PasswordlessSignUpTests.swift new file mode 100644 index 0000000000..76c65e8e72 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthHostApp/AuthIntegrationTests/PasswordlessTests/PasswordlessSignUpTests.swift @@ -0,0 +1,154 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest +import Amplify +import AWSCognitoAuthPlugin +import AWSAPIPlugin + +class PasswordlessSignUpTests: AWSAuthBaseTest { + + override func setUp() async throws { + // Only run these tests with Gen2 configuration + onlyUseGen2Configuration = true + + // Use a custom configuration these tests + amplifyOutputsFile = "testconfiguration/AWSCognitoPluginPasswordlessIntegrationTests-amplify_outputs" + + // Add API plugin to Amplify + let awsApiPlugin = AWSAPIPlugin() + try Amplify.add(plugin: awsApiPlugin) + + try await super.setUp() + AuthSessionHelper.clearSession() + } + + override func tearDown() async throws { + try await super.tearDown() + AuthSessionHelper.clearSession() + } + + /// Test if user registration is successful. + /// + /// - Given: A username that is not present in the system + /// - When: + /// - I invoke Amplify.Auth.signUp with username and no password + /// - Then: + /// - I should get a `.confirmUser` as the result next step. + /// + func testSuccessfulPasswordlessRegisterUser() async throws { + let username = "integTest\(UUID().uuidString)" + let options = AuthSignUpRequest.Options( + userAttributes: [ AuthUserAttribute(.email, value: randomEmail)]) + + // sign up + let signUpResult = try await Amplify.Auth.signUp(username: username, options: options) + guard case .confirmUser = signUpResult.nextStep else { + XCTFail("Incorrect next step for sign up confirmation") + return + } + XCTAssertFalse(signUpResult.isSignUpComplete) + } + + /// Test if multiple user registration is successful. + /// + /// - Given: Usernames that are not present in the system + /// - When: + /// - I invoke Amplify.Auth.signUp with username and no password, multiple times + /// - Then: + /// - I should get a `.confirmUser` as the result next step for each attempt + /// + func testSuccessfulMultiplePasswordlessSignUps() async throws { + + let signUpExpectation = expectation(description: "Next step should be .confirmUser") + signUpExpectation.expectedFulfillmentCount = 2 + + for _ in 0..` with your verified email address. + +```ts +import { defineAuth } from '@aws-amplify/backend'; + +const fromEmail = ''; + +function getAuthDefinition(): Parameters[0] { + return { + loginWith: { + email: true, + }, + userAttributes: { + email: { + required: false, + mutable: true, + }, + phoneNumber: { + required: false, + mutable: true, + }, + }, + accountRecovery: 'NONE', + multifactor: { + mode: 'OPTIONAL', + totp: true, + sms: true, + }, + senders: { + email: { + fromEmail, + }, + }, + }; +} + +export const auth = defineAuth(getAuthDefinition()); + +``` + +3. 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! +} +``` + +4. 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; +} +``` + +5. Update `backend.ts` with the following content and replace the `WebAuthnRelyingPartyID` with your own relying party ID. + +```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, cfnUserPoolClient } = cfnResources; + +cfnUserPool.addPropertyOverride( + 'Policies.SignInPolicy.AllowedFirstAuthFactors', + ['PASSWORD', 'WEB_AUTHN', 'EMAIL_OTP', 'SMS_OTP'] +); + +// sign in with username +cfnUserPool.usernameAttributes = []; + +cfnUserPoolClient.explicitAuthFlows = [ + 'ALLOW_REFRESH_TOKEN_AUTH', + 'ALLOW_USER_AUTH', + 'ALLOW_USER_PASSWORD_AUTH', + 'ALLOW_USER_SRP_AUTH', +]; + +cfnUserPool.addPropertyOverride('WebAuthnRelyingPartyID', ''); +cfnUserPool.addPropertyOverride('WebAuthnUserVerification', 'preferred'); + +// 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: [], + }, +}); + +``` + +6. Updated the triggers in `amplify/functions/cognito-triggers/comon.ts` with the following content + +```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); + } +}; +``` + +7. Update `amplify/functions/cognito-triggers/custom-email-sender.ts` with the following content + +```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; +}; +``` + +8. Update `amplify/functions/cognito-triggers/custom-sms-sender.ts` with the following content + +```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; +}; +``` + +9. 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 paswdless-tests --outputs-out-dir amplify_outputs/paswdless-tests +``` + +10. Copy the `amplify_outputs.json` file over to the test directory as `AWSCognitoPluginPasswordlessIntegrationTests-amplify_outputs.json`. 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/AWSCognitoPluginPasswordlessIntegrationTests-amplify_outputs.json +``` \ No newline at end of file diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp.xcodeproj/project.pbxproj b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..b12acf1622 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp.xcodeproj/project.pbxproj @@ -0,0 +1,545 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 680627E12CDEC99700AA7822 /* AWSCognitoAuthPlugin in Frameworks */ = {isa = PBXBuildFile; productRef = 680627E02CDEC99700AA7822 /* AWSCognitoAuthPlugin */; }; + 680627E32CDEC99700AA7822 /* Amplify in Frameworks */ = {isa = PBXBuildFile; productRef = 680627E22CDEC99700AA7822 /* Amplify */; }; + 6811F3422CE3C72600408D34 /* Amplify in Frameworks */ = {isa = PBXBuildFile; productRef = 6811F3412CE3C72600408D34 /* Amplify */; }; + 68D018952CE3CE04001F0A5B /* TestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = 68D018942CE3CE04001F0A5B /* TestPlan.xctestplan */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 680627C82CDEC30900AA7822 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 680627A42CDEC30600AA7822 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 680627AB2CDEC30600AA7822; + remoteInfo = AuthWebAuthnApp; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 680627AC2CDEC30600AA7822 /* AuthWebAuthnApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AuthWebAuthnApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 680627C72CDEC30900AA7822 /* AuthWebAuthnAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AuthWebAuthnAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 680627E52CDECA0800AA7822 /* amplify-swift */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "amplify-swift"; path = ../../../..; sourceTree = ""; }; + 68D018942CE3CE04001F0A5B /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = TestPlan.xctestplan; path = AuthWebAuthnAppUITests/TestPlan.xctestplan; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 680627AE2CDEC30600AA7822 /* AuthWebAuthnApp */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = AuthWebAuthnApp; + sourceTree = ""; + }; + 680627CA2CDEC30900AA7822 /* AuthWebAuthnAppUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = AuthWebAuthnAppUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 680627A92CDEC30600AA7822 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 680627E12CDEC99700AA7822 /* AWSCognitoAuthPlugin in Frameworks */, + 680627E32CDEC99700AA7822 /* Amplify in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 680627C42CDEC30900AA7822 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 6811F3422CE3C72600408D34 /* Amplify in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 680627A32CDEC30600AA7822 = { + isa = PBXGroup; + children = ( + 68D018942CE3CE04001F0A5B /* TestPlan.xctestplan */, + 6811F33E2CE3A45300408D34 /* Packages */, + 680627AE2CDEC30600AA7822 /* AuthWebAuthnApp */, + 680627CA2CDEC30900AA7822 /* AuthWebAuthnAppUITests */, + 6806280E2CDED96D00AA7822 /* Frameworks */, + 680627AD2CDEC30600AA7822 /* Products */, + ); + sourceTree = ""; + }; + 680627AD2CDEC30600AA7822 /* Products */ = { + isa = PBXGroup; + children = ( + 680627AC2CDEC30600AA7822 /* AuthWebAuthnApp.app */, + 680627C72CDEC30900AA7822 /* AuthWebAuthnAppUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 6806280E2CDED96D00AA7822 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + 6811F33E2CE3A45300408D34 /* Packages */ = { + isa = PBXGroup; + children = ( + 680627E52CDECA0800AA7822 /* amplify-swift */, + ); + name = Packages; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 680627AB2CDEC30600AA7822 /* AuthWebAuthnApp */ = { + isa = PBXNativeTarget; + buildConfigurationList = 680627D12CDEC30900AA7822 /* Build configuration list for PBXNativeTarget "AuthWebAuthnApp" */; + buildPhases = ( + 680627A82CDEC30600AA7822 /* Sources */, + 680627A92CDEC30600AA7822 /* Frameworks */, + 680627AA2CDEC30600AA7822 /* Resources */, + 6806280D2CDED58500AA7822 /* Copy Test Config */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 680627AE2CDEC30600AA7822 /* AuthWebAuthnApp */, + ); + name = AuthWebAuthnApp; + packageProductDependencies = ( + 680627E02CDEC99700AA7822 /* AWSCognitoAuthPlugin */, + 680627E22CDEC99700AA7822 /* Amplify */, + ); + productName = AuthWebAuthnApp; + productReference = 680627AC2CDEC30600AA7822 /* AuthWebAuthnApp.app */; + productType = "com.apple.product-type.application"; + }; + 680627C62CDEC30900AA7822 /* AuthWebAuthnAppUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 680627D72CDEC30900AA7822 /* Build configuration list for PBXNativeTarget "AuthWebAuthnAppUITests" */; + buildPhases = ( + 680627C32CDEC30900AA7822 /* Sources */, + 680627C42CDEC30900AA7822 /* Frameworks */, + 680627C52CDEC30900AA7822 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 680627C92CDEC30900AA7822 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 680627CA2CDEC30900AA7822 /* AuthWebAuthnAppUITests */, + ); + name = AuthWebAuthnAppUITests; + packageProductDependencies = ( + 6811F3412CE3C72600408D34 /* Amplify */, + ); + productName = AuthWebAuthnAppUITests; + productReference = 680627C72CDEC30900AA7822 /* AuthWebAuthnAppUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 680627A42CDEC30600AA7822 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1610; + LastUpgradeCheck = 1610; + TargetAttributes = { + 680627AB2CDEC30600AA7822 = { + CreatedOnToolsVersion = 16.1; + }; + 680627C62CDEC30900AA7822 = { + CreatedOnToolsVersion = 16.1; + TestTargetID = 680627AB2CDEC30600AA7822; + }; + }; + }; + buildConfigurationList = 680627A72CDEC30600AA7822 /* Build configuration list for PBXProject "AuthWebAuthnApp" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 680627A32CDEC30600AA7822; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + ); + preferredProjectObjectVersion = 77; + productRefGroup = 680627AD2CDEC30600AA7822 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 680627AB2CDEC30600AA7822 /* AuthWebAuthnApp */, + 680627C62CDEC30900AA7822 /* AuthWebAuthnAppUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 680627AA2CDEC30600AA7822 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 68D018952CE3CE04001F0A5B /* TestPlan.xctestplan in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 680627C52CDEC30900AA7822 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 6806280D2CDED58500AA7822 /* Copy Test Config */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Copy Test Config"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "# Type a script or drag a script file from your workspace to insert its path.\nSOURCE_DIR=$HOME/.aws-amplify/amplify-ios/testconfiguration\nDESTINATION_DIR=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/testconfiguration/\"\n\nif [ ! -d \"$SOURCE_DIR\" ]; then\n echo \"error: Test configuration directory does not exist: ${SOURCE_DIR}\" && exit 1\nfi\n\nmkdir -p \"$DESTINATION_DIR\"\ncp -r \"$SOURCE_DIR\"/*.json $DESTINATION_DIR\n\nexit 0\n\n"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 680627A82CDEC30600AA7822 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 680627C32CDEC30900AA7822 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 680627C92CDEC30900AA7822 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 680627AB2CDEC30600AA7822 /* AuthWebAuthnApp */; + targetProxy = 680627C82CDEC30900AA7822 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 680627CF2CDEC30900AA7822 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 680627D02CDEC30900AA7822 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + 680627D22CDEC30900AA7822 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = AuthWebAuthnApp/AuthWebAuthnApp.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 94KV3E626L; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.amazon.aws.amplify.swift.AuthWebAuthnApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,7"; + XROS_DEPLOYMENT_TARGET = 1.3; + }; + name = Debug; + }; + 680627D32CDEC30900AA7822 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = AuthWebAuthnApp/AuthWebAuthnApp.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 94KV3E626L; + ENABLE_PREVIEWS = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GENERATE_INFOPLIST_FILE = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.6; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.amazon.aws.amplify.swift.AuthWebAuthnApp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,7"; + XROS_DEPLOYMENT_TARGET = 1.3; + }; + name = Release; + }; + 680627D82CDEC30900AA7822 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 94KV3E626L; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.amazon.aws.amplify.swift.AuthWebAuthnAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_TARGET_NAME = AuthWebAuthnApp; + XROS_DEPLOYMENT_TARGET = 2.1; + }; + name = Debug; + }; + 680627D92CDEC30900AA7822 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 94KV3E626L; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.6; + MACOSX_DEPLOYMENT_TARGET = 15.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.amazon.aws.amplify.swift.AuthWebAuthnAppUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,7"; + TEST_TARGET_NAME = AuthWebAuthnApp; + XROS_DEPLOYMENT_TARGET = 2.1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 680627A72CDEC30600AA7822 /* Build configuration list for PBXProject "AuthWebAuthnApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 680627CF2CDEC30900AA7822 /* Debug */, + 680627D02CDEC30900AA7822 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 680627D12CDEC30900AA7822 /* Build configuration list for PBXNativeTarget "AuthWebAuthnApp" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 680627D22CDEC30900AA7822 /* Debug */, + 680627D32CDEC30900AA7822 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 680627D72CDEC30900AA7822 /* Build configuration list for PBXNativeTarget "AuthWebAuthnAppUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 680627D82CDEC30900AA7822 /* Debug */, + 680627D92CDEC30900AA7822 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCSwiftPackageProductDependency section */ + 680627E02CDEC99700AA7822 /* AWSCognitoAuthPlugin */ = { + isa = XCSwiftPackageProductDependency; + productName = AWSCognitoAuthPlugin; + }; + 680627E22CDEC99700AA7822 /* Amplify */ = { + isa = XCSwiftPackageProductDependency; + productName = Amplify; + }; + 6811F3412CE3C72600408D34 /* Amplify */ = { + isa = XCSwiftPackageProductDependency; + productName = Amplify; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 680627A42CDEC30600AA7822 /* Project object */; +} diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp.xcodeproj/xcshareddata/xcschemes/AuthWebAuthnApp.xcscheme b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp.xcodeproj/xcshareddata/xcschemes/AuthWebAuthnApp.xcscheme new file mode 100644 index 0000000000..3b5764b603 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp.xcodeproj/xcshareddata/xcschemes/AuthWebAuthnApp.xcscheme @@ -0,0 +1,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp.xcodeproj/xcshareddata/xcschemes/AuthWebAuthnAppUITests.xcscheme b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp.xcodeproj/xcshareddata/xcschemes/AuthWebAuthnAppUITests.xcscheme new file mode 100644 index 0000000000..9dbd6c2a90 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp.xcodeproj/xcshareddata/xcschemes/AuthWebAuthnAppUITests.xcscheme @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp/AuthWebAuthnApp.entitlements b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp/AuthWebAuthnApp.entitlements new file mode 100644 index 0000000000..5e701a266c --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp/AuthWebAuthnApp.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.associated-domains + + webcredentials:webauthn-test.hsinghvq.people.aws.dev?mode=developer + + + diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp/AuthWebAuthnAppApp.swift b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp/AuthWebAuthnAppApp.swift new file mode 100644 index 0000000000..4b40cb1f65 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp/AuthWebAuthnAppApp.swift @@ -0,0 +1,35 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import AWSCognitoAuthPlugin +import SwiftUI + +@main +struct AuthWebAuthnAppApp: App { + + private let amplifyOutputsFilePath = "testconfiguration/AWSCognitoPluginWebAuthnIntegrationTests-amplify_outputs" + + init() { + do { + try Amplify.add(plugin: AWSCognitoAuthPlugin()) + + let data = try TestConfigHelper.retrieve(forResource: amplifyOutputsFilePath) + try Amplify.configure(with: .data(data)) + print("Amplify configured!") + } catch { + print("Failed to init Amplify", error) + } + } + + var body: some Scene { + WindowGroup { + ContentView() + } + } + +} diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp/ContentView.swift b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp/ContentView.swift new file mode 100644 index 0000000000..6cf1a9eaee --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp/ContentView.swift @@ -0,0 +1,175 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Amplify +import AWSCognitoAuthPlugin +import SwiftUI + +struct ContentView: View { + @State private var lastResult: String = "" + @State private var credentialId: String = "" + @State private var isSignedUp: Bool = false + @State private var isSignedIn: Bool = false + + private let username = "integTest\(UUID().uuidString)" + private let password = "Pp123@\(UUID().uuidString)" + private let email = "test-\(UUID().uuidString)@amplify-swift-gamma.awsapps.com" + + var body: some View { + ScrollView { + VStack { + Text(username) + .accessibilityIdentifier("Username") + Divider() + if isSignedIn { + Button("Sign Out") { + Task { + lastResult = "" + _ = await Amplify.Auth.signOut() + isSignedIn = false + lastResult = "User is signed out" + } + } + .accessibilityIdentifier("SignOut") + } else { + if isSignedUp { + Button("Sign In") { + Task { + await signIn(authFlowType: .userAuth(preferredFirstFactor: .webAuthn)) + } + } + .accessibilityIdentifier("SignIn") + } else { + Button("Sign Up and Sign In") { + Task { + await signUpAndSignIn() + } + } + .accessibilityIdentifier("SignUp") + } + } + + Button("Associate WebAuthn Credential") { + Task { + do { + lastResult = "" + try await Amplify.Auth.associateWebAuthnCredential() + lastResult = "WebAuthn credential was associated" + } catch { + lastResult = "Associate WebAuthn Credential failed: \(error)" + } + } + } + .accessibilityIdentifier("AssociateWebAuthn") + + Button("List WebAuthn Credentials") { + Task { + do { + lastResult = "" + let result = try await Amplify.Auth.listWebAuthnCredentials() + lastResult = "WebAuthn Credentials: \(result.credentials.count)" + if let firstCredential = result.credentials.first { + credentialId = firstCredential.credentialId + } + } catch { + lastResult = "List WebAuthn Credentials failed: \(error)" + } + } + } + .accessibilityIdentifier("ListWebAuthn") + + Button("Delete WebAuthn Credential") { + Task { + do { + lastResult = "" + try await Amplify.Auth.deleteWebAuthnCredential(credentialId: credentialId) + lastResult = "WebAuthn credential was deleted" + } catch { + lastResult = "Delete WebAuthn Credential failed: \(error)" + } + } + } + .accessibilityIdentifier("DeleteWebAuthn") + + Button("Delete User") { + Task { + lastResult = "" + try? await Amplify.Auth.deleteUser() + lastResult = "User was deleted" + isSignedIn = false + isSignedUp = false + } + } + .accessibilityIdentifier("DeleteUser") + + Divider() + + Text(lastResult) + .font(.caption) + .fontDesign(.monospaced) + .accessibilityIdentifier("LastResult") + + Spacer() + } + .padding() + } + } + + private func signUpAndSignIn() async { + do { + lastResult = "" + let signUpResult = try await Amplify.Auth.signUp( + username: username, + password: password, + options: .init(userAttributes: [.init(.email, value: email)]) + ) + + guard signUpResult.isSignUpComplete else { + lastResult = "Sign Up was not completed. Next step is: \(signUpResult.nextStep)" + return + } + + lastResult = "User is signed up" + isSignedUp = true + + await signIn( + password: password, + authFlowType: .userPassword + ) + } catch { + lastResult = "Sign Up failed: \(error)" + } + } + + private func signIn( + password: String? = nil, + authFlowType: AuthFlowType + ) async { + do { + lastResult = "" + let signInResult = try await Amplify.Auth.signIn( + username: username, + password: password, + options: .init( + pluginOptions: AWSAuthSignInOptions( + authFlowType: authFlowType + ) + ) + ) + + guard signInResult.isSignedIn else { + lastResult = "Sign In was not completed. Next step is: \(signInResult.nextStep)" + return + } + + lastResult = "User is signed in" + isSignedIn = true + } catch { + lastResult = "Sign In failed: \(error)" + } + } +} diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp/Helpers/TestConfigHelper.swift b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp/Helpers/TestConfigHelper.swift new file mode 100644 index 0000000000..db4c077c3f --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnApp/Helpers/TestConfigHelper.swift @@ -0,0 +1,42 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +@testable import Amplify +import Foundation + +class TestConfigHelper { + enum ConfigError: Error { + case notFound(String) + case decodingFailed(String) + } + + static func retrieveAmplifyConfiguration(forResource: String) throws -> AmplifyConfiguration { + let data = try retrieve(forResource: forResource) + return try AmplifyConfiguration.decodeAmplifyConfiguration(from: data) + } + + static func retrieveCredentials(forResource: String) throws -> [String: String] { + let data = try retrieve(forResource: forResource) + + let jsonOptional = try JSONSerialization.jsonObject(with: data, options: []) as? [String: String] + guard let json = jsonOptional else { + throw ConfigError.decodingFailed("Could not deserialize `\(forResource)` into JSON object") + } + + return json + } + + static func retrieve(forResource: String) throws -> Data { + guard let path = Bundle(for: self).path(forResource: forResource, ofType: "json") else { + throw ConfigError.notFound("Could not retrieve configuration file: \(forResource)") + } + + let url = URL(fileURLWithPath: path) + return try Data(contentsOf: url) + } +} + diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnAppUITests/AuthWebAuthnAppUITests.swift b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnAppUITests/AuthWebAuthnAppUITests.swift new file mode 100644 index 0000000000..a314bd2fb3 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnAppUITests/AuthWebAuthnAppUITests.swift @@ -0,0 +1,280 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import XCTest + +final class AuthWebAuthnAppUITests: XCTestCase { + private let timeout = TimeInterval(6) + private let app = XCUIApplication() + private var username: String! + private var signUpButton: XCUIElement! + private var associateButton: XCUIElement! + private var listButton: XCUIElement! + private var signOutButton: XCUIElement! + private var signInButton: XCUIElement! + private var deleteButton: XCUIElement! + private var deleteUserButton: XCUIElement! + private var springboard: XCUIApplication! + private var continueButton: XCUIElement! + + private lazy var deviceIdentifier: String = { + let paths = Bundle.main.bundleURL.pathComponents + guard let index = paths.firstIndex(where: { $0 == "Devices" }), + let identifier = paths.dropFirst(index + 1).first + else { + fatalError("Failed to get device identifier") + } + + return identifier + }() + + @MainActor + override func setUp() async throws { + continueAfterFailure = false + try await bootDevice() + try await enrollBiometrics() + if ProcessInfo.processInfo.arguments.contains("GEN2") { + app.launchArguments.append("GEN2") + } + app.launch() + loadAndValidateElements() + signUpAndSignInUser() + } + + @MainActor + override func tearDown() async throws { + deleteCurrentUser() + app.terminate() + username = nil + signUpButton = nil + associateButton = nil + listButton = nil + signOutButton = nil + signInButton = nil + deleteButton = nil + deleteUserButton = nil + continueButton = nil + springboard = nil + try await uninstallApp() + } + + /// Because all of the WebAuthn operations are linked and some act as preconditions, + /// we're testing them all together. + /// + /// This includes: + /// - A signed in user wants to associate a new WebAuthn credential to their account + /// - A signed out user wants to use their associated WebAuthn credentials to sign in + /// - A signed in user wants to list their associated WebAuthn credentials + /// - A signed in user wants to delete an associated WebAuthn credential + /// + @MainActor + func testWebAuthnAPIs() async throws { + // 1. Associate new WebAuthn Credential + let associateAttempt = await attempt { + associateButton.tap() + return !waitForResult("Associate WebAuthn Credential failed:", timeout: 1) + } + + guard associateAttempt else { + XCTFail("Failed to trigger the Associate WebAuthn Credential workflow") + return + } + + // Wait for the "Continue" button to appear in the FaceID popover and tap it + guard continueButton.waitForExistence(timeout: timeout) else { + XCTFail("Failed to find 'Continue' button") + return + } + continueButton.tap() + + // Trigger a matching face + try await matchBiometrics() + guard waitForResult("WebAuthn credential was associated") else { + XCTFail("Failed to associate credential") + return + } + + // 2. List existing credentials + listButton.tap() + guard waitForResult("WebAuthn Credentials: 1") else { + XCTFail("Failed to list credentials") + return + } + + // 3. Sign Out + signOutButton.tap() + guard waitForResult("User is signed out"), signInButton.exists else { + XCTFail("Failed to sign out user") + return + } + + // 4. Sign in with WebAuthn + let signInAttempt = await attempt { + signInButton.tap() + return !waitForResult("Sign In failed:", timeout: 1) + } + + guard signInAttempt else { + XCTFail("Failed to trigger the Assert WebAuthn Credential workflow") + return + } + + // Wait for the "Continue" button to appear in the FaceID popover + guard continueButton.waitForExistence(timeout: timeout) else { + XCTFail("Failed to find 'Continue' button") + return + } + + // If presented with additional credentials, choose the one for this user by tapping on it + let webAuthnCredentialButton = springboard.staticTexts[username] + if webAuthnCredentialButton.waitForExistence(timeout: 1) { + webAuthnCredentialButton.tap() + } + + // Tap the "Continue" button + continueButton.tap() + + // Trigger a matching face + try await matchBiometrics() + + guard waitForResult("User is signed in") else { + XCTFail("Failed to Sign In with WebAuthn") + return + } + + // 5. Delete credential + deleteButton.tap() + guard waitForResult("WebAuthn credential was deleted") else { + XCTFail("Failed to delete credential") + return + } + + // 6. Verify deletion + listButton.tap() + guard waitForResult("WebAuthn Credentials: 0") else { + XCTFail("Failed to list credentials") + return + } + } + + private func bootDevice() async throws { + let request = LocalServer.boot(deviceIdentifier).urlRequest + let (_, response) = try await URLSession.shared.data(for: request) + XCTAssertTrue((response as! HTTPURLResponse).statusCode < 300, "Failed to boot the device") + } + + private func enrollBiometrics() async throws { + let request = LocalServer.enroll(deviceIdentifier).urlRequest + let (_, response) = try await URLSession.shared.data(for: request) + XCTAssertTrue((response as! HTTPURLResponse).statusCode < 300, "Failed to enroll biometrics in the device") + } + + private func matchBiometrics() async throws { + let request = LocalServer.match(deviceIdentifier).urlRequest + let (_, response) = try await URLSession.shared.data(for: request) + XCTAssertTrue((response as! HTTPURLResponse).statusCode < 300, "Failed to match biometrics in the device") + } + + private func uninstallApp() async throws { + let request = LocalServer.uninstall(deviceIdentifier).urlRequest + let (_, response) = try await URLSession.shared.data(for: request) + XCTAssertTrue((response as! HTTPURLResponse).statusCode < 300, "Failed to uninstall the App") + } + + @MainActor + private func loadAndValidateElements() { + let usernameElement = app.staticTexts["Username"] + guard usernameElement.waitForExistence(timeout: timeout) else { + XCTFail("Failed to find the Username label") + return + } + + username = usernameElement.label + + // Once the Username label exists, all these button are expected to visible as well, + // so we don't wait for them and instead just check for their existence + signUpButton = app.buttons["SignUp"] + guard signUpButton.exists else { + XCTFail("Failed to find the 'Sign Up and Sign In' button") + return + } + + associateButton = app.buttons["AssociateWebAuthn"] + guard associateButton.exists else { + XCTFail("Failed to find the 'Associate WebAuthn Credential' button") + return + } + + listButton = app.buttons["ListWebAuthn"] + guard listButton.exists else { + XCTFail("Failed to find the 'List WebAuthn Credentials' button") + return + } + + deleteButton = app.buttons["DeleteWebAuthn"] + guard deleteButton.exists else { + XCTFail("Failed to find the 'Delete WebAuthn Credential' button") + return + } + + deleteUserButton = app.buttons["DeleteUser"] + guard deleteUserButton.exists else { + XCTFail("Failed to find the 'Delete User' button") + return + } + + // The Sign In and Sign Out buttons only become visible when Sign Up and Sign In are completed respectively, + // so we don't check their existance. + signInButton = app.buttons["SignIn"] + signOutButton = app.buttons["SignOut"] + + springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + // The Continue button only appears when a FaceID operation is triggered, + // so we're also not checking for its existance + continueButton = springboard.otherElements["ASAuthorizationControllerContinueButton"] + } + + @MainActor + private func signUpAndSignInUser() { + signUpButton.tap() + guard waitForResult("User is signed in"), signOutButton.exists else { + XCTFail("Failed to Sign Up and Sign In") + return + } + } + + @MainActor + private func deleteCurrentUser() { + guard let deleteUserButton else { + XCTFail("Failed to find the 'Delete User' button") + return + } + deleteUserButton.tap() + guard waitForResult("User was deleted"), signUpButton.exists else { + XCTFail("Failed to delete the user") + return + } + } + + @MainActor + private func waitForResult(_ containing: String, timeout: TimeInterval? = nil) -> Bool { + let predicate = NSPredicate(format: "label CONTAINS %@", containing) + let element = app.staticTexts.matching(identifier: "LastResult") + .matching(predicate).firstMatch + return element.waitForExistence(timeout: timeout ?? self.timeout) + } + + @MainActor + private func attempt(times: Int = 3, _ action: () async -> Bool) async -> Bool { + let result = await action() + if !result, times > 0 { + sleep(5) + return await attempt(times: times - 1, action) + } + return result + } +} diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnAppUITests/LocalServer.swift b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnAppUITests/LocalServer.swift new file mode 100644 index 0000000000..07e7c02a3c --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnAppUITests/LocalServer.swift @@ -0,0 +1,50 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +enum LocalServer { + static let endpoint = "http://127.0.0.1:9293" + + case boot(String) + case enroll(String) + case match(String) + case uninstall(String) +} + +extension LocalServer { + var httpMethod: String { + return "POST" + } + + var path: String { + switch self { + case .boot: return "/boot" + case .enroll: return "/enroll" + case .match: return "/match" + case .uninstall: return "/uninstall" + } + } + + var payload: Data? { + switch self { + case .boot(let deviceId), + .enroll(let deviceId), + .match(let deviceId), + .uninstall(let deviceId): + return try? JSONEncoder().encode(["deviceId": deviceId]) + } + } + + var urlRequest: URLRequest { + var request = URLRequest(url: URL(string: Self.endpoint + self.path)!) + request.httpMethod = self.httpMethod + request.httpBody = self.payload + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + return request + } +} diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnAppUITests/README.md b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnAppUITests/README.md new file mode 100644 index 0000000000..9cba3cc6e2 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnAppUITests/README.md @@ -0,0 +1,125 @@ +# 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 will create a new amplify project with the latest version of the Amplify CLI. + +2. Update `amplify/auth/resource.ts`. The resulting file should look like this. Replace `` with your verified email address. + +```ts +import { defineAuth, defineFunction } from '@aws-amplify/backend'; + +const fromEmail = '>'; + +function getAuthDefinition(): Parameters[0] { + return { + loginWith: { + email: true, + }, + userAttributes: { + email: { + required: false, + mutable: true, + }, + phoneNumber: { + required: false, + mutable: true, + }, + }, + accountRecovery: 'NONE', + multifactor: { + mode: 'OPTIONAL', + totp: true, + sms: true, + }, + senders: { + email: { + fromEmail, + }, + }, + triggers: { + preSignUp: defineFunction({ + entry: "./pre-sign-up-handler.ts", + }), + }, + }; +} + +export const auth = defineAuth(getAuthDefinition()); + +``` + +3. Create a file `amplify/auth/pre-sign-up-handler.ts` with the following content +```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; +}; +``` + +4. Update `backend.ts` with the following content and replace the `WebAuthnRelyingPartyID` with your own relying party ID. + +```ts +import { defineBackend } from '@aws-amplify/backend'; +import { auth } from './auth/resource'; + +const backend = defineBackend({ + auth, +}); + +const { cfnResources } = backend.auth.resources; +const { cfnUserPool, cfnUserPoolClient } = cfnResources; + +cfnUserPool.addPropertyOverride( + 'Policies.SignInPolicy.AllowedFirstAuthFactors', + ['PASSWORD', 'WEB_AUTHN', 'EMAIL_OTP', 'SMS_OTP'] +); + +// sign in with username +cfnUserPool.usernameAttributes = []; + +cfnUserPoolClient.explicitAuthFlows = [ + 'ALLOW_REFRESH_TOKEN_AUTH', + 'ALLOW_USER_AUTH', + 'ALLOW_USER_PASSWORD_AUTH', + 'ALLOW_USER_SRP_AUTH', +]; + +cfnUserPool.addPropertyOverride('WebAuthnRelyingPartyID', ''); +cfnUserPool.addPropertyOverride('WebAuthnUserVerification', 'preferred'); +``` + +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 webauthn-tests --outputs-out-dir amplify_outputs/webauthn-tests +``` + +5. Copy the `amplify_outputs.json` file over to the test directory as `AWSCognitoPluginWebAuthnIntegrationTests-amplify_outputs.json`. 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/AWSCognitoPluginWebAuthnIntegrationTests-amplify_outputs.json +``` \ No newline at end of file diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnAppUITests/TestPlan.xctestplan b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnAppUITests/TestPlan.xctestplan new file mode 100644 index 0000000000..874d0eaf1b --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/AuthWebAuthnAppUITests/TestPlan.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "9E5E8742-0597-476D-8056-6CC7EF9032B1", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:AuthWebAuthnApp.xcodeproj", + "identifier" : "680627C62CDEC30900AA7822", + "name" : "AuthWebAuthnAppUITests" + } + } + ], + "version" : 1 +} diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/LocalServer/.gitignore b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/LocalServer/.gitignore new file mode 100644 index 0000000000..d8b83df9cd --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/LocalServer/.gitignore @@ -0,0 +1 @@ +package-lock.json diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/LocalServer/index.mjs b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/LocalServer/index.mjs new file mode 100644 index 0000000000..1ca545833c --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/LocalServer/index.mjs @@ -0,0 +1,77 @@ +import express from 'express' +import * as childProcess from 'node:child_process' + +const app = express() +app.use(express.json()) + +const bundleId = "com.amazon.aws.amplify.swift.AuthWebAuthnApp" + +const run = (cmd) => { + return new Promise((resolve, reject) => { + childProcess.exec(cmd, (error, stdout, stderror) => { + if (error) { + console.warn("Failed to execute cmd:", cmd) + reject(stderror) + } else { + resolve(stdout) + } + }) + }) +} + +app.post('/uninstall', async (req, res) => { + console.log("POST /uninstall ") + const { deviceId } = req.body + try { + const cmd = `xcrun simctl uninstall ${deviceId} ${bundleId}` + await run(cmd) + res.send("Done") + } catch (error) { + console.error("Failed to uninstall app", error) + res.sendStatus(500) + } +}) + +app.post('/boot', async (req, res) => { + console.log("POST /boot ") + const { deviceId } = req.body + try { + const cmd = `xcrun simctl bootstatus ${deviceId} -b` + await run(cmd) + res.send("Done") + } catch (error) { + console.error("Failed to boot the device", error) + res.sendStatus(500) + } +}) + +app.post('/enroll', async (req, res) => { + console.log("POST /enroll ") + const { deviceId } = req.body + try { + const cmd = `xcrun simctl spawn ${deviceId} notifyutil -s com.apple.BiometricKit.enrollmentChanged '1' && xcrun simctl spawn ${deviceId} notifyutil -p com.apple.BiometricKit.enrollmentChanged` + await run(cmd) + res.send("Done") + } catch (error) { + console.error("Failed to enroll biometrics in the device", error) + res.sendStatus(500) + } +}) + + +app.post('/match', async (req, res) => { + console.log("POST /match ") + const { deviceId } = req.body + try { + const cmd = `xcrun simctl spawn ${deviceId} notifyutil -p com.apple.BiometricKit_Sim.fingerTouch.match` + await run(cmd) + res.send("Done") + } catch (error) { + console.error("Failed to match biometrics", error) + res.sendStatus(500) + } +}) + +app.listen(9293, () => { + console.log("Simulator server started!") +}) diff --git a/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/LocalServer/package.json b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/LocalServer/package.json new file mode 100644 index 0000000000..56bc2a2054 --- /dev/null +++ b/AmplifyPlugins/Auth/Tests/AuthWebAuthnApp/LocalServer/package.json @@ -0,0 +1,14 @@ +{ + "name": "localserver", + "version": "1.0.0", + "description": "", + "main": "index.mjs", + "scripts": { + "start": "node index.mjs" + }, + "author": "Aws Amplify", + "license": "Apache-2.0", + "dependencies": { + "express": "^4.19.2" + } +} diff --git a/AmplifyPlugins/Geo/Tests/AWSLocationGeoPluginTests/Mocks/MockAWSClientConfiguration.swift b/AmplifyPlugins/Geo/Tests/AWSLocationGeoPluginTests/Mocks/MockAWSClientConfiguration.swift index 90031b94ae..9e50f04e11 100644 --- a/AmplifyPlugins/Geo/Tests/AWSLocationGeoPluginTests/Mocks/MockAWSClientConfiguration.swift +++ b/AmplifyPlugins/Geo/Tests/AWSLocationGeoPluginTests/Mocks/MockAWSClientConfiguration.swift @@ -35,11 +35,19 @@ class MockEndPointResolver: EndpointResolver { } class MockLogAgent: LogAgent { + func log( + level: Smithy.LogAgentLevel, + message: @autoclosure () -> String, + metadata: @autoclosure () -> [String : String]?, + source: @autoclosure () -> String, + file: String, + function: String, + line: UInt + ) { + print("MockLogAgent") + } + var name: String = "" var level: LogAgentLevel = .debug - - func log(level: LogAgentLevel, message: String, metadata: [String: String]?, source: String, file: String, function: String, line: UInt) { - print("MockLogAgent") - } } diff --git a/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/PinpointRequestsRegistryTests.swift b/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/PinpointRequestsRegistryTests.swift index 0cd3feec43..9843a89e54 100644 --- a/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/PinpointRequestsRegistryTests.swift +++ b/AmplifyPlugins/Internal/Tests/InternalAWSPinpointUnitTests/PinpointRequestsRegistryTests.swift @@ -107,6 +107,15 @@ private class MockLogAgent: LogAgent { let name = "MockLogAgent" var level: LogAgentLevel = .info - func log(level: LogAgentLevel, message: String, metadata: [String : String]?, source: String, file: String, function: String, line: UInt) { + func log( + level: Smithy.LogAgentLevel, + message: @autoclosure () -> String, + metadata: @autoclosure () -> [String : String]?, + source: @autoclosure () -> String, + file: String, + function: String, + line: UInt + ) { + print("MockLogAgent") } } diff --git a/AmplifyPlugins/Storage/Sources/AWSS3StoragePlugin/Dependency/UploadPartInput+presignURL.swift b/AmplifyPlugins/Storage/Sources/AWSS3StoragePlugin/Dependency/UploadPartInput+presignURL.swift index 0350c6ca01..32c08b3af5 100644 --- a/AmplifyPlugins/Storage/Sources/AWSS3StoragePlugin/Dependency/UploadPartInput+presignURL.swift +++ b/AmplifyPlugins/Storage/Sources/AWSS3StoragePlugin/Dependency/UploadPartInput+presignURL.swift @@ -61,7 +61,7 @@ extension UploadPartInput { builder.retryErrorInfoProvider(AWSClientRuntime.AWSRetryErrorInfoProvider.errorInfo(for:)) builder.applySigner(ClientRuntime.SignerMiddleware()) let endpointParams = EndpointParams(accelerate: config.accelerate ?? false, bucket: input.bucket, disableMultiRegionAccessPoints: config.disableMultiRegionAccessPoints ?? false, disableS3ExpressSessionAuth: config.disableS3ExpressSessionAuth, endpoint: config.endpoint, forcePathStyle: config.forcePathStyle ?? false, key: input.key, region: config.region, useArnRegion: config.useArnRegion, useDualStack: config.useDualStack ?? false, useFIPS: config.useFIPS ?? false, useGlobalEndpoint: config.useGlobalEndpoint ?? false) - context.attributes.set(key: Smithy.AttributeKey(name: "EndpointParams"), value: endpointParams) + context.set(key: Smithy.AttributeKey(name: "EndpointParams"), value: endpointParams) builder.applyEndpoint(AWSClientRuntime.EndpointResolverMiddleware(endpointResolverBlock: { [config] in try config.endpointResolver.resolve(params: $0) }, endpointParams: endpointParams)) builder.selectAuthScheme(ClientRuntime.AuthSchemeMiddleware()) builder.interceptors.add(AWSClientRuntime.AWSS3ErrorWith200StatusXMLMiddleware()) diff --git a/AmplifyTestCommon/Mocks/MockAuthCategoryPlugin.swift b/AmplifyTestCommon/Mocks/MockAuthCategoryPlugin.swift index 1a1f0ab6e8..195c88178a 100644 --- a/AmplifyTestCommon/Mocks/MockAuthCategoryPlugin.swift +++ b/AmplifyTestCommon/Mocks/MockAuthCategoryPlugin.swift @@ -61,6 +61,10 @@ class MockAuthCategoryPlugin: MessageReporter, AuthCategoryPlugin { public func signOut(options: AuthSignOutRequest.Options? = nil) async -> AuthSignOutResult { fatalError() } + + func autoSignIn() async throws -> AuthSignInResult { + fatalError() + } public func deleteUser() async throws { fatalError() @@ -141,6 +145,24 @@ class MockAuthCategoryPlugin: MessageReporter, AuthCategoryPlugin { fatalError() } +#if os(iOS) || os(macOS) + func associateWebAuthnCredential(presentationAnchor: AuthUIPresentationAnchor?, options: AuthAssociateWebAuthnCredentialRequest.Options?) async throws { + fatalError() + } +#elseif os(visionOS) + func associateWebAuthnCredential(presentationAnchor: AuthUIPresentationAnchor, options: AuthAssociateWebAuthnCredentialRequest.Options?) async throws { + fatalError() + } +#endif + + func listWebAuthnCredentials(options: AuthListWebAuthnCredentialsRequest.Options?) async throws -> AuthListWebAuthnCredentialsResult { + fatalError() + } + + func deleteWebAuthnCredential(credentialId: String, options: AuthDeleteWebAuthnCredentialRequest.Options?) async throws { + fatalError() + } + var key: String { return "MockAuthCategoryPlugin" } diff --git a/Package.resolved b/Package.resolved index 7ba77b8a62..554cd29b15 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,17 +14,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/awslabs/aws-crt-swift", "state" : { - "revision" : "7b42e0343f28b3451aab20840dc670abd12790bd", - "version" : "0.36.0" + "revision" : "3f844bef042cc0a4c3381f7090414ce3f9a7e935", + "version" : "0.37.0" } }, { "identity" : "aws-sdk-swift", "kind" : "remoteSourceControl", - "location" : "https://github.com/awslabs/aws-sdk-swift.git", + "location" : "https://github.com/awslabs/aws-sdk-swift", "state" : { - "revision" : "828358a2c39d138325b0f87a2d813f4b972e5f4f", - "version" : "1.0.0" + "revision" : "c6c1064da9bfccb119a7a8ab9ba636fb3bbfa6f5", + "version" : "1.0.47" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/smithy-lang/smithy-swift", "state" : { - "revision" : "0ed3440f8c41e27a0937364d5035d2d4fefb8aa3", - "version" : "0.71.0" + "revision" : "3cd9f181b3ba8ff71da43bf53c09f8de6790a4ad", + "version" : "0.96.0" } }, { diff --git a/Package.swift b/Package.swift index d0e70eda38..05ba6e751b 100644 --- a/Package.swift +++ b/Package.swift @@ -9,7 +9,7 @@ let platforms: [SupportedPlatform] = [ .watchOS(.v9) ] let dependencies: [Package.Dependency] = [ - .package(url: "https://github.com/awslabs/aws-sdk-swift.git", exact: "1.0.0"), + .package(url: "https://github.com/awslabs/aws-sdk-swift", exact: "1.0.47"), .package(url: "https://github.com/stephencelis/SQLite.swift.git", exact: "0.15.3"), .package(url: "https://github.com/mattgallagher/CwlPreconditionTesting.git", from: "2.1.0"), .package(url: "https://github.com/aws-amplify/amplify-swift-utils-notifications.git", from: "1.1.0")