├── NOTICE ├── Sources └── Authenticator │ ├── Resources │ └── media.xcassets │ │ ├── Contents.json │ │ └── passkey.imageset │ │ └── Contents.json │ ├── Models │ ├── Internal │ │ ├── AuthenticatorMFAType.swift │ │ ├── InputType.swift │ │ ├── Credentials.swift │ │ ├── Validator.swift │ │ └── AuthenticatorStateProtocol.swift │ ├── AuthenticationFlow.swift │ ├── PasskeyPrompt.swift │ ├── AuthenticatorMessage.swift │ └── AuthFactor.swift │ ├── Constants │ └── ComponentInformation.swift │ ├── Service │ ├── AmplifyAuthenticationService.swift │ └── AuthenticationService.swift │ ├── Extensions │ ├── Logger+Error.swift │ ├── AuthUserAttributeKey+LocalizedTitle.swift │ ├── DeliveryDestination+Value.swift │ ├── Bundle+Utils.swift │ ├── AuthError+Connectivity.swift │ ├── String+Localizable.swift │ ├── Color+Utils.swift │ └── EnvironmentValues+Authenticator.swift │ ├── Utilities │ ├── Platform.swift │ ├── AuthenticatorLogging.swift │ ├── Padding.swift │ ├── KeyboardIterableFields.swift │ └── AuthenticatorField.swift │ ├── Configuration │ └── AuthenticatorOptions.swift │ ├── Options │ └── TOTPOptions.swift │ ├── Views │ ├── ErrorView.swift │ ├── ContinueSignInWithTOTPCopyKeyView.swift │ ├── Internal │ │ ├── AuthenticatorTextWithHeader.swift │ │ ├── AuthenticatorView.swift │ │ ├── DefaultHeader.swift │ │ ├── ConfirmSignInWithCodeView.swift │ │ ├── SignUpInputField.swift │ │ └── AuthenticatorMessageView.swift │ ├── Primitives │ │ ├── RadioButton.swift │ │ ├── ImageButton.swift │ │ └── TextField.swift │ ├── ContinueSignInWithTOTPSetupQRCodeView.swift │ ├── ConfirmSignInWithCustomChallengeView.swift │ ├── ConfirmSignInWithTOTPCodeView.swift │ ├── ConfirmSignInWithOTPView.swift │ ├── ConfirmSignInWithMFACodeView.swift │ ├── VerifyUserView.swift │ └── PromptToCreatePasskeyView.swift │ └── States │ ├── SignedInState.swift │ ├── ContinueSignInWithEmailMFASetupState.swift │ ├── ConfirmSignInWithNewPasswordState.swift │ ├── ContinueSignInWithMFASelectionState.swift │ ├── ResetPasswordState.swift │ ├── ContinueSignInWithMFASetupSelectionState.swift │ ├── SignInConfirmPasswordState.swift │ ├── ConfirmSignInWithCodeState.swift │ ├── PasskeyCreatedState.swift │ ├── ConfirmResetPasswordState.swift │ ├── VerifyUserState.swift │ ├── ConfirmSignUpState.swift │ ├── PromptToCreatePasskeyState.swift │ ├── ContinueSignInWithTOTPSetupState.swift │ ├── SignInState.swift │ └── ConfirmVerifyUserState.swift ├── Tests ├── AuthenticatorHostApp │ ├── AuthenticatorHostApp │ │ ├── Assets.xcassets │ │ │ ├── Contents.json │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ └── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ ├── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ │ └── Contents.json │ │ ├── AuthenticatorHostApp.entitlements │ │ └── Utils │ │ │ └── AuthCategoryConfigurationFactory.swift │ ├── AuthenticatorHostApp.xcodeproj │ │ ├── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── AuthenticatorHostApp.xcscheme │ ├── AuthenticatorHostAppUITests │ │ ├── TestCases │ │ │ ├── __Snapshots__ │ │ │ │ ├── SignUpViewTests │ │ │ │ │ ├── testDefaultSignUpView.1.png │ │ │ │ │ └── testPasswordlessSignUpView.1.png │ │ │ │ ├── PasskeyPromptTests │ │ │ │ │ ├── testSignInPasskeyPrompt.1.png │ │ │ │ │ ├── testSignUpPasskeyPrompt.1.png │ │ │ │ │ └── testSignInPasskeyCreated.1.png │ │ │ │ ├── SignInViewTests │ │ │ │ │ ├── testSignInViewWithoutSignUp.1.png │ │ │ │ │ └── testSignInViewWithWithUsernameAsPhoneNumber.1.png │ │ │ │ ├── Untitled │ │ │ │ │ └── testConfirmSignInWithCustomAuthChallenge.1.png │ │ │ │ ├── ResetPasswordViewTests │ │ │ │ │ ├── testConfirmSignInWithResetPassword.1.png │ │ │ │ │ └── testResetPasswordViewWithUsernameAsPhoneNumber.1.png │ │ │ │ ├── ConfirmSignInWithOTPCodeViewTests │ │ │ │ │ ├── testConfirmSignInWithEmail.1.png │ │ │ │ │ └── testConfirmSignInWithSMS.1.png │ │ │ │ ├── ConfirmSignInWithNewPasswordTests │ │ │ │ │ └── testConfirmSignInWithNewPassword.1.png │ │ │ │ ├── ConfirmSignInWithConfirmSignUpTests │ │ │ │ │ └── testConfirmSignInWithConfirmSignUp.1.png │ │ │ │ ├── ConfirmSignInWithTOTPCodeViewTests │ │ │ │ │ └── testConfirmSignInWithTOTPCodeView.1.png │ │ │ │ ├── ContinueSignInWithTOTPSetupViewTests │ │ │ │ │ └── testContinueSignInWithTOTPSetupView.1.png │ │ │ │ ├── ContinueSignInWithEmailMFASetupViewTests │ │ │ │ │ └── testContinueSignInWithEmailMFASetupView.1.png │ │ │ │ ├── ContinueSignInWithMFASelectionViewTests │ │ │ │ │ └── testContinueSignInWithMFASelectionView.1.png │ │ │ │ ├── ConfirmSignInWithCustomAuthChallengeViewTests │ │ │ │ │ └── testConfirmSignInWithCustomAuthChallenge.1.png │ │ │ │ ├── ContinueSignInWithFirstFactorSelectionTests │ │ │ │ │ └── testContinueSignInWithFirstFactorSelection.1.png │ │ │ │ └── ContinueSignInWithMFASetupSelectionViewTests │ │ │ │ │ └── testContinueSignInWithMFASetupSelectionView.1.png │ │ │ ├── ConfirmSignInWithConfirmSignUpTests.swift │ │ │ ├── ConfirmSignInWithTOTPCodeViewTests.swift │ │ │ ├── ConfirmSignInWithNewPasswordTests.swift │ │ │ ├── ContinueSignInWithTOTPSetupViewTests.swift │ │ │ ├── ContinueSignInWithMFASelectionViewTests.swift │ │ │ ├── ContinueSignInWithEmailMFASetupViewTests.swift │ │ │ ├── ConfirmSignInWithCustomAuthChallengeViewTests.swift │ │ │ ├── ContinueSignInWithFirstFactorSelectionTests.swift │ │ │ ├── ContinueSignInWithMFASetupSelectionViewTests.swift │ │ │ ├── SignUpViewTests.swift │ │ │ ├── SignInViewTests.swift │ │ │ ├── ResetPasswordViewTests.swift │ │ │ ├── ConfirmSignInWithOTPCodeViewTests.swift │ │ │ └── PasskeyPromptTests.swift │ │ ├── SnapshotLogic │ │ │ └── CleanCounterBetweenTestCases.swift │ │ ├── AuthenticatorUITestUtils.swift │ │ └── AuthenticatorBaseTestCase.swift │ └── AuthenticatorHostApp.xctestplan └── AuthenticatorTests │ ├── States │ ├── SignedInStateTests.swift │ ├── ResetPasswordStateTests.swift │ ├── ConfirmSignInWithNewPasswordStateTests.swift │ ├── ContinueSignInWithEmailMFASetupStateTests.swift │ ├── ContinueSignInWithMFASelectionStateTests.swift │ ├── ConfirmResetPasswordStateTests.swift │ └── ContinueSignInWithMFASetupSelectionStateTests.swift │ └── Mocks │ └── MockAuthenticatorState.swift ├── .github ├── CODEOWNERS └── workflows │ ├── notify_release.yml │ ├── dependency-review.yml │ ├── ui_tests.yml │ ├── issue_closed.yml │ ├── unit_tests.yml │ ├── issue_comment.yml │ └── issue_opened.yml ├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── Authenticator.xcscheme ├── codecov.yml ├── CODE_OF_CONDUCT.md ├── Package.swift ├── .gitignore ├── README.md ├── CONTRIBUTING.md └── CHANGELOG.md /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | -------------------------------------------------------------------------------- /Sources/Authenticator/Resources/media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostApp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostApp/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 2 | * @aws-amplify/amplify-ios 3 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostApp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostApp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sources/Authenticator/Resources/media.xcassets/passkey.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "passkey.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/SignUpViewTests/testDefaultSignUpView.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/SignUpViewTests/testDefaultSignUpView.1.png -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | ignore: 2 | - "Sources/Authenticator/Views" 3 | - "Sources/Authenticator/Theming" 4 | - "Sources/Authenticator/Authenticator.swift" 5 | 6 | codecov: 7 | branch: main 8 | 9 | coverage: 10 | status: 11 | patch: off 12 | project: 13 | default: 14 | threshold: 1% 15 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/PasskeyPromptTests/testSignInPasskeyPrompt.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/PasskeyPromptTests/testSignInPasskeyPrompt.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/PasskeyPromptTests/testSignUpPasskeyPrompt.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/PasskeyPromptTests/testSignUpPasskeyPrompt.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/SignUpViewTests/testPasswordlessSignUpView.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/SignUpViewTests/testPasswordlessSignUpView.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/PasskeyPromptTests/testSignInPasskeyCreated.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/PasskeyPromptTests/testSignInPasskeyCreated.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/SignInViewTests/testSignInViewWithoutSignUp.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/SignInViewTests/testSignInViewWithoutSignUp.1.png -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /Sources/Authenticator/Models/Internal/AuthenticatorMFAType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | enum AuthenticatorFactorType { 9 | case sms 10 | case email 11 | case totp 12 | case none 13 | } 14 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/Untitled/testConfirmSignInWithCustomAuthChallenge.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/Untitled/testConfirmSignInWithCustomAuthChallenge.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostApp/AuthenticatorHostApp.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/Authenticator/Models/Internal/InputType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | enum InputType { 11 | case text 12 | case password 13 | case date 14 | case phoneNumber 15 | } 16 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ResetPasswordViewTests/testConfirmSignInWithResetPassword.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ResetPasswordViewTests/testConfirmSignInWithResetPassword.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ConfirmSignInWithOTPCodeViewTests/testConfirmSignInWithEmail.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ConfirmSignInWithOTPCodeViewTests/testConfirmSignInWithEmail.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ConfirmSignInWithOTPCodeViewTests/testConfirmSignInWithSMS.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ConfirmSignInWithOTPCodeViewTests/testConfirmSignInWithSMS.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/SignInViewTests/testSignInViewWithWithUsernameAsPhoneNumber.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/SignInViewTests/testSignInViewWithWithUsernameAsPhoneNumber.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ConfirmSignInWithNewPasswordTests/testConfirmSignInWithNewPassword.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ConfirmSignInWithNewPasswordTests/testConfirmSignInWithNewPassword.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostApp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ConfirmSignInWithConfirmSignUpTests/testConfirmSignInWithConfirmSignUp.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ConfirmSignInWithConfirmSignUpTests/testConfirmSignInWithConfirmSignUp.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ConfirmSignInWithTOTPCodeViewTests/testConfirmSignInWithTOTPCodeView.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ConfirmSignInWithTOTPCodeViewTests/testConfirmSignInWithTOTPCodeView.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ResetPasswordViewTests/testResetPasswordViewWithUsernameAsPhoneNumber.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ResetPasswordViewTests/testResetPasswordViewWithUsernameAsPhoneNumber.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ContinueSignInWithTOTPSetupViewTests/testContinueSignInWithTOTPSetupView.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ContinueSignInWithTOTPSetupViewTests/testContinueSignInWithTOTPSetupView.1.png -------------------------------------------------------------------------------- /Sources/Authenticator/Constants/ComponentInformation.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | public class ComponentInformation { 11 | public static let version = "1.3.0" 12 | public static let name = "amplify-ui-swift-authenticator" 13 | } 14 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ContinueSignInWithEmailMFASetupViewTests/testContinueSignInWithEmailMFASetupView.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ContinueSignInWithEmailMFASetupViewTests/testContinueSignInWithEmailMFASetupView.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ContinueSignInWithMFASelectionViewTests/testContinueSignInWithMFASelectionView.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ContinueSignInWithMFASelectionViewTests/testContinueSignInWithMFASelectionView.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ConfirmSignInWithCustomAuthChallengeViewTests/testConfirmSignInWithCustomAuthChallenge.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ConfirmSignInWithCustomAuthChallengeViewTests/testConfirmSignInWithCustomAuthChallenge.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ContinueSignInWithFirstFactorSelectionTests/testContinueSignInWithFirstFactorSelection.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ContinueSignInWithFirstFactorSelectionTests/testContinueSignInWithFirstFactorSelection.1.png -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ContinueSignInWithMFASetupSelectionViewTests/testContinueSignInWithMFASetupSelectionView.1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-amplify/amplify-ui-swift-authenticator/HEAD/Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/__Snapshots__/ContinueSignInWithMFASetupSelectionViewTests/testContinueSignInWithMFASetupSelectionView.1.png -------------------------------------------------------------------------------- /Sources/Authenticator/Service/AmplifyAuthenticationService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | typealias AmplifyAuthenticationService = AuthCategory 12 | 13 | extension AuthenticationService where Self == AmplifyAuthenticationService { 14 | static var `default`: AuthenticationService { 15 | return Amplify.Auth 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sources/Authenticator/Extensions/Logger+Error.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import Foundation 10 | 11 | extension Logger { 12 | func error(_ error: Error) { 13 | self.error(error: error) 14 | self.error(String(reflecting: error)) 15 | } 16 | 17 | func verbose(_ error: Error) { 18 | self.verbose(String(reflecting: error)) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Authenticator/Extensions/AuthUserAttributeKey+LocalizedTitle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | 10 | extension AuthUserAttributeKey { 11 | var localizedTitle: String { 12 | switch self { 13 | case .email: 14 | return "authenticator.field.email.label".localized() 15 | case .phoneNumber: 16 | return "authenticator.field.phoneNumber.label".localized() 17 | default: 18 | return "" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/ConfirmSignInWithConfirmSignUpTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class ConfirmSignInWithConfirmSignUpTests: AuthenticatorBaseTestCase { 11 | 12 | func testConfirmSignInWithConfirmSignUp() throws { 13 | launchAppAndLogin(with: [ 14 | .hidesSignUpButton(false), 15 | .initialStep(.signIn), 16 | .authSignInStep(.confirmSignUp) 17 | ]) 18 | assertSnapshot() 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/ConfirmSignInWithTOTPCodeViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class ConfirmSignInWithTOTPCodeViewTests: AuthenticatorBaseTestCase { 11 | 12 | func testConfirmSignInWithTOTPCodeView() throws { 13 | launchAppAndLogin(with: [ 14 | .hidesSignUpButton(false), 15 | .initialStep(.signIn), 16 | .authSignInStep(.confirmSignInWithTOTPCode) 17 | ]) 18 | assertSnapshot() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Authenticator/Service/AuthenticationService.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import class Amplify.AuthCategory 9 | import protocol Amplify.AmplifyError 10 | import protocol Amplify.AuthCategoryBehavior 11 | import enum Amplify.AuthError 12 | import enum Amplify.AuthUserAttributeKey 13 | import enum Amplify.AuthSignInStep 14 | import Foundation 15 | import SwiftUI 16 | 17 | protocol AuthenticationService: AuthCategoryBehavior, AnyObject { } 18 | 19 | extension Amplify.AuthCategory: AuthenticationService, @retroactive ObservableObject {} 20 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/ConfirmSignInWithNewPasswordTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class ConfirmSignInWithNewPasswordTests: AuthenticatorBaseTestCase { 11 | 12 | func testConfirmSignInWithNewPassword() throws { 13 | launchAppAndLogin(with: [ 14 | .hidesSignUpButton(false), 15 | .initialStep(.signIn), 16 | .authSignInStep(.confirmSignInWithNewPassword) 17 | ]) 18 | assertSnapshot() 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Authenticator/Extensions/DeliveryDestination+Value.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | 10 | extension DeliveryDestination { 11 | var value: String? { 12 | switch self { 13 | case .email(let destination): 14 | return destination 15 | case .phone(let destination): 16 | return destination 17 | case .sms(let destination): 18 | return destination 19 | case .unknown(let destination): 20 | return destination 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/ContinueSignInWithTOTPSetupViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class ContinueSignInWithTOTPSetupViewTests: AuthenticatorBaseTestCase { 11 | 12 | func testContinueSignInWithTOTPSetupView() throws { 13 | launchAppAndLogin(with: [ 14 | .hidesSignUpButton(false), 15 | .initialStep(.signIn), 16 | .authSignInStep(.continueSignInWithTOTPSetup) 17 | ]) 18 | assertSnapshot() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/ContinueSignInWithMFASelectionViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class ContinueSignInWithMFASelectionViewTests: AuthenticatorBaseTestCase { 11 | 12 | func testContinueSignInWithMFASelectionView() throws { 13 | launchAppAndLogin(with: [ 14 | .hidesSignUpButton(false), 15 | .initialStep(.signIn), 16 | .authSignInStep(.continueSignInWithMFASelection) 17 | ]) 18 | assertSnapshot() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/ContinueSignInWithEmailMFASetupViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class ContinueSignInWithEmailMFASetupViewTests: AuthenticatorBaseTestCase { 11 | 12 | func testContinueSignInWithEmailMFASetupView() throws { 13 | launchAppAndLogin(with: [ 14 | .hidesSignUpButton(false), 15 | .initialStep(.signIn), 16 | .authSignInStep(.continueSignInWithEmailMFASetup) 17 | ]) 18 | assertSnapshot() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/Authenticator/Models/AuthenticationFlow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// Represents the authentication flow configuration for the Authenticator 11 | public enum AuthenticationFlow: Equatable { 12 | /// Password-only authentication flow 13 | case password 14 | 15 | /// User choice authentication flow with optional preferred factor and passkey prompts 16 | case userChoice(preferredAuthFactor: AuthFactor? = nil, passkeyPrompts: PasskeyPrompts = .init()) 17 | } 18 | 19 | extension AuthenticationFlow: Codable {} 20 | -------------------------------------------------------------------------------- /Sources/Authenticator/Utilities/Platform.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | enum Platform { 11 | case macOS 12 | case iOS 13 | case unsupported 14 | 15 | static var current: Platform { 16 | #if os(macOS) 17 | return .macOS 18 | #elseif os(iOS) 19 | return .iOS 20 | #else 21 | return .unsupported 22 | #endif 23 | } 24 | 25 | static var isMacOS: Bool { 26 | current == .macOS 27 | } 28 | 29 | static var isIOS: Bool { 30 | current == .iOS 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/ConfirmSignInWithCustomAuthChallengeViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class ConfirmSignInWithCustomAuthChallengeViewTests: AuthenticatorBaseTestCase { 11 | 12 | func testConfirmSignInWithCustomAuthChallenge() throws { 13 | launchAppAndLogin(with: [ 14 | .hidesSignUpButton(false), 15 | .initialStep(.signIn), 16 | .authSignInStep(.confirmSignInWithCustomChallenge) 17 | ]) 18 | assertSnapshot() 19 | } 20 | 21 | } 22 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/ContinueSignInWithFirstFactorSelectionTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class ContinueSignInWithFirstFactorSelectionTests: AuthenticatorBaseTestCase { 11 | 12 | func testContinueSignInWithFirstFactorSelection() throws { 13 | launchAppAndLogin(with: [ 14 | .hidesSignUpButton(false), 15 | .initialStep(.signIn), 16 | .authSignInStep(.continueSignInWithFirstFactorSelection) 17 | ]) 18 | assertSnapshot() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/ContinueSignInWithMFASetupSelectionViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class ContinueSignInWithMFASetupSelectionViewTests: AuthenticatorBaseTestCase { 11 | 12 | func testContinueSignInWithMFASetupSelectionView() throws { 13 | launchAppAndLogin(with: [ 14 | .hidesSignUpButton(false), 15 | .initialStep(.signIn), 16 | .authSignInStep(.continueSignInWithMFASetupSelection) 17 | ]) 18 | assertSnapshot() 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/notify_release.yml: -------------------------------------------------------------------------------- 1 | name: Notify Release 2 | 3 | on: 4 | release: 5 | types: [released] 6 | 7 | permissions: {} 8 | 9 | jobs: 10 | notify: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Run webhook curl command 14 | env: 15 | WEBHOOK_URL: ${{ secrets.SLACK_RELEASE_WEBHOOK_URL }} 16 | VERSION: ${{github.event.release.html_url}} 17 | REPO_NAME: ${{github.event.repository.name}} 18 | ACTION_NAME: ${{github.event.action}} 19 | shell: bash 20 | run: echo $VERSION | xargs -I {} curl -s POST "$WEBHOOK_URL" -H "Content-Type:application/json" --data '{"action":"'$ACTION_NAME'", "repo":"'$REPO_NAME'", "version":"{}"}' 21 | -------------------------------------------------------------------------------- /Sources/Authenticator/Configuration/AuthenticatorOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | class AuthenticatorOptions: ObservableObject { 11 | @Published var hidesSignUpButton = false 12 | @Published var contentAnimation: Animation = .easeInOut(duration: 0.25) 13 | @Published var contentTransition: AnyTransition = .opacity 14 | @Published var signUpFields: [SignUpField] = [] 15 | @Published var busyStyle = BusyStyle(content: ProgressView()) 16 | 17 | struct BusyStyle { 18 | var blurRadius: CGFloat = 2 19 | var content: any View 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/SignUpViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class SignUpViewTests: AuthenticatorBaseTestCase { 11 | 12 | func testDefaultSignUpView() throws { 13 | launchApp(with: [ 14 | .initialStep(.signUp), 15 | ]) 16 | assertSnapshot() 17 | } 18 | 19 | func testPasswordlessSignUpView() throws { 20 | launchApp(with: [ 21 | .initialStep(.signUp), 22 | .passwordlessFlow(true) 23 | ]) 24 | assertSnapshot() 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: Dependency Review 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | dependency-review: 13 | name: Dependency Review 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout Code 17 | uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 18 | with: 19 | persist-credentials: false 20 | 21 | - name: Dependency Review 22 | uses: actions/dependency-review-action@7d90b4f05fea31dde1c4a1fb3fa787e197ea93ab # v3.0.7 23 | with: 24 | config-file: aws-amplify/amplify-ci-support/.github/dependency-review-config.yml@main 25 | -------------------------------------------------------------------------------- /Sources/Authenticator/Models/Internal/Credentials.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | class Credentials: ObservableObject { 12 | @Published var username: String = "" 13 | @Published var password: String? 14 | 15 | @Published var message: AuthenticatorMessage? 16 | 17 | /// Tracks the currently selected auth factor during sign-in. 18 | /// Used to detect when user changes their auth factor selection after already selecting one. 19 | /// When non-nil, subsequent factor selections require restarting the sign-in flow. 20 | @Published var selectedAuthFactor: AuthFactor? 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Authenticator/Utilities/AuthenticatorLogging.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | 10 | protocol AuthenticatorLogging { 11 | static var log: Logger { get } 12 | var log: Logger { get } 13 | } 14 | 15 | extension AuthenticatorLogging { 16 | static var log: Logger { 17 | var category = String(describing: self) 18 | if let index = category.firstIndex(of: "<") { 19 | category = String(category.prefix(upTo: index)) 20 | } 21 | 22 | return Amplify.Logging.logger(forCategory: category) 23 | } 24 | 25 | var log: Logger { 26 | type(of: self).log 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Authenticator/Extensions/Bundle+Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | extension Bundle { 11 | 12 | var applicationName: String? { 13 | if let localizedName = Bundle.main.infoDictionary?[kCFBundleLocalizationsKey as String] as? String { 14 | return localizedName 15 | } 16 | if let displayName = Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String { 17 | return displayName 18 | } 19 | if let bundleName = Bundle.main.infoDictionary?[kCFBundleNameKey as String] as? String { 20 | return bundleName 21 | } 22 | return nil 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sources/Authenticator/Extensions/AuthError+Connectivity.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import Foundation 10 | 11 | extension AuthError { 12 | var isConnectivityError: Bool { 13 | guard let error = underlyingError as? NSError else { 14 | return false 15 | } 16 | 17 | let networkErrorCodes = [ 18 | NSURLErrorCannotFindHost, 19 | NSURLErrorCannotConnectToHost, 20 | NSURLErrorNetworkConnectionLost, 21 | NSURLErrorDNSLookupFailed, 22 | NSURLErrorNotConnectedToInternet 23 | ] 24 | return networkErrorCodes.contains(where: { $0 == error.code }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sources/Authenticator/Options/TOTPOptions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | import Foundation 8 | 9 | /// Options for configuring the TOTP MFA Experience 10 | public struct TOTPOptions { 11 | 12 | /// The `issuer` is the title displayed in a user's TOTP App preceding the 13 | /// account name. In most cases, this should be the name of your app. 14 | /// For example, if your app is called "My App", your user will see 15 | /// "My App" - "username" in their TOTP app. 16 | public let issuer: String? 17 | 18 | /// Creates a `TOTPOptions` 19 | /// - Parameter issuer: The `issuer` is the title displayed in a user's TOTP App 20 | public init(issuer: String? = nil) { 21 | self.issuer = issuer 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/SignInViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class SignInViewTests: AuthenticatorBaseTestCase { 11 | 12 | func testSignInViewWithWithUsernameAsPhoneNumber() throws { 13 | launchApp(with: [ 14 | .hidesSignUpButton(false), 15 | .initialStep(.signIn), 16 | .userAttributes([ .phoneNumber ]) 17 | ]) 18 | assertSnapshot() 19 | } 20 | 21 | func testSignInViewWithoutSignUp() throws { 22 | launchApp(with: [ 23 | .hidesSignUpButton(true), 24 | .initialStep(.signIn), 25 | .userAttributes([ .phoneNumber ]) 26 | ]) 27 | assertSnapshot() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Authenticator/Extensions/String+Localizable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | extension String { 11 | /// Looks for a localized value using this value as the key. 12 | /// If no localization is found in the current app's bundle, 13 | /// it defaults to the one provided by Authenticator 14 | func localized(comment: String = "") -> String { 15 | let defaultValue = NSLocalizedString(self, bundle: .module, comment: "") 16 | return NSLocalizedString( 17 | self, 18 | bundle: .main, 19 | value: defaultValue, 20 | comment: "" 21 | ) 22 | } 23 | 24 | func localized(using arguments: CVarArg...) -> String { 25 | return String(format: localized(), arguments) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/ResetPasswordViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class ResetPasswordViewTests: AuthenticatorBaseTestCase { 11 | 12 | func testResetPasswordViewWithUsernameAsPhoneNumber() throws { 13 | launchApp(with: [ 14 | .hidesSignUpButton(false), 15 | .initialStep(.resetPassword), 16 | .userAttributes([ .phoneNumber ]) 17 | ]) 18 | assertSnapshot() 19 | } 20 | 21 | func testConfirmSignInWithResetPassword() throws { 22 | launchAppAndLogin(with: [ 23 | .hidesSignUpButton(false), 24 | .initialStep(.signIn), 25 | .authSignInStep(.resetPassword) 26 | ]) 27 | assertSnapshot() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostApp.xctestplan: -------------------------------------------------------------------------------- 1 | { 2 | "configurations" : [ 3 | { 4 | "id" : "6A2C8881-BF18-41B8-8578-D487AB88126F", 5 | "name" : "Test Scheme Action", 6 | "options" : { 7 | 8 | } 9 | } 10 | ], 11 | "defaultOptions" : { 12 | "targetForVariableExpansion" : { 13 | "containerPath" : "container:AuthenticatorHostApp.xcodeproj", 14 | "identifier" : "483E093C2ABBC0D800EFD1D7", 15 | "name" : "AuthenticatorHostApp" 16 | }, 17 | "testExecutionOrdering" : "random" 18 | }, 19 | "testTargets" : [ 20 | { 21 | "skippedTests" : [ 22 | "AuthenticatorBaseTestCase" 23 | ], 24 | "target" : { 25 | "containerPath" : "container:AuthenticatorHostApp.xcodeproj", 26 | "identifier" : "483E095B2ABBC4AC00EFD1D7", 27 | "name" : "AuthenticatorHostAppUITests" 28 | } 29 | } 30 | ], 31 | "version" : 1 32 | } 33 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/ConfirmSignInWithOTPCodeViewTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class ConfirmSignInWithOTPCodeViewTests: AuthenticatorBaseTestCase { 11 | 12 | func testConfirmSignInWithEmail() throws { 13 | launchAppAndLogin(with: [ 14 | .hidesSignUpButton(false), 15 | .initialStep(.signIn), 16 | .authSignInStep(.confirmSignInWithEmailMFACode) 17 | ]) 18 | assertSnapshot() 19 | } 20 | 21 | func testConfirmSignInWithSMS() throws { 22 | launchAppAndLogin(with: [ 23 | .hidesSignUpButton(false), 24 | .initialStep(.signIn), 25 | .authSignInStep(.confirmSignInWithSMSMFACode) 26 | ]) 27 | assertSnapshot() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/ErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Represents the content being displayed when the ``Authenticator`` is in the ``AuthenticatorStep/error`` step. 11 | public struct ErrorView: View { 12 | @Environment(\.authenticatorTheme) private var theme 13 | 14 | public init() {} 15 | 16 | public var body: some View { 17 | AuthenticatorView(isBusy: false) { 18 | DefaultHeader( 19 | title: "authenticator.authenticatorError.title".localized() 20 | ) 21 | .foregroundColor(theme.colors.border.error) 22 | 23 | SwiftUI.Text("authenticator.authenticatorError.message".localized()) 24 | .foregroundColor(theme.colors.foreground.error) 25 | 26 | Spacer() 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/ui_tests.yml: -------------------------------------------------------------------------------- 1 | name: Run UI Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | ui-test-ios: 14 | runs-on: macos-15 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 18 | 19 | - name: Resolve and update Swift packages 20 | run: xcodebuild -resolvePackageDependencies -scheme Authenticator 21 | 22 | - name: UI test Authenticator on iOS 23 | working-directory: Tests/AuthenticatorHostApp 24 | run: | 25 | xcodebuild -resolvePackageDependencies -scheme Authenticator 26 | xcodebuild test -scheme AuthenticatorHostApp -sdk 'iphonesimulator' -destination 'platform=iOS Simulator,name=iPhone 16 Pro Max,OS=latest' -derivedDataPath Build/ -clonedSourcePackagesDirPath ~/Library/Developer/Xcode/DerivedData/Authenticator | xcpretty --simple --color --report junit && exit ${PIPESTATUS[0]} 27 | -------------------------------------------------------------------------------- /Sources/Authenticator/Models/Internal/Validator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | class Validator: ObservableObject { 11 | enum State: Equatable { 12 | case normal 13 | case error(message: String?) 14 | } 15 | 16 | @Published var state: State 17 | var value: Binding 18 | 19 | private let validator: FieldValidator 20 | 21 | init(using validator: @escaping FieldValidator) { 22 | self.value = .constant("") 23 | self.state = .normal 24 | self.validator = validator 25 | if !self.value.wrappedValue.isEmpty { 26 | self.validate() 27 | } 28 | } 29 | 30 | @discardableResult 31 | func validate() -> Bool { 32 | if let error = validator(value.wrappedValue) { 33 | state = .error(message: error.description) 34 | return false 35 | } 36 | state = .normal 37 | return true 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.7 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "AmplifyUIAuthenticator", 8 | defaultLocalization: "en", 9 | platforms: [.iOS(.v15), .macOS(.v12)], 10 | products: [ 11 | .library( 12 | name: "Authenticator", 13 | targets: ["Authenticator"]), 14 | ], 15 | dependencies: [ 16 | .package(url: "https://github.com/aws-amplify/amplify-swift", from: "2.45.0") 17 | ], 18 | targets: [ 19 | .target( 20 | name: "Authenticator", 21 | dependencies: [ 22 | .product(name: "Amplify", package: "amplify-swift"), 23 | .product(name: "AWSCognitoAuthPlugin", package: "amplify-swift") 24 | ], 25 | resources: [ 26 | .process("Resources") 27 | ]), 28 | .testTarget( 29 | name: "AuthenticatorTests", 30 | dependencies: ["Authenticator"]), 31 | ] 32 | ) 33 | -------------------------------------------------------------------------------- /Tests/AuthenticatorTests/States/SignedInStateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | @testable import Authenticator 10 | import XCTest 11 | 12 | class SignedInStateTests: XCTestCase { 13 | private var state: SignedInState! 14 | private var authenticationService: MockAuthenticationService! 15 | 16 | override func setUp() { 17 | authenticationService = MockAuthenticationService() 18 | state = SignedInState( 19 | user: MockAuthenticationService.User(username: "username", userId: "userId"), 20 | authenticationService: authenticationService 21 | ) 22 | } 23 | 24 | override func tearDown() { 25 | state = nil 26 | authenticationService = nil 27 | } 28 | 29 | func testSignOut() async { 30 | let result = await state.signOut() 31 | XCTAssertEqual(authenticationService.signOutCount, 1) 32 | XCTAssertTrue(result is MockAuthenticationService.SignOutResult) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/ContinueSignInWithTOTPCopyKeyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Default QRCodeContent for the ``ContinueSignInWithTOTPSetupView``. It displays the view's QR Code 11 | public struct ContinueSignInWithTOTPCopyKeyView: View { 12 | 13 | @ObservedObject private var state: ContinueSignInWithTOTPSetupState 14 | 15 | public init(state: ContinueSignInWithTOTPSetupState) { 16 | self.state = state 17 | } 18 | 19 | public var body: some View { 20 | Button("authenticator.continueSignInWithTOTPSetup.button.copyKey".localized()) { 21 | #if os(iOS) 22 | UIPasteboard.general.string = state.sharedSecret 23 | #elseif os(macOS) 24 | let pasteboard = NSPasteboard.general 25 | pasteboard.clearContents() 26 | pasteboard.setString(state.sharedSecret, forType: .string) 27 | #endif 28 | } 29 | .buttonStyle(.capsule) 30 | } 31 | 32 | } 33 | 34 | extension ContinueSignInWithTOTPCopyKeyView: AuthenticatorLogging {} 35 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/SnapshotLogic/CleanCounterBetweenTestCases.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | let counterQueue = DispatchQueue(label: "com.amplify.authenticator.counter") 11 | var counterMap: [URL: Int] = [:] 12 | 13 | // We need to clean counter between tests executions in order to support test-iterations. 14 | class CleanCounterBetweenTestCases: NSObject, XCTestObservation { 15 | private static var registered = false 16 | private static var registerQueue = DispatchQueue( 17 | label: "com.amplify.authenticator.testObserver") 18 | 19 | static func registerIfNeeded() { 20 | registerQueue.sync { 21 | if !registered { 22 | registered = true 23 | XCTestObservationCenter.shared.addTestObserver(CleanCounterBetweenTestCases()) 24 | } 25 | } 26 | } 27 | 28 | func testCaseDidFinish(_ testCase: XCTestCase) { 29 | counterQueue.sync { 30 | counterMap = [:] 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | DerivedData/ 5 | .swiftpm/config/registries.json 6 | .netrc 7 | 8 | # Xcode setups 9 | /*.xcodeproj 10 | **/xcuserdata 11 | **/*.xcuserdata 12 | 13 | # Built assets # 14 | ###################### 15 | .build/ 16 | build 17 | builtFramework 18 | AWS*.framework 19 | node_modules 20 | package.json 21 | package-lock.json 22 | 23 | # Stuff that can't be committed # 24 | ###################### 25 | *credentials.json 26 | __pycache__/ 27 | *awsconfiguration.json 28 | *amplifyconfiguration.json 29 | awsconfiguration.json 30 | amplifyconfiguration.json 31 | credentials-mc.json 32 | 33 | # Amplify artifacts 34 | amplify/\#current-cloud-backend 35 | amplify/.config/local-* 36 | amplify/logs 37 | amplify/mock-data 38 | amplify/backend/amplify-meta.json 39 | amplify/backend/awscloudformation 40 | amplify/backend/.temp 41 | build/ 42 | dist/ 43 | node_modules/ 44 | aws-exports.js 45 | awsconfiguration.json 46 | amplifyconfiguration.json 47 | amplifyconfiguration.dart 48 | amplify-build-config.json 49 | amplify-gradle-config.json 50 | amplifytools.xcconfig 51 | .secret-* 52 | Tests/AuthenticatorHostApp/AuthenticatorHostApp.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved 53 | -------------------------------------------------------------------------------- /Sources/Authenticator/Models/Internal/AuthenticatorStateProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import Foundation 10 | 11 | protocol AuthenticatorStateProtocol { 12 | var authenticationService: AuthenticationService { get } 13 | var configuration: CognitoConfiguration { get } 14 | var authenticationFlow: AuthenticationFlow { get } 15 | var step: Step { get } 16 | func setCurrentStep(_ step: Step) 17 | func move(to initialStep: AuthenticatorInitialStep) 18 | } 19 | 20 | extension AuthenticatorStateProtocol where Self == EmptyAuthenticatorState { 21 | static var empty: AuthenticatorStateProtocol { 22 | return EmptyAuthenticatorState() 23 | } 24 | } 25 | 26 | struct EmptyAuthenticatorState: AuthenticatorStateProtocol { 27 | var authenticationService: AuthenticationService = .default 28 | var configuration: CognitoConfiguration = .empty 29 | var authenticationFlow: AuthenticationFlow = .password 30 | var step: Step = .loading 31 | func setCurrentStep(_ step: Step) {} 32 | func move(to initialStep: AuthenticatorInitialStep) {} 33 | } 34 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/SignedInState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import Foundation 10 | 11 | /// The state that represents that the ``Authenticator`` is in ``AuthenticatorStep/signedIn`` step 12 | /// It can be retrieved through `@EnvironmentObject var state: SignedInState`, but it will only be set once the Authenticator successfuly completes an authentication flow. 13 | public class SignedInState: ObservableObject { 14 | /// The signed in user 15 | public let user: AuthUser 16 | let authenticationService: AuthenticationService 17 | 18 | init(user: AuthUser, authenticationService: AuthenticationService) { 19 | self.user = user 20 | self.authenticationService = authenticationService 21 | } 22 | 23 | /// Performs a sign out. 24 | /// - Returns: A `AuthSignOutResult` 25 | @discardableResult public func signOut() async -> AuthSignOutResult { 26 | let result = await authenticationService.signOut(options: nil) 27 | log.verbose("Sign out result is \(result)") 28 | return result 29 | } 30 | } 31 | 32 | extension SignedInState: AuthenticatorLogging {} 33 | -------------------------------------------------------------------------------- /Sources/Authenticator/Models/PasskeyPrompt.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | 10 | /// Represents when to prompt users to create a passkey 11 | public enum PasskeyPrompt: Equatable { 12 | /// Never prompt the user to create a passkey 13 | case never 14 | 15 | /// Always prompt the user to create a passkey 16 | case always 17 | } 18 | 19 | extension PasskeyPrompt: Codable {} 20 | 21 | /// Configuration for when to prompt users to create passkeys 22 | public struct PasskeyPrompts: Equatable { 23 | /// When to prompt after sign up 24 | public let afterSignUp: PasskeyPrompt 25 | 26 | /// When to prompt after sign in 27 | public let afterSignIn: PasskeyPrompt 28 | 29 | /// Creates a PasskeyPrompts configuration 30 | /// - Parameters: 31 | /// - afterSignUp: When to prompt after sign up. Defaults to `.always` 32 | /// - afterSignIn: When to prompt after sign in. Defaults to `.always` 33 | public init(afterSignUp: PasskeyPrompt = .always, afterSignIn: PasskeyPrompt = .always) { 34 | self.afterSignUp = afterSignUp 35 | self.afterSignIn = afterSignIn 36 | } 37 | } 38 | 39 | extension PasskeyPrompts: Codable {} 40 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostApp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "1x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "2x", 16 | "size" : "16x16" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "1x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "2x", 26 | "size" : "32x32" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "2x", 36 | "size" : "128x128" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "1x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "2x", 46 | "size" : "256x256" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "1x", 51 | "size" : "512x512" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "author" : "xcode", 61 | "version" : 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Tests/AuthenticatorTests/Mocks/MockAuthenticatorState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Foundation 9 | @testable import Authenticator 10 | 11 | class MockAuthenticatorState: AuthenticatorStateProtocol { 12 | var authenticationService: AuthenticationService = MockAuthenticationService() 13 | 14 | var authenticationFlow: AuthenticationFlow = .password 15 | 16 | var configuration = CognitoConfiguration( 17 | usernameAttributes: [], 18 | signupAttributes: [], 19 | passwordProtectionSettings: .init(minLength: 0, characterPolicy: []), 20 | verificationMechanisms: [], 21 | hasUserPool: true, 22 | hasIdentityPool: true 23 | ) 24 | 25 | var setCurrentStepCount = 0 26 | var setCurrentStepValue: Step? 27 | func setCurrentStep(_ step: Step) { 28 | setCurrentStepCount += 1 29 | setCurrentStepValue = step 30 | } 31 | 32 | var mockedStep: Step? 33 | var step: Step { 34 | return mockedStep ?? .loading 35 | } 36 | 37 | var moveToCount = 0 38 | var moveToValue: AuthenticatorInitialStep? 39 | func move(to initialStep: AuthenticatorInitialStep) { 40 | moveToCount += 1 41 | moveToValue = initialStep 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/AuthenticatorUITestUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProcessArgument.swift 3 | // AuthenticatorHostApp 4 | // 5 | // Created by Singh, Harshdeep on 2023-09-22. 6 | // 7 | 8 | import Foundation 9 | @testable import Authenticator 10 | 11 | let UITestKeyKey = "-uiTestArgsData" 12 | 13 | enum ProcessArgument: Codable { 14 | case hidesSignUpButton(Bool) 15 | case initialStep(AuthenticatorInitialStep) 16 | case authSignInStep(AuthUITestSignInStep) 17 | case userAttributes([UserAttribute]) 18 | case passwordlessFlow(Bool) 19 | } 20 | 21 | enum UserAttribute: String, Codable { 22 | case username = "USERNAME" 23 | case email = "EMAIL" 24 | case phoneNumber = "PHONE_NUMBER" 25 | } 26 | 27 | public enum AuthUITestSignInStep: Codable { 28 | case confirmSignInWithSMSMFACode 29 | case confirmSignInWithCustomChallenge 30 | case confirmSignInWithNewPassword 31 | case confirmSignInWithTOTPCode 32 | case continueSignInWithTOTPSetup 33 | case continueSignInWithMFASelection 34 | case continueSignInWithMFASetupSelection 35 | case continueSignInWithEmailMFASetup 36 | case confirmSignInWithEmailMFACode 37 | case continueSignInWithFirstFactorSelection 38 | case confirmSignInWithOTP 39 | case confirmSignInWithPassword 40 | case resetPassword 41 | case confirmSignUp 42 | case done 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/Internal/AuthenticatorTextWithHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AuthenticatorTextWithHeader: View { 11 | @Environment(\.authenticatorTheme) private var theme 12 | private var title: String 13 | private var content: String 14 | 15 | init(title: String, content: String) { 16 | self.title = title 17 | self.content = content 18 | } 19 | 20 | var body: some View { 21 | VStack { 22 | 23 | SwiftUI.Text(title) 24 | .font(theme.fonts.headline) 25 | .foregroundColor(theme.colors.foreground.primary) 26 | .accessibilityAddTraits(.isStaticText) 27 | .multilineTextAlignment(.leading) 28 | .frame(maxWidth: .infinity, alignment: .leading) 29 | 30 | Spacer(minLength: 10) 31 | 32 | SwiftUI.Text(content) 33 | .font(theme.fonts.body) 34 | .foregroundColor(theme.colors.foreground.primary) 35 | .accessibilityAddTraits(.isStaticText) 36 | .multilineTextAlignment(.leading) 37 | .frame(maxWidth: .infinity, alignment: .leading) 38 | 39 | Spacer() 40 | } 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/Internal/AuthenticatorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AuthenticatorView: View { 11 | @Environment(\.authenticatorTheme) private var theme 12 | @Environment(\.authenticatorOptions) private var options 13 | private var isBusy: Bool 14 | private let content: Content 15 | 16 | init(isBusy: Bool, @ViewBuilder content: () -> Content) { 17 | self.isBusy = isBusy 18 | self.content = content() 19 | } 20 | 21 | var body: some View { 22 | ZStack { 23 | ScrollView { 24 | VStack(spacing: theme.components.authenticator.spacing.vertical) { 25 | content 26 | } 27 | .padding(theme.components.authenticator.padding/2) 28 | .background(theme.components.authenticator.backgroundColor) 29 | .cornerRadius(theme.components.authenticator.cornerRadius) 30 | .padding(theme.components.authenticator.padding/2) 31 | } 32 | .blur(radius: isBusy ? options.busyStyle.blurRadius : 0) 33 | .disabled(isBusy) 34 | 35 | if isBusy { 36 | AnyView(options.busyStyle.content) 37 | } 38 | } 39 | .frame(maxWidth: .infinity, maxHeight: .infinity) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/Internal/DefaultHeader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct DefaultHeader: View { 11 | @Environment(\.authenticatorTheme) private var theme 12 | var title: String 13 | private var font: Font? = nil 14 | private var foregroundColor: Color? = nil 15 | private var alignment: Alignment = .leading 16 | 17 | init(title: String) { 18 | self.title = title 19 | } 20 | 21 | var body: some View { 22 | HStack { 23 | SwiftUI.Text(title) 24 | .frame(maxWidth: .infinity, alignment: alignment) 25 | .font(font ?? theme.fonts.title) 26 | .foregroundColor(foregroundColor ?? theme.colors.foreground.primary) 27 | .accessibilityAddTraits(.isHeader) 28 | Spacer() 29 | } 30 | } 31 | 32 | func font(_ font: Font) -> DefaultHeader { 33 | var view = self 34 | view.font = font 35 | return view 36 | } 37 | 38 | func foregroundColor(_ foregroundColor: Color) -> DefaultHeader { 39 | var view = self 40 | view.foregroundColor = foregroundColor 41 | return view 42 | } 43 | 44 | func alignment(_ alignment: Alignment) -> DefaultHeader { 45 | var view = self 46 | view.alignment = alignment 47 | return view 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Amplify UI Authenticator for SwiftUI 2 | 3 | [![GitHub](https://img.shields.io/github/license/aws-amplify/amplify-ui-swift-authenticator)](LICENSE) 4 | [![Code Coverage](https://codecov.io/gh/aws-amplify/amplify-ui-swift-authenticator/branch/main/graph/badge.svg)](https://codecov.io/gh/aws-amplify/amplify-ui-swift-authenticator) 5 | [![Discord](https://img.shields.io/discord/308323056592486420?logo=discord)](https://discord.gg/jWVbPfC) 6 | [![Open Bugs](https://img.shields.io/github/issues/aws-amplify/amplify-ui-swift-authenticator/bug?color=d73a4a&label=bugs)](https://github.com/aws-amplify/amplify-ui-swift-authenticator/issues?q=is%3Aissue+is%3Aopen+label%3Abug) 7 | [![Feature Requests](https://img.shields.io/github/issues/aws-amplify/amplify-ui-swift-authenticator/feature-request?color=ff9001&label=feature%20requests)](https://github.com/aws-amplify/amplify-ui-swift-authenticator/issues?q=is%3Aissue+label%3Afeature-request+is%3Aopen) 8 | 9 | The **Amplify UI Authenticator** is a component that supports several authentiation flows using [Amplify Authentication](https://docs.amplify.aws/lib/auth/getting-started/q/platform/ios/). 10 | 11 | More information on setting up and using the Authenticator is in the [Amplify UI Authenticator documentation](https://ui.docs.amplify.aws/swift/connected-components/authenticator). 12 | 13 | ## Security 14 | 15 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 16 | 17 | ## License 18 | 19 | This project is licensed under the Apache-2.0 License. 20 | 21 | -------------------------------------------------------------------------------- /Sources/Authenticator/Extensions/Color+Utils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Color { 11 | /// Creates a color using HSL (Hue, Saturation, and Lightness) 12 | init(hue: Int, saturation: Int, lightness: Int) { 13 | let hue = Double(hue) / 360.0 14 | let saturation = Double(saturation) / 100.0 15 | let lightness = Double(lightness) / 100.0 16 | 17 | let brightness = lightness + saturation * min(lightness, 1 - lightness) 18 | let newSaturation = brightness == 0 ? 0 : 2 * (1 - lightness / brightness) 19 | 20 | self.init( 21 | hue: hue, 22 | saturation: newSaturation, 23 | brightness: brightness 24 | ) 25 | } 26 | 27 | /// Creates a color that suports Light and Dark mode 28 | init(light: Color, dark: Color) { 29 | #if os(iOS) 30 | self.init( 31 | uiColor: .init { 32 | if $0.userInterfaceStyle == .dark { 33 | return UIColor(dark) 34 | } else { 35 | return UIColor(light) 36 | } 37 | } 38 | ) 39 | #elseif os(macOS) 40 | self.init( 41 | nsColor: .init(name: nil) { 42 | if $0.name == .darkAqua { 43 | return NSColor(dark) 44 | } else { 45 | return NSColor(light) 46 | } 47 | } 48 | ) 49 | #endif 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/issue_closed.yml: -------------------------------------------------------------------------------- 1 | name: Issue Closed 2 | 3 | on: 4 | issues: 5 | types: [closed] 6 | 7 | permissions: 8 | issues: write 9 | 10 | jobs: 11 | cleanup-labels: 12 | runs-on: ubuntu-latest 13 | if: ${{ contains(github.event.issue.labels.*.name, 'pending-community-response') || contains(github.event.issue.labels.*.name, 'pending-maintainer-response') || contains(github.event.issue.labels.*.name, 'closing soon') || contains(github.event.issue.labels.*.name, 'pending-release') || contains(github.event.issue.labels.*.name, 'pending-triage') }} 14 | steps: 15 | - name: Remove unnecessary labels after closing 16 | shell: bash 17 | env: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | ISSUE_NUMBER: ${{ github.event.issue.number }} 20 | REPOSITORY_NAME: ${{ github.event.repository.full_name }} 21 | run: | 22 | gh issue edit $ISSUE_NUMBER --repo $REPOSITORY_NAME --remove-label "closing soon" --remove-label "pending-community-response" --remove-label "pending-maintainer-response" --remove-label "pending-release" --remove-label "pending-triage" 23 | 24 | comment-visibility-warning: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: aws-actions/closed-issue-message@v1 28 | with: 29 | repo-token: "${{ secrets.GITHUB_TOKEN }}" 30 | message: | 31 | This issue is now closed. Comments on closed issues are hard for our team to see. 32 | If you need more assistance, please open a new issue that references this one. 33 | If you wish to keep having a conversation with other community members under this issue feel free to do so. 34 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/Primitives/RadioButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This represents a simple Button with a Radio Button-like look. 11 | /// It automatically toggles its state on tap 12 | struct RadioButton: View { 13 | @Environment(\.authenticatorTheme) var theme 14 | @Binding private var isSelected: Bool 15 | private let label: String 16 | private let action: () -> () 17 | 18 | init( 19 | label: String, 20 | isSelected: Binding, 21 | action: @escaping () -> () 22 | ) { 23 | self.label = label 24 | self._isSelected = isSelected 25 | self.action = action 26 | } 27 | 28 | var body: some View { 29 | SwiftUI.Button( 30 | action: { 31 | isSelected.toggle() 32 | action() 33 | }, 34 | label: { 35 | HStack(alignment: .center) { 36 | SwiftUI.Image( 37 | systemName: isSelected 38 | ? "circle.inset.filled" 39 | : "circle" 40 | ) 41 | .font(.system(size: 24)) 42 | .foregroundColor(foregroundColor) 43 | Text(label) 44 | .foregroundColor(theme.colors.foreground.primary) 45 | Spacer() 46 | } 47 | } 48 | ) 49 | } 50 | 51 | private var foregroundColor: Color { 52 | if isSelected { 53 | return theme.colors.background.interactive 54 | } 55 | 56 | return theme.colors.border.primary 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /.github/workflows/unit_tests.yml: -------------------------------------------------------------------------------- 1 | name: Run Unit Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | unit-test-ios: 14 | runs-on: macos-15 15 | steps: 16 | - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 17 | - name: Unit test Authenticator on iOS 18 | run: xcodebuild test -scheme Authenticator -sdk 'iphonesimulator' -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' -derivedDataPath Build/ -enableCodeCoverage YES -clonedSourcePackagesDirPath ~/Library/Developer/Xcode/DerivedData/Authenticator | xcpretty --simple --color --report junit && exit ${PIPESTATUS[0]} 19 | - name: Generate Coverage Report 20 | continue-on-error: true 21 | run: | 22 | cd Build/Build/ProfileData 23 | cd $(ls -d */|head -n 1) 24 | pathCoverage=Build/Build/ProfileData/${PWD##*/}/Coverage.profdata 25 | cd ${{ github.workspace }} 26 | xcrun llvm-cov export -format="lcov" -instr-profile $pathCoverage Build/Build/Products/Debug-iphonesimulator/Authenticator.o > Authenticator-Coverage.lcov 27 | - name: Upload Report 28 | uses: codecov/codecov-action@84508663e988701840491b86de86b666e8a86bed # v4.3.0 29 | with: 30 | flags: Authenticator 31 | token: ${{ secrets.CODECOV_TOKEN }} 32 | 33 | unit-test-macos: 34 | runs-on: macos-latest 35 | steps: 36 | - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 37 | - name: Unit test Authenticator on macOS 38 | run: xcodebuild test -scheme Authenticator -sdk 'macosx' -destination 'platform=macOS,arch=x86_64' | xcpretty --simple --color --report junit && exit ${PIPESTATUS[0]} -------------------------------------------------------------------------------- /Sources/Authenticator/Utilities/Padding.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | func padding(_ padding: AuthenticatorTheme.Padding?) -> some View { 12 | modifier(PaddingModifier(padding: padding)) 13 | } 14 | 15 | func padding(_ edges: [Edge], _ padding: AuthenticatorTheme.Padding?) -> some View { 16 | modifier(PaddingWithEdgesModifier(edges: edges, padding: padding)) 17 | } 18 | 19 | @ViewBuilder fileprivate func padding(_ edges: Edge.Set, _ padding: CGFloat?, if condition: Bool) -> some View { 20 | if condition { 21 | self.padding(edges, padding) 22 | } else { 23 | self 24 | } 25 | } 26 | } 27 | 28 | private struct PaddingModifier: ViewModifier { 29 | var padding: AuthenticatorTheme.Padding? 30 | 31 | func body(content: Content) -> some View { 32 | content 33 | .padding([.top], padding?.top) 34 | .padding([.bottom], padding?.bottom) 35 | .padding([.leading], padding?.leading) 36 | .padding([.trailing], padding?.trailing) 37 | } 38 | } 39 | 40 | private struct PaddingWithEdgesModifier: ViewModifier { 41 | var edges: [Edge] 42 | var padding: AuthenticatorTheme.Padding? 43 | 44 | func body(content: Content) -> some View { 45 | content 46 | .padding([.top], padding?.top, if: edges.contains(.top)) 47 | .padding([.bottom], padding?.bottom, if: edges.contains(.bottom)) 48 | .padding([.leading], padding?.leading, if: edges.contains(.leading)) 49 | .padding([.trailing], padding?.trailing, if: edges.contains(.trailing)) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Authenticator/Extensions/EnvironmentValues+Authenticator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension EnvironmentValues { 11 | /// The Authenticator's ``AuthenticatorState`` 12 | public var authenticatorState: AuthenticatorState { 13 | get { 14 | self[AuthenticatorStateKey.self] 15 | } 16 | 17 | set { 18 | self[AuthenticatorStateKey.self] = newValue 19 | } 20 | } 21 | } 22 | 23 | extension EnvironmentValues { 24 | var authenticatorTheme: AuthenticatorTheme { 25 | get { 26 | self[AuthenticationThemeKey.self] 27 | } 28 | 29 | set { 30 | self[AuthenticationThemeKey.self] = newValue 31 | } 32 | } 33 | 34 | var authenticationService: AuthenticationService { 35 | get { 36 | self[AuthenticationServiceKey.self] 37 | } 38 | 39 | set { 40 | self[AuthenticationServiceKey.self] = newValue 41 | } 42 | } 43 | 44 | var authenticatorOptions: AuthenticatorOptions { 45 | get { 46 | self[AuthenticatorOptionsKey.self] 47 | } 48 | 49 | set { 50 | self[AuthenticatorOptionsKey.self] = newValue 51 | } 52 | } 53 | } 54 | 55 | private struct AuthenticatorStateKey: EnvironmentKey { 56 | static let defaultValue: AuthenticatorState = .init() 57 | } 58 | 59 | private struct AuthenticationThemeKey: EnvironmentKey { 60 | static let defaultValue: AuthenticatorTheme = AuthenticatorTheme() 61 | } 62 | 63 | private struct AuthenticationServiceKey: EnvironmentKey { 64 | static let defaultValue: AuthenticationService = .default 65 | } 66 | 67 | private struct AuthenticatorOptionsKey: EnvironmentKey { 68 | static let defaultValue: AuthenticatorOptions = .init() 69 | } 70 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/ContinueSignInWithTOTPSetupQRCodeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// Default QRCodeContent for the ``ContinueSignInWithTOTPSetupView``. It displays the view's QR Code 11 | public struct ContinueSignInWithTOTPSetupQRCodeView: View { 12 | 13 | @Environment(\.authenticatorTheme) private var theme 14 | @ObservedObject private var state: ContinueSignInWithTOTPSetupState 15 | 16 | public init(state: ContinueSignInWithTOTPSetupState) { 17 | self.state = state 18 | } 19 | 20 | public var body: some View { 21 | if let qrCodeImage = generateQRCode(qrCodeURIString: state.setupURI) { 22 | Image(decorative: qrCodeImage, scale: 1) 23 | .interpolation(.none) 24 | .resizable() 25 | .scaledToFit() 26 | .frame(width: theme.components.authenticator.qrCodeSize, 27 | height: theme.components.authenticator.qrCodeSize) 28 | } 29 | } 30 | 31 | private func generateQRCode(qrCodeURIString: String?) -> CGImage? { 32 | guard let qrCodeURIString = qrCodeURIString else { 33 | return nil 34 | } 35 | 36 | let filter = CIFilter.qrCodeGenerator() 37 | filter.message = Data(qrCodeURIString.utf8) 38 | guard let outputImage = filter.outputImage else { 39 | log.error("Unable to create a CI Image for TOTP Setup QRCode") 40 | return nil 41 | } 42 | guard let cgImage = CIContext().createCGImage(outputImage, from: outputImage.extent) else { 43 | log.error("Unable to create a CGImage from CIImage for TOTP Setup QRCode ") 44 | return nil 45 | } 46 | return cgImage 47 | } 48 | } 49 | 50 | extension ContinueSignInWithTOTPSetupQRCodeView: AuthenticatorLogging {} 51 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/ContinueSignInWithEmailMFASetupState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import AWSCognitoAuthPlugin 10 | import SwiftUI 11 | 12 | /// The state observed by the Continue Sign In with Email MFA Setup Challenge, representing the ``Authenticator`` is in the ``AuthenticatorStep/continueSignInWithEmailMFASetup`` step. 13 | public class ContinueSignInWithEmailMFASetupState: AuthenticatorBaseState { 14 | /// The email provided by the user 15 | @Published public var email: String = "" 16 | 17 | override init(credentials: Credentials) { 18 | super.init(credentials: credentials) 19 | } 20 | 21 | init(authenticatorState: AuthenticatorStateProtocol) { 22 | super.init(authenticatorState: authenticatorState, 23 | credentials: Credentials()) 24 | } 25 | 26 | /// Attempts to continue user's sign by providing email. 27 | /// 28 | /// Automatically sets the Authenticator's next step accordingly, as well as the 29 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 30 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 31 | public func continueSignIn() async throws { 32 | setBusy(true) 33 | 34 | do { 35 | log.verbose("Attempting to continue Sign In with Email setup") 36 | let result = try await authenticationService.confirmSignIn( 37 | challengeResponse: email, 38 | options: nil 39 | ) 40 | let nextStep = try await nextStep(for: result) 41 | 42 | setBusy(false) 43 | 44 | authenticatorState.setCurrentStep(nextStep) 45 | } catch { 46 | log.error("Continue Sign In with Email MFA Setup failed") 47 | setBusy(false) 48 | let authenticationError = self.error(for: error) 49 | setMessage(authenticationError) 50 | throw authenticationError 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/ConfirmSignInWithNewPasswordState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import AWSCognitoAuthPlugin 10 | import SwiftUI 11 | 12 | /// The state observed by the Confirm Sign In with New Password content view, representing the ``Authenticator`` is in the ``AuthenticatorStep/confirmSignInWithNewPassword`` step. 13 | public class ConfirmSignInWithNewPasswordState: AuthenticatorBaseState { 14 | /// The new password provided by the user 15 | @Published public var newPassword: String = "" 16 | 17 | /// The new password confirmation provided by the user 18 | @Published public var confirmPassword: String = "" 19 | 20 | /// Attempts to confirm the user's Sign In with the provided new password 21 | /// 22 | /// Automatically sets the Authenticator's next step accordingly, as well as the 23 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 24 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 25 | public func confirmSignIn() async throws { 26 | setBusy(true) 27 | 28 | do { 29 | log.verbose("Attempting to confirm Sign In") 30 | let result = try await authenticationService.confirmSignIn( 31 | challengeResponse: newPassword, 32 | options: nil 33 | ) 34 | 35 | let nextStep = try await nextStep(for: result) 36 | 37 | setBusy(false) 38 | 39 | authenticatorState.setCurrentStep(nextStep) 40 | } catch { 41 | log.error("Confirm Sign In with new password failed") 42 | setBusy(false) 43 | let authenticationError = self.error(for: error) 44 | setMessage(authenticationError) 45 | throw authenticationError 46 | } 47 | } 48 | } 49 | 50 | extension ConfirmSignInWithNewPasswordState { 51 | enum Field: Int, Hashable, CaseIterable { 52 | case newPassword 53 | case newPasswordConfirmation 54 | } 55 | } 56 | 57 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/Primitives/ImageButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This is a convenient way of having a Button display a single known image 11 | struct ImageButton: View { 12 | private let image: Image 13 | private let action: () -> () 14 | private var color: Color? 15 | 16 | init( 17 | _ image: Image, 18 | _ action: @escaping () ->() 19 | ) { 20 | self.image = image 21 | self.action = action 22 | } 23 | 24 | var body: some View { 25 | SwiftUI.Button( 26 | action: { 27 | action() 28 | }, 29 | label: { 30 | SwiftUI.Image(systemName: image.rawValue) 31 | } 32 | ) 33 | .buttonStyle(.plain) 34 | .foregroundColor(color) 35 | .accessibilityLabel( 36 | Text(accessibilityLabel) 37 | ) 38 | } 39 | 40 | func tintColor(_ color: Color?) -> Self { 41 | var view = self 42 | view.color = color 43 | return view 44 | } 45 | 46 | private var accessibilityLabel: String { 47 | switch image { 48 | case .close: 49 | return "authenticator.imageButton.close".localized() 50 | case .clear: 51 | return "authenticator.imageButton.clear".localized() 52 | case .open: 53 | return "authenticator.imageButton.open".localized() 54 | case .showPassword: 55 | return "authenticator.imageButton.showPassword".localized() 56 | case .hidePassword: 57 | return "authenticator.imageButton.hidePassword".localized() 58 | case .calendar: 59 | return "authenticator.imageButton.calendar".localized() 60 | } 61 | } 62 | } 63 | 64 | extension ImageButton { 65 | enum Image: String { 66 | case close = "x.circle.fill" 67 | case clear = "xmark.circle.fill" 68 | case open = "chevron.down.circle" 69 | case showPassword = "eye.fill" 70 | case hidePassword = "eye.slash.fill" 71 | case calendar = "calendar" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostApp/Utils/AuthCategoryConfigurationFactory.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | @testable import Authenticator 10 | @_spi(InternalAmplifyConfiguration) 11 | @testable import AWSCognitoAuthPlugin 12 | import Foundation 13 | 14 | class AuthCategoryConfigurationFactory { 15 | static var shared = AuthCategoryConfigurationFactory() 16 | 17 | private var usernameAttributes: [JSONValue] = [] 18 | private var signupAttributes: [JSONValue] = [] 19 | private var verificationMechanisms: [JSONValue] = [ 20 | .string("EMAIL") 21 | ] 22 | 23 | func createConfiguration() -> AuthCategoryConfiguration { 24 | return AuthCategoryConfiguration(plugins: [ 25 | "awsCognitoAuthPlugin": [ 26 | "CognitoUserPool": [ 27 | "Default": [ 28 | "PoolId": "PoolId", 29 | "AppClientId": "AppClientId", 30 | "Region": "us-east-1" 31 | ] 32 | ], 33 | "CredentialsProvider": [ 34 | "CognitoIdentity": [ 35 | "Default": [ 36 | "PoolId": "PoolId", 37 | "Region": "us-east-1" 38 | ] 39 | ] 40 | ], 41 | "Auth": [ 42 | "Default": [ 43 | "usernameAttributes": .array(usernameAttributes), 44 | "signupAttributes": .array(signupAttributes), 45 | "verificationMechanisms": .array(verificationMechanisms), 46 | "passwordProtectionSettings": [ 47 | "passwordPolicyMinLength": 8, 48 | "passwordPolicyCharacters": [] 49 | ] 50 | ] 51 | ] 52 | ] 53 | ]) 54 | } 55 | 56 | func setUserAtributes(_ userAttributesArg: [UserAttribute]) { 57 | usernameAttributes = userAttributesArg.map({ .string($0.rawValue) }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/issue_comment.yml: -------------------------------------------------------------------------------- 1 | name: Issue Comment 2 | 3 | on: 4 | issue_comment: 5 | types: [created] 6 | 7 | jobs: 8 | notify: 9 | runs-on: ubuntu-latest 10 | permissions: {} 11 | if: ${{ !github.event.issue.pull_request && !contains(fromJSON('["MEMBER", "OWNER"]'), github.event.comment.author_association) }} 12 | steps: 13 | - name: Run webhook curl command 14 | env: 15 | WEBHOOK_URL: ${{ secrets.SLACK_COMMENT_WEBHOOK_URL }} 16 | COMMENT: ${{toJson(github.event.comment.body)}} 17 | USER: ${{github.event.comment.user.login}} 18 | COMMENT_URL: ${{github.event.comment.html_url}} 19 | shell: bash 20 | run: echo $COMMENT | sed "s/\\\n/. /g; s/\\\r//g; s/[^a-zA-Z0-9 &().,:]//g" | xargs -I {} curl -s POST "$WEBHOOK_URL" -H "Content-Type:application/json" --data '{"comment":"{}", "commentUrl":"'$COMMENT_URL'", "user":"'$USER'"}' 21 | 22 | adjust-labels: 23 | runs-on: ubuntu-latest 24 | if: ${{ github.event.issue.state == 'open' }} 25 | permissions: 26 | issues: write 27 | env: 28 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 29 | ISSUE_NUMBER: ${{ github.event.issue.number }} 30 | REPOSITORY_NAME: ${{ github.event.repository.full_name }} 31 | steps: 32 | - name: remove pending-community-response when new comment received 33 | if: ${{ !contains(fromJSON('["MEMBER", "OWNER"]'), github.event.comment.author_association) && !github.event.issue.pull_request }} 34 | shell: bash 35 | run: | 36 | gh issue edit $ISSUE_NUMBER --repo $REPOSITORY_NAME --remove-label "pending-community-response" 37 | - name: add pending-maintainer-response when new community comment received 38 | if: ${{ !contains(fromJSON('["MEMBER", "OWNER"]'), github.event.comment.author_association) }} 39 | shell: bash 40 | run: | 41 | gh issue edit $ISSUE_NUMBER --repo $REPOSITORY_NAME --add-label "pending-maintainer-response" 42 | - name: remove pending-maintainer-response when new owner/member comment received 43 | if: ${{ contains(fromJSON('["MEMBER", "OWNER"]'), github.event.comment.author_association) }} 44 | shell: bash 45 | run: | 46 | gh issue edit $ISSUE_NUMBER --repo $REPOSITORY_NAME --remove-label "pending-maintainer-response" -------------------------------------------------------------------------------- /Sources/Authenticator/States/ContinueSignInWithMFASelectionState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import AWSCognitoAuthPlugin 10 | import SwiftUI 11 | 12 | /// The state observed by the Continue Sign In With MFA Selection content views, representing the ``Authenticator`` is in ``AuthenticatorStep/continueSignInWithMFASelection`` step. 13 | public class ContinueSignInWithMFASelectionState: AuthenticatorBaseState { 14 | 15 | /// The confirmation code provided by the user 16 | @Published public var selectedMFAType: MFAType? 17 | 18 | init(authenticatorState: AuthenticatorStateProtocol, 19 | allowedMFATypes: AllowedMFATypes) { 20 | self.allowedMFATypes = allowedMFATypes 21 | super.init(authenticatorState: authenticatorState, 22 | credentials: Credentials()) 23 | } 24 | 25 | /// The `Amplify.AllowedMFATypes` associated with this state. 26 | public let allowedMFATypes: AllowedMFATypes 27 | 28 | /// Attempts to continue the user's sign in using the provided confirmation code. 29 | /// 30 | /// Automatically sets the Authenticator's next step accordingly, as well as the 31 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 32 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 33 | public func continueSignIn() async throws { 34 | guard let selectedMFAType = selectedMFAType else { 35 | log.error("MFA type not selected") 36 | return 37 | } 38 | 39 | setBusy(true) 40 | do { 41 | log.verbose("Attempting to confirm Sign In with Code") 42 | let result = try await authenticationService.confirmSignIn( 43 | challengeResponse: selectedMFAType.challengeResponse, 44 | options: nil 45 | ) 46 | let nextStep = try await nextStep(for: result) 47 | 48 | setBusy(false) 49 | 50 | authenticatorState.setCurrentStep(nextStep) 51 | } catch { 52 | log.error("Confirm Sign In with Code failed") 53 | setBusy(false) 54 | let authenticationError = self.error(for: error) 55 | setMessage(authenticationError) 56 | throw authenticationError 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/ResetPasswordState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | /// The state observed by the Reset Password content view, representing the ``Authenticator`` is in the ``AuthenticatorStep/resetPassword`` step. 12 | public class ResetPasswordState: AuthenticatorBaseState { 13 | /// The username provided by the user 14 | @Published public var username: String = "" { 15 | didSet { 16 | credentials.username = username 17 | } 18 | } 19 | 20 | /// Attempts to request a password reset for the provided username. 21 | /// 22 | /// Automatically sets the Authenticator's next step accordingly, as well as the 23 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 24 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 25 | public func resetPassword() async throws { 26 | setBusy(true) 27 | do { 28 | log.verbose("Attempting to reset password") 29 | let result = try await authenticationService.resetPassword( 30 | for: username, 31 | options: nil 32 | ) 33 | setBusy(false) 34 | 35 | switch result.nextStep { 36 | case .confirmResetPasswordWithCode(let details, _): 37 | authenticatorState.setCurrentStep(.confirmResetPassword(deliveryDetails: details)) 38 | case .done: 39 | // This should not happen, go back to Sign In screen 40 | log.warn("Received done next step after initiating a reset password. This is unexpected") 41 | authenticatorState.setCurrentStep(.signIn) 42 | } 43 | } catch { 44 | log.error("Unable to initialize a password reset") 45 | setBusy(false) 46 | let authenticationError = self.error(for: error) 47 | setMessage(authenticationError) 48 | throw authenticationError 49 | } 50 | } 51 | 52 | /// Manually moves the Authenticator to a different initial step 53 | /// - Parameter initialStep: The desired ``AuthenticatorInitialStep`` 54 | public func move(to initialStep: AuthenticatorInitialStep) { 55 | authenticatorState.move(to: initialStep) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/issue_opened.yml: -------------------------------------------------------------------------------- 1 | name: Issue Opened 2 | on: 3 | issues: 4 | types: [opened] 5 | 6 | jobs: 7 | notify: 8 | runs-on: ubuntu-latest 9 | permissions: {} 10 | if: ${{ !contains(fromJSON('["MEMBER", "OWNER"]'), github.event.issue.author_association) }} 11 | steps: 12 | - name: Run webhook curl command 13 | env: 14 | WEBHOOK_URL: ${{ secrets.SLACK_ISSUE_WEBHOOK_URL }} 15 | ISSUE: ${{toJson(github.event.issue.title)}} 16 | ISSUE_URL: ${{github.event.issue.html_url}} 17 | USER: ${{github.event.issue.user.login}} 18 | shell: bash 19 | run: echo $ISSUE | sed 's/[^a-zA-Z0-9 &().,:]//g' | xargs -I {} curl -s POST "$WEBHOOK_URL" -H "Content-Type:application/json" --data '{"issue":"{}", "issueUrl":"'$ISSUE_URL'", "user":"'$USER'"}' 20 | 21 | add-issue-opened-labels: 22 | runs-on: ubuntu-latest 23 | permissions: 24 | issues: write 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | ISSUE_NUMBER: ${{ github.event.issue.number }} 28 | REPOSITORY_NAME: ${{ github.event.repository.full_name }} 29 | steps: 30 | - name: Add the pending-triage label 31 | shell: bash 32 | run: | 33 | gh issue edit $ISSUE_NUMBER --repo $REPOSITORY_NAME --add-label "pending-triage" 34 | - name: Add the pending-maintainer-response label 35 | if: ${{ !contains(fromJSON('["MEMBER", "OWNER"]'), github.event.issue.author_association) }} 36 | shell: bash 37 | run: | 38 | gh issue edit $ISSUE_NUMBER --repo $REPOSITORY_NAME --add-label "pending-maintainer-response" 39 | 40 | maintainer-opened: 41 | runs-on: ubuntu-latest 42 | permissions: 43 | issues: write 44 | if: ${{ contains(fromJSON('["MEMBER", "OWNER"]'), github.event.issue.author_association) }} 45 | steps: 46 | - name: Post comment if maintainer opened. 47 | shell: bash 48 | env: 49 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 50 | ISSUE_NUMBER: ${{ github.event.issue.number }} 51 | REPOSITORY_NAME: ${{ github.event.repository.full_name }} 52 | run: | 53 | gh issue comment $ISSUE_NUMBER --repo $REPOSITORY_NAME -b "This issue was opened by a maintainer of this repository; updates will be posted here. If you are also experiencing this issue, please comment here with any relevant information so that we're aware and can prioritize accordingly." 54 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/ConfirmSignInWithCustomChallengeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | /// Represents the content being displayed when the ``Authenticator`` is in the ``AuthenticatorStep/confirmSignInWithCustomChallenge`` step. 12 | public struct ConfirmSignInWithCustomChallengeView: View { 14 | @ObservedObject private var state: ConfirmSignInWithCodeState 15 | private let content: ConfirmSignInWithCodeView 16 | 17 | /// Creates a `ConfirmSignInWithCustomChallengeView` 18 | /// - Parameter state: The ``ConfirmSignInWithCodeState`` that is observed by this view 19 | /// - Parameter headerContent: The content displayed above the fields. Defaults to ``ConfirmSignInWithCustomChallengeHeader`` 20 | /// - Parameter footerContent: The content displayed bellow the fields. Defaults to `SwiftUI.EmptyView` 21 | public init( 22 | state: ConfirmSignInWithCodeState, 23 | @ViewBuilder headerContent: () -> Header = { 24 | ConfirmSignInWithCustomChallengeHeader() 25 | }, 26 | @ViewBuilder footerContent: () -> Footer = { 27 | EmptyView() 28 | } 29 | ) { 30 | self.state = state 31 | self.content = ConfirmSignInWithCodeView( 32 | state: state, 33 | headerContent: headerContent, 34 | footerContent: footerContent 35 | ) 36 | } 37 | 38 | public var body: some View { 39 | content 40 | } 41 | 42 | /// Sets a custom error mapping function for the `AuthError`s that are displayed 43 | /// - Parameter errorTransform: A closure that takes an `AuthError` and returns a ``AuthenticatorError`` that will be displayed. 44 | public func errorMap(_ errorTransform: @escaping (AuthError) -> AuthenticatorError?) -> Self { 45 | state.errorTransform = errorTransform 46 | return self 47 | } 48 | } 49 | 50 | /// Default header for the ``ConfirmSignInWithCustomChallengeView``. It displays the view's title 51 | public struct ConfirmSignInWithCustomChallengeHeader: View { 52 | public init() {} 53 | public var body: some View { 54 | DefaultHeader( 55 | title: "authenticator.confirmSignInWithCustomChallenge.title".localized() 56 | ) 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/ContinueSignInWithMFASetupSelectionState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import AWSCognitoAuthPlugin 10 | import SwiftUI 11 | 12 | /// The state observed by the Continue Sign In With MFA Setup Selection content views, representing the ``Authenticator`` is in the ``AuthenticatorStep/continueSignInWithMFASetupSelection`` step. 13 | public class ContinueSignInWithMFASetupSelectionState: AuthenticatorBaseState { 14 | 15 | /// The MFA type selected by the user 16 | @Published public var selectedMFATypeToSetup: MFAType? 17 | 18 | init(authenticatorState: AuthenticatorStateProtocol, 19 | allowedMFATypes: AllowedMFATypes) { 20 | self.allowedMFATypes = allowedMFATypes 21 | super.init(authenticatorState: authenticatorState, 22 | credentials: Credentials()) 23 | } 24 | 25 | /// The `Amplify.AllowedMFATypes` associated with this state. 26 | public let allowedMFATypes: AllowedMFATypes 27 | 28 | /// Attempts to continue the user's sign in using the provided MFA type to setup. 29 | /// 30 | /// Automatically sets the Authenticator's next step accordingly, as well as the 31 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 32 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 33 | public func continueSignIn() async throws { 34 | guard let selectedMFATypeToSetup = selectedMFATypeToSetup else { 35 | log.error("MFA type not selected") 36 | return 37 | } 38 | 39 | setBusy(true) 40 | do { 41 | log.verbose("Attempting to continue Sign In with selected MFA type to setup") 42 | let result = try await authenticationService.confirmSignIn( 43 | challengeResponse: selectedMFATypeToSetup.challengeResponse, 44 | options: nil 45 | ) 46 | let nextStep = try await nextStep(for: result) 47 | 48 | setBusy(false) 49 | 50 | authenticatorState.setCurrentStep(nextStep) 51 | } catch { 52 | log.error("Continue Sign In with MFA Setup Selection failed") 53 | setBusy(false) 54 | let authenticationError = self.error(for: error) 55 | setMessage(authenticationError) 56 | throw authenticationError 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/TestCases/PasskeyPromptTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import XCTest 9 | 10 | final class PasskeyPromptTests: AuthenticatorBaseTestCase { 11 | 12 | func testSignInPasskeyPrompt() throws { 13 | launchAppAndLogin(with: [ 14 | .hidesSignUpButton(false), 15 | .initialStep(.signIn), 16 | .authSignInStep(.done) 17 | ]) 18 | assertSnapshot() 19 | } 20 | 21 | func testSignUpPasskeyPrompt() throws { 22 | 23 | let app = XCUIApplication() 24 | 25 | launchApp(with: [ 26 | .hidesSignUpButton(false), 27 | .initialStep(.signUp), 28 | .authSignInStep(.done), 29 | .passwordlessFlow(true) 30 | ]) 31 | 32 | // Enter some username 33 | app.textFields.firstMatch.tap() 34 | app.textFields.firstMatch.typeText("username") 35 | 36 | // Enter some username 37 | app.textFields["Enter your email"].tap() 38 | app.textFields["Enter your email"].typeText("username@username.com") 39 | 40 | // Tap Sign in button 41 | app.buttons["Create account"].firstMatch.tap() 42 | 43 | // Wait for Sign In view to disappear 44 | let expectation = expectation( 45 | for: .init(format: "exists == false"), 46 | evaluatedWith: app.staticTexts["Create account"]) 47 | let result = XCTWaiter.wait(for: [expectation], timeout: 5.0) 48 | XCTAssertEqual(result, .completed) 49 | 50 | assertSnapshot() 51 | } 52 | 53 | func testSignInPasskeyCreated() throws { 54 | launchAppAndLogin(with: [ 55 | .hidesSignUpButton(false), 56 | .initialStep(.signIn), 57 | .authSignInStep(.done) 58 | ]) 59 | 60 | let app = XCUIApplication() 61 | // Tap Sign in button 62 | app.buttons["Create a Passkey"].firstMatch.tap() 63 | 64 | // Wait for Sign In view to disappear 65 | let expectation = expectation( 66 | for: .init(format: "exists == false"), 67 | evaluatedWith: app.staticTexts["Create a Passkey"]) 68 | let result = XCTWaiter.wait(for: [expectation], timeout: 5.0) 69 | XCTAssertEqual(result, .completed) 70 | 71 | assertSnapshot() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Authenticator/Models/AuthenticatorMessage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// A message that is displayed in the Authenticator 11 | public protocol AuthenticatorMessage { 12 | /// The message to display 13 | var content: String { get } 14 | /// The style in which this message is displayed 15 | var style: AuthenticatorMessageStyle { get } 16 | } 17 | 18 | /// The style used to display an ``AuthenticatorMessage`` 19 | public struct AuthenticatorMessageStyle: Equatable { 20 | private enum InternalStyle { 21 | case info 22 | case error 23 | } 24 | private var type: InternalStyle 25 | 26 | private init(type: InternalStyle) { 27 | self.type = type 28 | } 29 | 30 | /// Used to display Information messages 31 | public static let info: AuthenticatorMessageStyle = .init(type: .info) 32 | 33 | /// Used to display error messages 34 | public static let error: AuthenticatorMessageStyle = .init(type: .error) 35 | } 36 | 37 | extension AuthenticatorMessage where Self == AuthenticatorInformation { 38 | /// A simple info message 39 | /// - Parameter message: The message that will be displayed 40 | public static func info(message: String) -> Self { 41 | return AuthenticatorInformation(content: message) 42 | } 43 | } 44 | 45 | extension AuthenticatorMessage where Self == AuthenticatorError { 46 | /// A simple error message 47 | /// - Parameter message: The message that will be displayed 48 | public static func error(message: String) -> Self { 49 | return AuthenticatorError(content: message) 50 | } 51 | } 52 | 53 | /// Represent a message that displays normal information 54 | public struct AuthenticatorInformation: AuthenticatorMessage { 55 | public let content: String 56 | public let style: AuthenticatorMessageStyle = .info 57 | } 58 | 59 | /// Represent a message that displays an error 60 | public struct AuthenticatorError: LocalizedError, AuthenticatorMessage { 61 | public let content: String 62 | public let style: AuthenticatorMessageStyle = .error 63 | 64 | /// An unknown error. 65 | public static func unknown(from error: Error) -> Self { 66 | log.verbose("Creating an unknown AuthenticatorError") 67 | log.verbose(error) 68 | 69 | return AuthenticatorError( 70 | content: "authenticator.unknownError".localized() 71 | ) 72 | } 73 | } 74 | 75 | extension AuthenticatorError: AuthenticatorLogging {} 76 | -------------------------------------------------------------------------------- /Sources/Authenticator/Utilities/KeyboardIterableFields.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | protocol KeyboardIterableFields: AuthenticatorLogging { 11 | associatedtype Field: Hashable 12 | var focusedField: FocusState {set get} 13 | 14 | func focusPreviousField() 15 | 16 | func focusNextField() 17 | 18 | var hasPreviousField: Bool { get } 19 | 20 | var hasNextField: Bool { get } 21 | } 22 | 23 | extension KeyboardIterableFields where Field: RawRepresentable, Field: CaseIterable { 24 | private var currentIndex: Int? { 25 | return focusedField.wrappedValue?.rawValue 26 | } 27 | 28 | func focusPreviousField() { 29 | guard let currentIndex = currentIndex else { return } 30 | focusedField.wrappedValue = .init(rawValue: currentIndex - 1) 31 | } 32 | 33 | func focusNextField() { 34 | guard let currentIndex = currentIndex else { return } 35 | focusedField.wrappedValue = .init(rawValue: currentIndex + 1) 36 | } 37 | 38 | var hasPreviousField: Bool { 39 | guard let currentIndex = currentIndex else { return false } 40 | return currentIndex - 1 >= 0 41 | } 42 | 43 | var hasNextField: Bool { 44 | guard let currentIndex = currentIndex else { return false } 45 | return currentIndex + 1 < Field.allCases.count 46 | } 47 | } 48 | 49 | extension View { 50 | func keyboardIterableToolbar(fields: F) -> some View { 51 | self.modifier(KeyboardIterableToolbar(fields: fields)) 52 | } 53 | } 54 | 55 | private struct KeyboardIterableToolbar: ViewModifier where V: KeyboardIterableFields { 56 | let fields: V 57 | 58 | func body(content: Content) -> some View { 59 | content 60 | .toolbar { 61 | SwiftUI.ToolbarItemGroup(placement: .keyboard) { 62 | SwiftUI.Button(action: fields.focusPreviousField) { 63 | Image(systemName: "chevron.up") 64 | } 65 | .disabled(!fields.hasPreviousField) 66 | 67 | SwiftUI.Button(action: fields.focusNextField) { 68 | Image(systemName: "chevron.down") 69 | } 70 | .disabled(!fields.hasNextField) 71 | 72 | Spacer() 73 | 74 | SwiftUI.Button("authenticator.keyboardToolbar.Done".localized()) { 75 | fields.focusedField.wrappedValue = nil 76 | } 77 | } 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/SignInConfirmPasswordState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | /// The state observed by the Sign In Confirm Password content view, representing the ``Authenticator`` is in the ``AuthenticatorStep/signInConfirmPassword`` step. 12 | public class SignInConfirmPasswordState: AuthenticatorBaseState { 13 | /// The password provided by the user 14 | @Published public var password: String = "" { 15 | didSet { 16 | credentials.password = password 17 | } 18 | } 19 | 20 | /// The username for this sign-in attempt 21 | public var username: String { 22 | return credentials.username 23 | } 24 | 25 | override init(credentials: Credentials) { 26 | super.init(credentials: credentials) 27 | } 28 | 29 | init(authenticatorState: AuthenticatorStateProtocol) { 30 | super.init(authenticatorState: authenticatorState, 31 | credentials: Credentials()) 32 | } 33 | 34 | /// Attempts to confirm the password and complete sign-in 35 | /// 36 | /// Automatically sets the Authenticator's next step accordingly, as well as the 37 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 38 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 39 | public func confirmPassword() async throws { 40 | setBusy(true) 41 | 42 | do { 43 | log.verbose("Attempting to confirm Sign In with Password") 44 | let result = try await authenticationService.confirmSignIn( 45 | challengeResponse: password, 46 | options: nil 47 | ) 48 | let nextStep = try await nextStep(for: result) 49 | 50 | setBusy(false) 51 | 52 | authenticatorState.setCurrentStep(nextStep) 53 | } catch { 54 | log.error("Confirm Sign In with Password failed") 55 | setBusy(false) 56 | let authenticationError = self.error(for: error) 57 | setMessage(authenticationError) 58 | throw authenticationError 59 | } 60 | } 61 | 62 | /// Manually moves the Authenticator to a different initial step 63 | /// - Parameter initialStep: The desired ``AuthenticatorInitialStep`` 64 | public func move(to initialStep: AuthenticatorInitialStep) { 65 | authenticatorState.move(to: initialStep) 66 | } 67 | } 68 | 69 | extension SignInConfirmPasswordState { 70 | enum Field: Int, Hashable, CaseIterable { 71 | case password 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/ConfirmSignInWithCodeState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import AWSCognitoAuthPlugin 10 | import SwiftUI 11 | 12 | /// The state observed by the Confirm Sign In with Custom Challenge and Confirm Sign In with MFA Code content views, representing the ``Authenticator`` is in either the ``AuthenticatorStep/confirmSignInWithCustomChallenge`` or the ``AuthenticatorStep/confirmSignInWithMFACode`` step accordingly. 13 | public class ConfirmSignInWithCodeState: AuthenticatorBaseState { 14 | /// The confirmation code provided by the user 15 | @Published public var confirmationCode: String = "" 16 | 17 | override init(credentials: Credentials) { 18 | super.init(credentials: credentials) 19 | } 20 | 21 | init(authenticatorState: AuthenticatorStateProtocol) { 22 | super.init(authenticatorState: authenticatorState, 23 | credentials: Credentials()) 24 | } 25 | 26 | /// The `Amplify.AuthCodeDeliveryDetails` associated with this state. If the Authenticator is not in the `.confirmSignInWithMFACode` or `confirmSignInWithOTP` step, it returns `nil` 27 | public var deliveryDetails: AuthCodeDeliveryDetails? { 28 | switch authenticatorState.step { 29 | case .confirmSignInWithMFACode(let deliveryDetails), 30 | .confirmSignInWithOTP(let deliveryDetails): 31 | return deliveryDetails 32 | default: 33 | return nil 34 | } 35 | } 36 | 37 | /// Attempts to confirm the user's sign in using the provided confirmation code. 38 | /// 39 | /// Automatically sets the Authenticator's next step accordingly, as well as the 40 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 41 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 42 | public func confirmSignIn() async throws { 43 | setBusy(true) 44 | 45 | do { 46 | log.verbose("Attempting to confirm Sign In with Code") 47 | let result = try await authenticationService.confirmSignIn( 48 | challengeResponse: confirmationCode, 49 | options: nil 50 | ) 51 | let nextStep = try await nextStep(for: result) 52 | 53 | setBusy(false) 54 | 55 | authenticatorState.setCurrentStep(nextStep) 56 | } catch { 57 | log.error("Confirm Sign In with Code failed") 58 | setBusy(false) 59 | let authenticationError = self.error(for: error) 60 | setMessage(authenticationError) 61 | throw authenticationError 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostAppUITests/AuthenticatorBaseTestCase.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // AuthenticatorHostAppUITests 4 | // 5 | // Created by Singh, Harshdeep on 2023-09-21. 6 | // 7 | 8 | import XCTest 9 | 10 | class AuthenticatorBaseTestCase: XCTestCase { 11 | 12 | override func setUpWithError() throws { 13 | continueAfterFailure = false 14 | } 15 | 16 | override func tearDownWithError() throws { 17 | XCUIApplication().terminate() 18 | } 19 | 20 | func assertSnapshot( 21 | named name: String? = nil, 22 | snapshotDirectory: String? = nil, 23 | timeout: TimeInterval = 5, 24 | file: StaticString = #file, 25 | testName: String = #function, 26 | line: UInt = #line 27 | ) { 28 | let result = Snapshotter.captureAndVerifySnapshot( 29 | for: XCUIApplication().screenshot().image, 30 | named: name, 31 | snapshotDirectory: snapshotDirectory, 32 | timeout: timeout, 33 | file: file, 34 | testName: testName, 35 | line: line) 36 | 37 | // Add the attachments to the test case 38 | result.attachments.forEach( { add($0) }) 39 | 40 | XCTAssertTrue( 41 | result.didSucceed, 42 | "Snapshot Assertion failed for test. Description:\n\n\(result.message ?? "No description")") 43 | } 44 | 45 | func launchApp(with args: [ProcessArgument]) { 46 | // Launch Application 47 | let app = XCUIApplication() 48 | 49 | if let encodedData = try? JSONEncoder().encode(args), 50 | let stringJSON = String(data: encodedData, encoding: .utf8) { 51 | app.launchArguments = [ 52 | UITestKeyKey, stringJSON, 53 | ] 54 | } else { 55 | print("Unable to encode process args") 56 | } 57 | 58 | app.launch() 59 | } 60 | 61 | func launchAppAndLogin(with args: [ProcessArgument], shouldEnterPassword: Bool = true) { 62 | 63 | // Launch Application 64 | launchApp(with: args) 65 | // Get app instance 66 | let app = XCUIApplication() 67 | 68 | // Enter some username 69 | app.textFields.firstMatch.tap() 70 | app.textFields.firstMatch.typeText("username") 71 | 72 | if shouldEnterPassword { 73 | // Enter some password 74 | app.secureTextFields.firstMatch.tap() 75 | app.secureTextFields.firstMatch.typeText("password") 76 | } 77 | 78 | // Tap Sign in button 79 | app.buttons["Sign In"].firstMatch.tap() 80 | 81 | // Wait for Sign In view to disappear 82 | let expectation = expectation( 83 | for: .init(format: "exists == false"), 84 | evaluatedWith: app.staticTexts["Sign In"]) 85 | let result = XCTWaiter.wait(for: [expectation], timeout: 5.0) 86 | XCTAssertEqual(result, .completed) 87 | } 88 | 89 | } 90 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/PasskeyCreatedState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | /// The state observed by the Passkey Created content view, representing the ``Authenticator`` is in the ``AuthenticatorStep/passkeyCreated`` step. 12 | public class PasskeyCreatedState: AuthenticatorBaseState { 13 | 14 | /// The list of WebAuthn credentials (passkeys) for the user 15 | @Published public var passkeyCredentials: [AuthWebAuthnCredential] = [] 16 | 17 | override init(credentials: Credentials) { 18 | super.init(credentials: credentials) 19 | } 20 | 21 | init(authenticatorState: AuthenticatorStateProtocol) { 22 | super.init(authenticatorState: authenticatorState, 23 | credentials: Credentials()) 24 | } 25 | 26 | /// Fetches the list of passkey credentials for the user 27 | public func fetchPasskeyCredentials() async { 28 | do { 29 | log.verbose("Fetching passkey credentials") 30 | 31 | if #available(iOS 17.4, macOS 13.5, visionOS 1.0, *) { 32 | let result = try await authenticationService.listWebAuthnCredentials(options: nil) 33 | 34 | await MainActor.run { 35 | self.passkeyCredentials = result.credentials 36 | } 37 | log.verbose("Fetched \(result.credentials.count) passkey credentials") 38 | } else { 39 | log.error("WebAuthn is not supported on this platform (requires iOS 17.4+, macOS 13.5+, or visionOS 1.0+)") 40 | } 41 | } catch { 42 | log.error("Failed to fetch passkey credentials: \(error)") 43 | // Don't throw - just log the error, credentials list will remain empty 44 | } 45 | } 46 | 47 | /// Continues the authentication flow after passkey creation 48 | /// 49 | /// Automatically sets the Authenticator's next step accordingly, as well as the 50 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 51 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 52 | public func `continue`() async throws { 53 | setBusy(true) 54 | 55 | do { 56 | log.verbose("Continuing after passkey creation") 57 | // Use post-passkey logic (attribute verification and sign-in) 58 | let nextStep = try await nextStepAfterPasskeyFlow() 59 | 60 | setBusy(false) 61 | authenticatorState.setCurrentStep(nextStep) 62 | } catch { 63 | log.error("Continue after passkey creation failed") 64 | setBusy(false) 65 | let authenticationError = self.error(for: error) 66 | setMessage(authenticationError) 67 | throw authenticationError 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/ConfirmSignInWithTOTPCodeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | /// Represents the content being displayed when the ``Authenticator`` is in the ``AuthenticatorStep/confirmSignInWithTOTPCode`` step. 12 | public struct ConfirmSignInWithTOTPView: View { 14 | @Environment(\.authenticatorState) private var authenticatorState 15 | @ObservedObject private var state: ConfirmSignInWithCodeState 16 | private let content: ConfirmSignInWithCodeView 17 | 18 | /// Creates a `ConfirmSignInWithTOTPView` 19 | /// - Parameter state: The ``ConfirmSignInWithCodeState`` that is observed by this view 20 | /// - Parameter headerContent: The content displayed above the fields. Defaults to ``ConfirmSignInWithTOTPHeader`` 21 | /// - Parameter footerContent: The content displayed bellow the fields. Defaults to ``ConfirmSignInWithTOTPFooter`` 22 | public init( 23 | state: ConfirmSignInWithCodeState, 24 | @ViewBuilder headerContent: () -> Header = { 25 | ConfirmSignInWithTOTPHeader() 26 | }, 27 | @ViewBuilder footerContent: () -> Footer = { 28 | ConfirmSignInWithTOTPFooter() 29 | } 30 | ) { 31 | self.state = state 32 | self.content = ConfirmSignInWithCodeView( 33 | state: state, 34 | headerContent: headerContent, 35 | footerContent: footerContent 36 | ) 37 | } 38 | 39 | public var body: some View { 40 | content 41 | } 42 | 43 | /// Sets a custom error mapping function for the `AuthError`s that are displayed 44 | /// - Parameter errorTransform: A closure that takes an `AuthError` and returns a ``AuthenticatorError`` that will be displayed. 45 | public func errorMap(_ errorTransform: @escaping (AuthError) -> AuthenticatorError?) -> Self { 46 | state.errorTransform = errorTransform 47 | return self 48 | } 49 | } 50 | 51 | /// Default header for the ``ConfirmSignInWithTOTPCodeView``. It displays the view's title 52 | public struct ConfirmSignInWithTOTPHeader: View { 53 | public init() {} 54 | public var body: some View { 55 | DefaultHeader( 56 | title: "authenticator.confirmSignInWithCode.totp.title".localized() 57 | ) 58 | } 59 | } 60 | 61 | /// Default footer for the ``ConfirmSignInWithTOTPCodeView``. It displays the "Back to Sign In" button 62 | public struct ConfirmSignInWithTOTPFooter: View { 63 | @Environment(\.authenticatorState) private var authenticatorState 64 | 65 | public init() {} 66 | public var body: some View { 67 | Button("authenticator.confirmSignInWithCode.totp.button.backToSignIn".localized()) { 68 | authenticatorState.move(to: .signIn) 69 | } 70 | .buttonStyle(.link) 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/Authenticator.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 34 | 40 | 41 | 42 | 43 | 44 | 54 | 55 | 61 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Tests/AuthenticatorTests/States/ResetPasswordStateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | @testable import Authenticator 10 | import XCTest 11 | 12 | class ResetPasswordStateTests: XCTestCase { 13 | private var state: ResetPasswordState! 14 | private var authenticatorState: MockAuthenticatorState! 15 | private var authenticationService: MockAuthenticationService! 16 | 17 | override func setUp() { 18 | state = ResetPasswordState(credentials: Credentials()) 19 | authenticatorState = MockAuthenticatorState() 20 | authenticationService = MockAuthenticationService() 21 | authenticatorState.authenticationService = authenticationService 22 | state.configure(with: authenticatorState) 23 | } 24 | 25 | override func tearDown() { 26 | state = nil 27 | authenticatorState = nil 28 | authenticationService = nil 29 | } 30 | 31 | func testResetPassword_withConfirmReset_shouldSetNextStep() async throws { 32 | let destination = DeliveryDestination.sms("12345678") 33 | authenticationService.mockedResetPasswordResult = .init( 34 | isPasswordReset: false, 35 | nextStep: .confirmResetPasswordWithCode(.init(destination: destination), nil) 36 | ) 37 | 38 | try await state.resetPassword() 39 | XCTAssertEqual(authenticatorState.setCurrentStepCount, 1) 40 | let currentStep = try XCTUnwrap(authenticatorState.setCurrentStepValue) 41 | guard case .confirmResetPassword(let deliveryDetails) = currentStep else { 42 | XCTFail("Expected confirmResetPassword, was \(currentStep)") 43 | return 44 | } 45 | XCTAssertEqual(deliveryDetails?.destination, destination) 46 | } 47 | 48 | func testResetPassword_withDone_shouldSetNextStep() async throws { 49 | authenticationService.mockedResetPasswordResult = .init( 50 | isPasswordReset: true, 51 | nextStep: .done 52 | ) 53 | 54 | try await state.resetPassword() 55 | XCTAssertEqual(authenticatorState.setCurrentStepCount, 1) 56 | let currentStep = try XCTUnwrap(authenticatorState.setCurrentStepValue) 57 | guard case .signIn = currentStep else { 58 | XCTFail("Expected signIn, was \(currentStep)") 59 | return 60 | } 61 | } 62 | 63 | func testResetPassword_withFailure_shouldSetErrorMessage() async throws { 64 | do { 65 | try await state.resetPassword() 66 | XCTFail("Should not succeed") 67 | } catch { 68 | guard let authenticatorError = error as? AuthenticatorError else { 69 | XCTFail("Expected AuthenticatorError, was \(type(of: error))") 70 | return 71 | } 72 | 73 | let task = Task { @MainActor in 74 | XCTAssertNotNil(state.message) 75 | XCTAssertEqual(state.message?.content, authenticatorError.content) 76 | } 77 | await task.value 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/ConfirmSignInWithOTPView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | /// Represents the content being displayed when the ``Authenticator`` is in the ``AuthenticatorStep/confirmSignInWithOTP`` step. 12 | public struct ConfirmSignInWithOTPView: View { 14 | @Environment(\.authenticatorState) private var authenticatorState 15 | @ObservedObject private var state: ConfirmSignInWithCodeState 16 | private let content: ConfirmSignInWithCodeView 17 | 18 | /// Creates a `ConfirmSignInWithOTPView` 19 | /// - Parameter state: The ``ConfirmSignInWithCodeState`` that is observed by this view 20 | /// - Parameter headerContent: The content displayed above the fields. Defaults to ``ConfirmSignInWithOTPHeader`` 21 | /// - Parameter footerContent: The content displayed bellow the fields. Defaults to ``ConfirmSignInWithOTPFooter`` 22 | public init( 23 | state: ConfirmSignInWithCodeState, 24 | @ViewBuilder headerContent: () -> Header = { 25 | ConfirmSignInWithOTPHeader() 26 | }, 27 | @ViewBuilder footerContent: () -> Footer = { 28 | ConfirmSignInWithOTPFooter() 29 | } 30 | ) { 31 | self.state = state 32 | self.content = ConfirmSignInWithCodeView( 33 | state: state, 34 | headerContent: headerContent, 35 | footerContent: footerContent 36 | ) 37 | } 38 | 39 | public var body: some View { 40 | content 41 | .onAppear { 42 | state.message = .info( 43 | message: state.localizedMessage(for: state.deliveryDetails) 44 | ) 45 | } 46 | } 47 | 48 | /// Sets a custom error mapping function for the `AuthError`s that are displayed 49 | /// - Parameter errorTransform: A closure that takes an `AuthError` and returns a ``AuthenticatorError`` that will be displayed. 50 | public func errorMap(_ errorTransform: @escaping (AuthError) -> AuthenticatorError?) -> Self { 51 | state.errorTransform = errorTransform 52 | return self 53 | } 54 | } 55 | 56 | /// Default header for the ``ConfirmSignInWithOTPView``. It displays the view's title 57 | public struct ConfirmSignInWithOTPHeader: View { 58 | public init() {} 59 | public var body: some View { 60 | DefaultHeader( 61 | title: "authenticator.confirmSignInWithOTP.title".localized() 62 | ) 63 | } 64 | } 65 | 66 | /// Default footer for the ``ConfirmSignInWithOTPView``. It displays the "Back to Sign In" button 67 | public struct ConfirmSignInWithOTPFooter: View { 68 | @Environment(\.authenticatorState) private var authenticatorState 69 | 70 | public init() {} 71 | public var body: some View { 72 | Button("authenticator.confirmSignInWithCode.button.backToSignIn".localized()) { 73 | authenticatorState.move(to: .signIn) 74 | } 75 | .buttonStyle(.link) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/ConfirmSignInWithMFACodeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | /// Represents the content being displayed when the ``Authenticator`` is in the ``AuthenticatorStep/confirmSignInWithMFACode`` step. 12 | public struct ConfirmSignInWithMFACodeView: View { 14 | @Environment(\.authenticatorState) private var authenticatorState 15 | @ObservedObject private var state: ConfirmSignInWithCodeState 16 | private let content: ConfirmSignInWithCodeView 17 | 18 | /// Creates a `ConfirmSignInWithMFACodeView` 19 | /// - Parameter state: The ``ConfirmSignInWithCodeState`` that is observed by this view 20 | /// - Parameter headerContent: The content displayed above the fields. Defaults to ``ConfirmSignInWithMFACodeHeader`` 21 | /// - Parameter footerContent: The content displayed bellow the fields. Defaults to ``ConfirmSignInWithMFACodeFooter`` 22 | public init( 23 | state: ConfirmSignInWithCodeState, 24 | @ViewBuilder headerContent: () -> Header = { 25 | ConfirmSignInWithMFACodeHeader() 26 | }, 27 | @ViewBuilder footerContent: () -> Footer = { 28 | ConfirmSignInWithMFACodeFooter() 29 | } 30 | ) { 31 | self.state = state 32 | self.content = ConfirmSignInWithCodeView( 33 | state: state, 34 | headerContent: headerContent, 35 | footerContent: footerContent 36 | ) 37 | } 38 | 39 | public var body: some View { 40 | content 41 | .onAppear { 42 | state.message = .info( 43 | message: state.localizedMessage(for: state.deliveryDetails) 44 | ) 45 | } 46 | } 47 | 48 | /// Sets a custom error mapping function for the `AuthError`s that are displayed 49 | /// - Parameter errorTransform: A closure that takes an `AuthError` and returns a ``AuthenticatorError`` that will be displayed. 50 | public func errorMap(_ errorTransform: @escaping (AuthError) -> AuthenticatorError?) -> Self { 51 | state.errorTransform = errorTransform 52 | return self 53 | } 54 | } 55 | 56 | /// Default header for the ``ConfirmSignInWithMFACodeView``. It displays the view's title 57 | public struct ConfirmSignInWithMFACodeHeader: View { 58 | public init() {} 59 | public var body: some View { 60 | DefaultHeader( 61 | title: "authenticator.confirmSignInWithMFACode.title".localized() 62 | ) 63 | } 64 | } 65 | 66 | /// Default footer for the ``ConfirmSignInWithMFACodeView``. It displays the "Back to Sign In" button 67 | public struct ConfirmSignInWithMFACodeFooter: View { 68 | @Environment(\.authenticatorState) private var authenticatorState 69 | 70 | public init() {} 71 | public var body: some View { 72 | Button("authenticator.confirmSignInWithCode.button.backToSignIn".localized()) { 73 | authenticatorState.move(to: .signIn) 74 | } 75 | .buttonStyle(.link) 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/ConfirmResetPasswordState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import AWSCognitoAuthPlugin 10 | import SwiftUI 11 | 12 | /// The state observed by the Confirm Reset Password content view, representing the ``Authenticator`` is in the ``AuthenticatorStep/confirmResetPassword`` step. 13 | public class ConfirmResetPasswordState: AuthenticatorBaseState { 14 | /// The confirmation code provided by the user 15 | @Published public var confirmationCode: String = "" 16 | 17 | /// The new password provided by the user 18 | @Published public var newPassword: String = "" 19 | 20 | /// The new password confirmation provided by the user 21 | @Published public var confirmPassword: String = "" 22 | 23 | /// The `Amplify.AuthCodeDeliveryDetails` associated with this state. If the Authenticator is not in the `.confirmResetPassword` step, it returns `nil` 24 | public var deliveryDetails: AuthCodeDeliveryDetails? { 25 | guard case .confirmResetPassword(let deliveryDetails) = authenticatorState.step else { 26 | return nil 27 | } 28 | 29 | return deliveryDetails 30 | } 31 | 32 | /// Attempts to confirm the new password using the provided values. 33 | /// 34 | /// Automatically sets the Authenticator's next step accordingly, as well as the 35 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 36 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 37 | public func confirmResetPassword() async throws { 38 | setBusy(true) 39 | 40 | do { 41 | log.verbose("Attempting to confirm Password Reset") 42 | try await authenticationService.confirmResetPassword( 43 | for: credentials.username, 44 | with: newPassword, 45 | confirmationCode: confirmationCode, 46 | options: nil 47 | ) 48 | 49 | let nextStep = await nextStep() 50 | setBusy(false) 51 | authenticatorState.setCurrentStep(nextStep) 52 | } catch { 53 | log.error("Confirm Reset Password failed") 54 | setBusy(false) 55 | let authenticationError = self.error(for: error) 56 | setMessage(authenticationError) 57 | throw authenticationError 58 | } 59 | } 60 | 61 | private func nextStep() async -> Step { 62 | do { 63 | let result = try await authenticationService.signIn( 64 | username: credentials.username, 65 | password: newPassword, 66 | options: nil 67 | ) 68 | 69 | return try await nextStep(for: result) 70 | } catch { 71 | log.error("Unable to Sign In after confirming password reset") 72 | log.error(error) 73 | return .signIn 74 | } 75 | } 76 | } 77 | 78 | extension ConfirmResetPasswordState { 79 | enum Field: Int, Hashable, CaseIterable { 80 | case confirmationCode 81 | case newPassword 82 | case newPasswordConfirmation 83 | } 84 | } 85 | 86 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/VerifyUserState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | /// The state observed by the Verify User content view, representing the ``Authenticator`` is in the ``AuthenticatorStep/verifyUser`` step. 12 | public class VerifyUserState: AuthenticatorBaseState { 13 | /// The `Amplify.AuthUserAttributeKey` to be verified selected by the user 14 | @Published public var selectedField: AuthUserAttributeKey? 15 | 16 | /// An array of the unverified `Amplify.AuthUserAttributeKey` that are associated with this state. 17 | /// If the Authenticator is not in the `.verifyUser` step, it returns an empty array. 18 | public var unverifiedFields: [AuthUserAttributeKey] { 19 | guard case .verifyUser(let attributes) = authenticatorState.step else { 20 | return [] 21 | } 22 | 23 | return attributes 24 | } 25 | 26 | /// Attempts to request a verification for the attribute selected by the user 27 | /// 28 | /// Automatically sets the Authenticator's next step accordingly, as well as the 29 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 30 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 31 | public func verifyUser() async throws { 32 | guard let key = selectedField else { 33 | return 34 | } 35 | 36 | setBusy(true) 37 | 38 | do { 39 | log.verbose("Attempting to verify user attribute \(key)") 40 | let result = try await authenticationService.sendVerificationCode( 41 | forUserAttributeKey: key, 42 | options: nil 43 | ) 44 | setBusy(false) 45 | authenticatorState.setCurrentStep( 46 | .confirmVerifyUser(attribute: key, deliveryDetails: result) 47 | ) 48 | } catch { 49 | log.error("Unable to send confirmation code for user attribute") 50 | let authenticationError = self.error(for: error) 51 | setMessage(authenticationError) 52 | throw authenticationError 53 | } 54 | } 55 | 56 | /// Skips the verification of any ``unverifiedFields`` and attempts to proceed with sign in 57 | /// 58 | /// Automatically sets the Authenticator's next step accordingly, as well as the 59 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 60 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 61 | public func skip() async throws { 62 | setBusy(true) 63 | 64 | do { 65 | log.verbose("Skipping user verification") 66 | let user = try await authenticatorState.authenticationService.getCurrentUser() 67 | authenticatorState.setCurrentStep(.signedIn(user: user)) 68 | setBusy(false) 69 | } catch { 70 | log.error("Unable to get the current user after skipping verification") 71 | log.error(error) 72 | setBusy(false) 73 | // Go back to sign in 74 | authenticatorState.setCurrentStep(.signIn) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/ConfirmSignUpState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | import AWSCognitoAuthPlugin 11 | 12 | /// The state observed by the Confirm Sign Up content view, representing the ``Authenticator`` is in the ``AuthenticatorStep/confirmSignUp`` step. 13 | public class ConfirmSignUpState: AuthenticatorBaseState { 14 | /// The confirmation code provided by the user 15 | @Published public var confirmationCode: String = "" 16 | 17 | /// The `Amplify.AuthCodeDeliveryDetails` associated with this state. If the Authenticator is not in the `.confirmSignUp` step, it returns `nil` 18 | public var deliveryDetails: AuthCodeDeliveryDetails? { 19 | guard case .confirmSignUp(let deliveryDetails) = authenticatorState.step else { 20 | return nil 21 | } 22 | 23 | return deliveryDetails 24 | } 25 | 26 | /// Attempts to confirm the user's Sign Up using the provided confirmation code. 27 | /// 28 | /// Automatically sets the Authenticator's next step accordingly, as well as the 29 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 30 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 31 | public func confirmSignUp() async throws { 32 | setBusy(true) 33 | 34 | do { 35 | log.verbose("Attempting to confirm Sign Up") 36 | let result = try await authenticationService.confirmSignUp( 37 | for: credentials.username, 38 | confirmationCode: confirmationCode, 39 | options: nil) 40 | 41 | let nextStep = try await nextStep(for: result) 42 | setBusy(false) 43 | authenticatorState.setCurrentStep(nextStep) 44 | } catch { 45 | log.error("Confirm Sign Up failed") 46 | setBusy(false) 47 | let authenticationError = self.error(for: error) 48 | setMessage(authenticationError) 49 | throw authenticationError 50 | } 51 | } 52 | 53 | /// Attempts to resend the user's Sign Up confirmation code. 54 | /// 55 | /// Automatically sets the Authenticator's next step accordingly, as well as the 56 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 57 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 58 | public func sendCode() async throws { 59 | setBusy(true) 60 | 61 | do { 62 | log.verbose("Attempting to resend the Sign Up code") 63 | let details = try await authenticationService.resendSignUpCode( 64 | for: credentials.username, 65 | options: nil 66 | ) 67 | 68 | setMessage(.info(message: localizedMessage(for: details))) 69 | authenticatorState.setCurrentStep(.confirmSignUp(deliveryDetails: details)) 70 | } catch { 71 | log.error("Unable to resend the Sign Up confirmation code") 72 | setBusy(false) 73 | let authenticationError = self.error(for: error) 74 | setMessage(authenticationError) 75 | throw authenticationError 76 | } 77 | } 78 | 79 | var username: String { 80 | return credentials.username 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Tests/AuthenticatorTests/States/ConfirmSignInWithNewPasswordStateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | @testable import Authenticator 10 | import XCTest 11 | 12 | class ConfirmSignInWithNewPasswordStateTests: XCTestCase { 13 | private var state: ConfirmSignInWithNewPasswordState! 14 | private var authenticatorState: MockAuthenticatorState! 15 | private var authenticationService: MockAuthenticationService! 16 | 17 | override func setUp() { 18 | state = ConfirmSignInWithNewPasswordState(credentials: Credentials()) 19 | authenticatorState = MockAuthenticatorState() 20 | authenticationService = MockAuthenticationService() 21 | authenticatorState.authenticationService = authenticationService 22 | state.configure(with: authenticatorState) 23 | } 24 | 25 | override func tearDown() { 26 | state = nil 27 | authenticatorState = nil 28 | authenticationService = nil 29 | } 30 | 31 | func testConfirmSignIn_withSuccess_shouldSetNextStep() async throws { 32 | authenticationService.mockedConfirmSignInResult = .init(nextStep: .done) 33 | authenticationService.mockedCurrentUser = MockAuthenticationService.User( 34 | username: "username", 35 | userId: "userId" 36 | ) 37 | 38 | try await state.confirmSignIn() 39 | XCTAssertEqual(authenticationService.confirmSignInCount, 1) 40 | XCTAssertEqual(authenticatorState.setCurrentStepCount, 1) 41 | let currentStep = try XCTUnwrap(authenticatorState.setCurrentStepValue) 42 | guard case .signedIn(_) = currentStep else { 43 | XCTFail("Expected signedIn, was \(currentStep)") 44 | return 45 | } 46 | } 47 | 48 | func testConfirmSignIn_withError_shouldSetErrorMessage() async throws { 49 | do { 50 | try await state.confirmSignIn() 51 | XCTFail("Should not succeed") 52 | } catch { 53 | guard let authenticatorError = error as? AuthenticatorError else { 54 | XCTFail("Expected AuthenticatorError, was \(type(of: error))") 55 | return 56 | } 57 | 58 | let task = Task { @MainActor in 59 | XCTAssertNotNil(state.message) 60 | XCTAssertEqual(state.message?.content, authenticatorError.content) 61 | } 62 | await task.value 63 | } 64 | } 65 | 66 | func testConfirmSignIn_withSuccess_andFailedToSignIn_shouldSetErrorMessage() async throws { 67 | authenticationService.mockedConfirmSignInResult = .init(nextStep: .done) 68 | do { 69 | try await state.confirmSignIn() 70 | XCTFail("Should not succeed") 71 | } catch { 72 | XCTAssertEqual(authenticationService.confirmSignInCount, 1) 73 | guard let authenticatorError = error as? AuthenticatorError else { 74 | XCTFail("Expected AuthenticatorError, was \(type(of: error))") 75 | return 76 | } 77 | 78 | let task = Task { @MainActor in 79 | XCTAssertNotNil(state.message) 80 | XCTAssertEqual(state.message?.content, authenticatorError.content) 81 | } 82 | await task.value 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/PromptToCreatePasskeyState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | /// The state observed by the Prompt To Create Passkey content view, representing the ``Authenticator`` is in the ``AuthenticatorStep/promptToCreatePasskey`` step. 12 | public class PromptToCreatePasskeyState: AuthenticatorBaseState { 13 | 14 | override init(credentials: Credentials) { 15 | super.init(credentials: credentials) 16 | } 17 | 18 | init(authenticatorState: AuthenticatorStateProtocol) { 19 | super.init(authenticatorState: authenticatorState, 20 | credentials: Credentials()) 21 | } 22 | 23 | /// Attempts to create a passkey for the user 24 | /// 25 | /// Automatically sets the Authenticator's next step accordingly, as well as the 26 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 27 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 28 | public func createPasskey() async throws { 29 | setBusy(true) 30 | 31 | do { 32 | log.verbose("Attempting to create passkey") 33 | 34 | // Call Amplify WebAuthn API to associate a passkey credential 35 | if #available(iOS 17.4, macOS 13.5, visionOS 1.0, *) { 36 | try await authenticationService.associateWebAuthnCredential( 37 | presentationAnchor: nil, 38 | options: nil 39 | ) 40 | } else { 41 | throw AuthError.configuration( 42 | "WebAuthn is not supported on this platform", 43 | "WebAuthn requires iOS 17.4+, macOS 13.5+, or visionOS 1.0+", 44 | nil 45 | ) 46 | } 47 | 48 | log.verbose("Passkey created successfully") 49 | setBusy(false) 50 | authenticatorState.setCurrentStep(.passkeyCreated) 51 | } catch { 52 | log.error("Passkey creation failed: \(error)") 53 | setBusy(false) 54 | let authenticationError = self.error(for: error) 55 | setMessage(authenticationError) 56 | throw authenticationError 57 | } 58 | } 59 | 60 | /// Skips passkey creation and continues with the authentication flow 61 | /// 62 | /// Automatically sets the Authenticator's next step accordingly, as well as the 63 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 64 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 65 | public func skip() async throws { 66 | setBusy(true) 67 | 68 | do { 69 | log.verbose("Skipping passkey creation") 70 | // Use post-passkey logic (attribute verification and sign-in) 71 | let nextStep = try await nextStepAfterPasskeyFlow() 72 | 73 | setBusy(false) 74 | authenticatorState.setCurrentStep(nextStep) 75 | } catch { 76 | log.error("Skip passkey creation failed") 77 | setBusy(false) 78 | let authenticationError = self.error(for: error) 79 | setMessage(authenticationError) 80 | throw authenticationError 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/ContinueSignInWithTOTPSetupState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import AWSCognitoAuthPlugin 10 | import SwiftUI 11 | 12 | /// The state observed by the Continue Sign In with TOTP Setup content views, representing the ``Authenticator`` is the ``AuthenticatorStep/continueSignInWithTOTPSetup`` step. 13 | public class ContinueSignInWithTOTPSetupState: AuthenticatorBaseState { 14 | /// The confirmation code provided by the user 15 | @Published public var confirmationCode: String = "" 16 | 17 | private let issuer: String? 18 | private let totpSetupDetails: TOTPSetupDetails 19 | 20 | init(authenticatorState: AuthenticatorStateProtocol, 21 | issuer: String?, 22 | totpSetupDetails: TOTPSetupDetails) { 23 | self.totpSetupDetails = totpSetupDetails 24 | self.issuer = issuer 25 | super.init(authenticatorState: authenticatorState, 26 | credentials: Credentials()) 27 | } 28 | 29 | /// The `Amplify.TOTPSetupDetails.sharedSecret` associated with this state. 30 | public var sharedSecret: String { 31 | return totpSetupDetails.sharedSecret 32 | } 33 | 34 | /// The `Amplify.TOTPSetupDetails.getSetupURI` associated with this state. 35 | public var setupURI: String { 36 | var setupURIAccountName: String = "" 37 | if let issuer = extractIssuerForQRCodeGeneration() { 38 | setupURIAccountName = issuer + ":" + totpSetupDetails.username 39 | return "otpauth://totp/\(setupURIAccountName)?secret=\(sharedSecret)" + "&issuer=\(issuer)" 40 | } else { 41 | return "otpauth://totp/\(setupURIAccountName)?secret=\(sharedSecret)" 42 | 43 | } 44 | } 45 | 46 | private func extractIssuerForQRCodeGeneration() -> String? { 47 | if let issuer = issuer { 48 | return issuer 49 | } 50 | log.warn("`totpOptions` not provided as part of initialization. Falling back to extract application name from Bundle.") 51 | 52 | if let applicationName = Bundle.main.applicationName { 53 | return applicationName 54 | } 55 | log.error("Unable to extract the application name from Bundle") 56 | return nil 57 | } 58 | 59 | /// Attempts to continue the user's sign in using the provided confirmation code. 60 | /// 61 | /// Automatically sets the Authenticator's next step accordingly, as well as the 62 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 63 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 64 | public func continueSignIn() async throws { 65 | setBusy(true) 66 | 67 | do { 68 | log.verbose("Attempting to confirm Sign In with Code") 69 | let result = try await authenticationService.confirmSignIn( 70 | challengeResponse: confirmationCode, 71 | options: nil 72 | ) 73 | let nextStep = try await nextStep(for: result) 74 | 75 | setBusy(false) 76 | 77 | authenticatorState.setCurrentStep(nextStep) 78 | } catch { 79 | log.error("Confirm Sign In with Code failed") 80 | setBusy(false) 81 | let authenticationError = self.error(for: error) 82 | setMessage(authenticationError) 83 | throw authenticationError 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Tests/AuthenticatorTests/States/ContinueSignInWithEmailMFASetupStateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | @testable import Authenticator 10 | import XCTest 11 | 12 | class ContinueSignInWithEmailMFASetupStateTests: XCTestCase { 13 | private var state: ContinueSignInWithEmailMFASetupState! 14 | private var authenticatorState: MockAuthenticatorState! 15 | private var authenticationService: MockAuthenticationService! 16 | 17 | override func setUp() { 18 | authenticatorState = MockAuthenticatorState() 19 | state = ContinueSignInWithEmailMFASetupState(credentials: Credentials()) 20 | state.email = "test@test.com" 21 | 22 | authenticationService = MockAuthenticationService() 23 | authenticatorState.authenticationService = authenticationService 24 | state.configure(with: authenticatorState) 25 | } 26 | 27 | override func tearDown() { 28 | state = nil 29 | authenticatorState = nil 30 | authenticationService = nil 31 | } 32 | 33 | func testContinueSignIn_withSuccess_shouldSetNextStep() async throws { 34 | authenticationService.mockedConfirmSignInResult = .init(nextStep: .confirmSignInWithOTP(.init(destination: .email("test@test.com")))) 35 | authenticationService.mockedCurrentUser = MockAuthenticationService.User( 36 | username: "username", 37 | userId: "userId" 38 | ) 39 | 40 | try await state.continueSignIn() 41 | XCTAssertEqual(authenticationService.confirmSignInCount, 1) 42 | XCTAssertEqual(authenticatorState.setCurrentStepCount, 1) 43 | let currentStep = try XCTUnwrap(authenticatorState.setCurrentStepValue) 44 | guard case .confirmSignInWithOTP = currentStep else { 45 | XCTFail("Expected confirmSignInWithOTP, was \(currentStep)") 46 | return 47 | } 48 | } 49 | 50 | func testContinueSignIn_withError_shouldSetErrorMessage() async throws { 51 | do { 52 | try await state.continueSignIn() 53 | XCTFail("Should not succeed") 54 | } catch { 55 | guard let authenticatorError = error as? AuthenticatorError else { 56 | XCTFail("Expected AuthenticatorError, was \(type(of: error))") 57 | return 58 | } 59 | 60 | let task = Task { @MainActor in 61 | XCTAssertNotNil(state.message) 62 | XCTAssertEqual(state.message?.content, authenticatorError.content) 63 | } 64 | await task.value 65 | } 66 | } 67 | 68 | func testContinueSignIn_withSuccess_andFailedToSignIn_shouldSetErrorMessage() async throws { 69 | authenticationService.mockedConfirmSignInResult = .init(nextStep: .done) 70 | do { 71 | try await state.continueSignIn() 72 | XCTFail("Should not succeed") 73 | } catch { 74 | XCTAssertEqual(authenticationService.confirmSignInCount, 1) 75 | guard let authenticatorError = error as? AuthenticatorError else { 76 | XCTFail("Expected AuthenticatorError, was \(type(of: error))") 77 | return 78 | } 79 | 80 | let task = Task { @MainActor in 81 | XCTAssertNotNil(state.message) 82 | XCTAssertEqual(state.message?.content, authenticatorError.content) 83 | } 84 | await task.value 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/SignInState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import AWSCognitoAuthPlugin 10 | import SwiftUI 11 | 12 | /// The state observed by the Sign In content view, representing the ``Authenticator`` is in the ``AuthenticatorStep/signIn`` step. 13 | public class SignInState: AuthenticatorBaseState { 14 | /// The username provided by the user. Note that this could be an email and a phone number as well. 15 | @Published public var username: String = "" { 16 | didSet { 17 | credentials.username = username 18 | } 19 | } 20 | /// The password provided by the user 21 | @Published public var password: String = "" { 22 | didSet { 23 | credentials.password = password 24 | } 25 | } 26 | 27 | /// Attempts to sign in using the provided credentials 28 | /// 29 | /// Automatically sets the Authenticator's next step accordingly, as well as the 30 | /// ``AuthenticatorBaseState/isBusy`` and `AuthenticatorBaseState/message` properties. 31 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 32 | public func signIn() async throws { 33 | setBusy(true) 34 | 35 | // Reset selected auth factor tracking for new sign-in flow 36 | credentials.selectedAuthFactor = nil 37 | 38 | do { 39 | log.verbose("Attempting to Sign In") 40 | 41 | // Translate AuthenticationFlow to Amplify AuthFlowType 42 | let signInOptions = createSignInOptions() 43 | 44 | let result = try await authenticationService.signIn( 45 | username: username.isEmpty ? nil : username, 46 | password: password.isEmpty ? nil : password, 47 | options: signInOptions 48 | ) 49 | let nextStep = try await nextStep(for: result) 50 | setBusy(false) 51 | authenticatorState.setCurrentStep(nextStep) 52 | } catch { 53 | log.error("Unable to Sign In") 54 | setBusy(false) 55 | let authenticationError = self.error(for: error) 56 | setMessage(authenticationError) 57 | throw authenticationError 58 | } 59 | } 60 | 61 | /// Creates sign-in options based on the authentication flow configuration 62 | private func createSignInOptions() -> AuthSignInRequest.Options? { 63 | switch authenticationFlow { 64 | case .password: 65 | // Use standard SRP flow for password-only authentication 66 | return .init(pluginOptions: AWSAuthSignInOptions(authFlowType: .userSRP)) 67 | 68 | case .userChoice(let preferredAuthFactor, _): 69 | // Use the AuthFactor extension to translate to AuthFactorType 70 | let preferredFirstFactor = preferredAuthFactor?.toAuthFactorType() 71 | 72 | // Use userAuth flow for user choice authentication 73 | return .init(pluginOptions: AWSAuthSignInOptions( 74 | authFlowType: .userAuth(preferredFirstFactor: preferredFirstFactor) 75 | )) 76 | } 77 | } 78 | 79 | /// Manually moves the Authenticator to a different initial step 80 | /// - Parameter initialStep: The desired ``AuthenticatorInitialStep`` 81 | public func move(to initialStep: AuthenticatorInitialStep) { 82 | authenticatorState.move(to: initialStep) 83 | } 84 | } 85 | 86 | extension SignInState { 87 | enum Field: Int, Hashable, CaseIterable { 88 | case username 89 | case password 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/Authenticator/States/ConfirmVerifyUserState.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | /// The state observed by the Confirm Verify User content view, representing the ``Authenticator`` is in the ``AuthenticatorStep/confirmVerifyUser`` step. 12 | public class ConfirmVerifyUserState: AuthenticatorBaseState { 13 | /// The confirmation code provided by the user 14 | @Published public var confirmationCode: String = "" 15 | 16 | /// The `Amplify.AuthUserAttributeKey` associated with this state. If the Authenticator is not in the `.confirmVerifyUser` step, it returns `nil` 17 | public var userAttributeKey: AuthUserAttributeKey? { 18 | guard case .confirmVerifyUser(let attribute, _) = authenticatorState.step else { 19 | return nil 20 | } 21 | 22 | return attribute 23 | } 24 | 25 | /// The `Amplify.AuthCodeDeliveryDetails` associated with this state. If the Authenticator is not in the `.confirmVerifyUser` step, it returns `nil` 26 | public var deliveryDetails: AuthCodeDeliveryDetails? { 27 | guard case .confirmVerifyUser(_, let deliveryDetails) = authenticatorState.step else { 28 | return nil 29 | } 30 | 31 | return deliveryDetails 32 | } 33 | 34 | /// Attempts to verify the associated ``userAttributeKey`` using the provided confirmation code 35 | /// 36 | /// Automatically sets the Authenticator's next step accordingly, as well as the 37 | /// `Amplify.AuthenticatorBaseState/isBusy` and ``AuthenticatorBaseState/message`` properties. 38 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 39 | public func confirmVerifyUser() async throws { 40 | guard let userAttributeKey = userAttributeKey else { 41 | return 42 | } 43 | setBusy(true) 44 | 45 | do { 46 | log.verbose("Attempting to confirm attribute \(userAttributeKey)") 47 | try await authenticationService.confirm( 48 | userAttribute: userAttributeKey, 49 | confirmationCode: confirmationCode, 50 | options: nil 51 | ) 52 | 53 | let user = try await authenticationService.getCurrentUser() 54 | 55 | setBusy(false) 56 | 57 | authenticatorState.setCurrentStep(.signedIn(user: user)) 58 | } catch { 59 | log.error("Unable to confirm user attribute") 60 | setBusy(false) 61 | let authenticationError = self.error(for: error) 62 | setMessage(authenticationError) 63 | throw authenticationError 64 | } 65 | } 66 | 67 | /// Skips the verification of the assocaited ``userAttributeKey`` and attempts to proceed with sign in 68 | /// 69 | /// Automatically sets the Authenticator's next step accordingly, as well as the 70 | /// ``AuthenticatorBaseState/isBusy`` and ``AuthenticatorBaseState/message`` properties. 71 | /// - Throws: An `Amplify.AuthenticationError` if the operation fails 72 | public func skip() async throws { 73 | setBusy(true) 74 | 75 | do { 76 | log.verbose("Skipping user verification") 77 | let user = try await authenticatorState.authenticationService.getCurrentUser() 78 | authenticatorState.setCurrentStep(.signedIn(user: user)) 79 | setBusy(false) 80 | } catch { 81 | log.error("Unable to get the current user after skipping verification") 82 | log.error(error) 83 | setBusy(false) 84 | // Go back to sign in 85 | authenticatorState.setCurrentStep(.signIn) 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/Authenticator/Models/AuthFactor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import AWSCognitoAuthPlugin 10 | import Foundation 11 | 12 | /// Represents an authentication factor that can be used during sign-in 13 | public enum AuthFactor: Equatable, Hashable { 14 | /// Password authentication with optional SRP (Secure Remote Password) 15 | case password(srp: Bool = true) 16 | 17 | /// Email-based one-time password authentication 18 | case emailOtp 19 | 20 | /// SMS-based one-time password authentication 21 | case smsOtp 22 | 23 | /// WebAuthn/Passkey authentication 24 | case webAuthn 25 | } 26 | 27 | extension AuthFactor: Codable {} 28 | 29 | extension AuthFactor { 30 | /// Translates AuthFactor to Amplify AuthFactorType 31 | func toAuthFactorType() -> AuthFactorType { 32 | switch self { 33 | case .password(let srp): 34 | return srp ? .passwordSRP : .password 35 | case .emailOtp: 36 | return .emailOTP 37 | case .smsOtp: 38 | return .smsOTP 39 | case .webAuthn: 40 | #if os(iOS) || os(macOS) || os(visionOS) 41 | if #available(iOS 17.4, macOS 13.5, visionOS 1.0, *) { 42 | return .webAuthn 43 | } else { 44 | // Fallback to password if WebAuthn not available 45 | return .passwordSRP 46 | } 47 | #else 48 | // Fallback to password on unsupported platforms 49 | return .passwordSRP 50 | #endif 51 | } 52 | } 53 | 54 | /// Returns true if this auth factor is a password-based factor (with or without SRP) 55 | var isPassword: Bool { 56 | if case .password = self { 57 | return true 58 | } 59 | return false 60 | } 61 | 62 | /// Returns true if this auth factor is password with SRP enabled 63 | var isPasswordWithSRP: Bool { 64 | guard case .password(let srp) = self else { 65 | return false 66 | } 67 | return srp 68 | } 69 | 70 | /// Display priority for sorting auth factors 71 | /// Lower values appear first: WebAuthn (1), SMS (2), Email (3), Password (4) 72 | var displayPriority: Int { 73 | switch self { 74 | case .webAuthn: 75 | return 1 76 | case .smsOtp: 77 | return 2 78 | case .emailOtp: 79 | return 3 80 | case .password: 81 | return 4 82 | } 83 | } 84 | } 85 | 86 | extension Array where Element == AuthFactor { 87 | /// Returns true if the array contains any password-based auth factor 88 | var containsPassword: Bool { 89 | return contains(where: { $0.isPassword }) 90 | } 91 | 92 | /// Returns the preferred password-based auth factor 93 | /// Prefers passwordSRP over password when both are available (more secure) 94 | var preferredPasswordFactor: AuthFactor? { 95 | // First, try to find password with SRP (more secure) 96 | if let passwordSRP = first(where: { $0.isPasswordWithSRP }) { 97 | return passwordSRP 98 | } 99 | 100 | // Fall back to password without SRP 101 | return first(where: { $0.isPassword }) 102 | } 103 | 104 | /// Returns all non-password auth factors sorted by priority 105 | /// Order: WebAuthn (Passkey), SMS OTP, Email OTP 106 | var nonPasswordFactors: [AuthFactor] { 107 | return filter { !$0.isPassword }.sorted { factor1, factor2 in 108 | return factor1.displayPriority < factor2.displayPriority 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Tests/AuthenticatorTests/States/ContinueSignInWithMFASelectionStateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | @testable import Authenticator 10 | import XCTest 11 | 12 | class ContinueSignInWithMFASelectionStateTests: XCTestCase { 13 | private var state: ContinueSignInWithMFASelectionState! 14 | private var authenticatorState: MockAuthenticatorState! 15 | private var authenticationService: MockAuthenticationService! 16 | 17 | override func setUp() { 18 | authenticatorState = MockAuthenticatorState() 19 | state = ContinueSignInWithMFASelectionState( 20 | authenticatorState: authenticatorState, 21 | allowedMFATypes: [.sms, .totp]) 22 | state.selectedMFAType = .totp 23 | 24 | authenticationService = MockAuthenticationService() 25 | authenticatorState.authenticationService = authenticationService 26 | state.configure(with: authenticatorState) 27 | } 28 | 29 | override func tearDown() { 30 | state = nil 31 | authenticatorState = nil 32 | authenticationService = nil 33 | } 34 | 35 | func testContinueSignIn_withSuccess_shouldSetNextStep() async throws { 36 | authenticationService.mockedConfirmSignInResult = .init(nextStep: .confirmSignInWithTOTPCode) 37 | authenticationService.mockedCurrentUser = MockAuthenticationService.User( 38 | username: "username", 39 | userId: "userId" 40 | ) 41 | 42 | try await state.continueSignIn() 43 | XCTAssertEqual(authenticationService.confirmSignInCount, 1) 44 | XCTAssertEqual(authenticatorState.setCurrentStepCount, 1) 45 | let currentStep = try XCTUnwrap(authenticatorState.setCurrentStepValue) 46 | guard case .confirmSignInWithTOTPCode = currentStep else { 47 | XCTFail("Expected confirmSignInWithTOTPCode, was \(currentStep)") 48 | return 49 | } 50 | } 51 | 52 | func testContinueSignIn_withError_shouldSetErrorMessage() async throws { 53 | do { 54 | try await state.continueSignIn() 55 | XCTFail("Should not succeed") 56 | } catch { 57 | guard let authenticatorError = error as? AuthenticatorError else { 58 | XCTFail("Expected AuthenticatorError, was \(type(of: error))") 59 | return 60 | } 61 | 62 | let task = Task { @MainActor in 63 | XCTAssertNotNil(state.message) 64 | XCTAssertEqual(state.message?.content, authenticatorError.content) 65 | } 66 | await task.value 67 | } 68 | } 69 | 70 | func testContinueSignIn_withSuccess_andFailedToSignIn_shouldSetErrorMessage() async throws { 71 | authenticationService.mockedConfirmSignInResult = .init(nextStep: .done) 72 | do { 73 | try await state.continueSignIn() 74 | XCTFail("Should not succeed") 75 | } catch { 76 | XCTAssertEqual(authenticationService.confirmSignInCount, 1) 77 | guard let authenticatorError = error as? AuthenticatorError else { 78 | XCTFail("Expected AuthenticatorError, was \(type(of: error))") 79 | return 80 | } 81 | 82 | let task = Task { @MainActor in 83 | XCTAssertNotNil(state.message) 84 | XCTAssertEqual(state.message?.content, authenticatorError.content) 85 | } 86 | await task.value 87 | } 88 | } 89 | 90 | func testAllowedMFATypes_onContinueSignInWithMFACodeSelection_shouldReturnDetails() throws { 91 | 92 | authenticatorState.mockedStep = .continueSignInWithMFASelection(allowedMFATypes: [.sms, .totp]) 93 | 94 | let allowedMFATypes = try XCTUnwrap(state.allowedMFATypes) 95 | XCTAssertEqual(allowedMFATypes, [.sms, .totp]) 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /Tests/AuthenticatorTests/States/ConfirmResetPasswordStateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | @testable import Authenticator 10 | import XCTest 11 | 12 | class ConfirmResetPasswordStateTests: XCTestCase { 13 | private var state: ConfirmResetPasswordState! 14 | private var authenticatorState: MockAuthenticatorState! 15 | private var authenticationService: MockAuthenticationService! 16 | 17 | override func setUp() { 18 | state = ConfirmResetPasswordState(credentials: Credentials()) 19 | state.credentials.username = "username" 20 | authenticatorState = MockAuthenticatorState() 21 | authenticationService = MockAuthenticationService() 22 | authenticatorState.authenticationService = authenticationService 23 | state.configure(with: authenticatorState) 24 | } 25 | 26 | override func tearDown() { 27 | state = nil 28 | authenticatorState = nil 29 | authenticationService = nil 30 | } 31 | 32 | func testConfirmResetPassword_withSuccess_andSignIn_shouldSetNextStep() async throws { 33 | authenticationService.mockedSignInResult = .init(nextStep: .done) 34 | authenticationService.mockedCurrentUser = MockAuthenticationService.User( 35 | username: "username", 36 | userId: "userId" 37 | ) 38 | 39 | try await state.confirmResetPassword() 40 | XCTAssertEqual(authenticationService.confirmResetPasswordCount, 1) 41 | XCTAssertEqual(authenticatorState.setCurrentStepCount, 1) 42 | let currentStep = try XCTUnwrap(authenticatorState.setCurrentStepValue) 43 | guard case .signedIn(_) = currentStep else { 44 | XCTFail("Expected signedIn, was \(currentStep)") 45 | return 46 | } 47 | } 48 | 49 | func testConfirmResetPassword_withError_shouldSetErrorMessage() async throws { 50 | authenticationService.mockedConfirmResetPasswordError = .error(message: "Unable to confirm reset password") 51 | do { 52 | try await state.confirmResetPassword() 53 | XCTFail("Should not succeed") 54 | } catch { 55 | guard let authenticatorError = error as? AuthenticatorError else { 56 | XCTFail("Expected AuthenticatorError, was \(type(of: error))") 57 | return 58 | } 59 | 60 | let task = Task { @MainActor in 61 | XCTAssertNotNil(state.message) 62 | XCTAssertEqual(state.message?.content, authenticatorError.content) 63 | } 64 | await task.value 65 | } 66 | } 67 | 68 | func testConfirmResetPassword_withSuccess_andFailedToSignIn_shouldSetNextStep() async throws { 69 | try await state.confirmResetPassword() 70 | XCTAssertEqual(authenticationService.confirmResetPasswordCount, 1) 71 | XCTAssertEqual(authenticatorState.setCurrentStepCount, 1) 72 | let currentStep = try XCTUnwrap(authenticatorState.setCurrentStepValue) 73 | guard case .signIn = currentStep else { 74 | XCTFail("Expected signIn, was \(currentStep)") 75 | return 76 | } 77 | } 78 | 79 | func testDeliveryDetails_onConfirmResetPasswordStep_shouldReturnDetails() throws { 80 | let destination = DeliveryDestination.sms("123456789") 81 | authenticatorState.mockedStep = .confirmResetPassword(deliveryDetails: .init(destination: destination)) 82 | 83 | let deliveryDetails = try XCTUnwrap(state.deliveryDetails) 84 | XCTAssertEqual(deliveryDetails.destination, destination) 85 | } 86 | 87 | func testDeliveryDetails_onUnexpectedStep_shouldRetunNil() throws { 88 | let destination = DeliveryDestination.sms("123456789") 89 | authenticatorState.mockedStep = .confirmSignUp(deliveryDetails: .init(destination: destination)) 90 | 91 | XCTAssertNil(state.deliveryDetails) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /Tests/AuthenticatorTests/States/ContinueSignInWithMFASetupSelectionStateTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | @testable import Authenticator 10 | import XCTest 11 | 12 | class ContinueSignInWithMFASetupSelectionStateTests: XCTestCase { 13 | private var state: ContinueSignInWithMFASetupSelectionState! 14 | private var authenticatorState: MockAuthenticatorState! 15 | private var authenticationService: MockAuthenticationService! 16 | 17 | override func setUp() { 18 | authenticatorState = MockAuthenticatorState() 19 | state = ContinueSignInWithMFASetupSelectionState( 20 | authenticatorState: authenticatorState, 21 | allowedMFATypes: [.sms, .totp, .email]) 22 | state.selectedMFATypeToSetup = MFAType.email 23 | 24 | authenticationService = MockAuthenticationService() 25 | authenticatorState.authenticationService = authenticationService 26 | state.configure(with: authenticatorState) 27 | } 28 | 29 | override func tearDown() { 30 | state = nil 31 | authenticatorState = nil 32 | authenticationService = nil 33 | } 34 | 35 | func testContinueSignIn_withSuccess_shouldSetNextStep() async throws { 36 | authenticationService.mockedConfirmSignInResult = .init(nextStep: .continueSignInWithMFASetupSelection([.sms, .totp, .email])) 37 | authenticationService.mockedCurrentUser = MockAuthenticationService.User( 38 | username: "username", 39 | userId: "userId" 40 | ) 41 | 42 | try await state.continueSignIn() 43 | XCTAssertEqual(authenticationService.confirmSignInCount, 1) 44 | XCTAssertEqual(authenticatorState.setCurrentStepCount, 1) 45 | let currentStep = try XCTUnwrap(authenticatorState.setCurrentStepValue) 46 | guard case .continueSignInWithMFASetupSelection = currentStep else { 47 | XCTFail("Expected continueSignInWithMFASetupSelection, was \(currentStep)") 48 | return 49 | } 50 | } 51 | 52 | func testContinueSignIn_withError_shouldSetErrorMessage() async throws { 53 | do { 54 | try await state.continueSignIn() 55 | XCTFail("Should not succeed") 56 | } catch { 57 | guard let authenticatorError = error as? AuthenticatorError else { 58 | XCTFail("Expected AuthenticatorError, was \(type(of: error))") 59 | return 60 | } 61 | 62 | let task = Task { @MainActor in 63 | XCTAssertNotNil(state.message) 64 | XCTAssertEqual(state.message?.content, authenticatorError.content) 65 | } 66 | await task.value 67 | } 68 | } 69 | 70 | func testContinueSignIn_withSuccess_andFailedToSignIn_shouldSetErrorMessage() async throws { 71 | authenticationService.mockedConfirmSignInResult = .init(nextStep: .done) 72 | do { 73 | try await state.continueSignIn() 74 | XCTFail("Should not succeed") 75 | } catch { 76 | XCTAssertEqual(authenticationService.confirmSignInCount, 1) 77 | guard let authenticatorError = error as? AuthenticatorError else { 78 | XCTFail("Expected AuthenticatorError, was \(type(of: error))") 79 | return 80 | } 81 | 82 | let task = Task { @MainActor in 83 | XCTAssertNotNil(state.message) 84 | XCTAssertEqual(state.message?.content, authenticatorError.content) 85 | } 86 | await task.value 87 | } 88 | } 89 | 90 | func testAllowedMFATypes_onContinueSignInWithMFACodeSelection_shouldReturnDetails() throws { 91 | 92 | authenticatorState.mockedStep = .continueSignInWithMFASelection(allowedMFATypes: [.sms, .totp, .email]) 93 | 94 | let allowedMFATypes = try XCTUnwrap(state.allowedMFATypes) 95 | XCTAssertEqual(allowedMFATypes, [.sms, .totp, .email]) 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /Tests/AuthenticatorHostApp/AuthenticatorHostApp.xcodeproj/xcshareddata/xcschemes/AuthenticatorHostApp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 34 | 35 | 36 | 37 | 40 | 46 | 47 | 48 | 49 | 50 | 60 | 62 | 68 | 69 | 70 | 71 | 77 | 79 | 85 | 86 | 87 | 88 | 90 | 91 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /Sources/Authenticator/Utilities/AuthenticatorField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AuthenticatorField: View { 11 | @Environment(\.isEnabled) private var isEnabled: Bool 12 | private var isFocused: Bool 13 | @Environment(\.authenticatorOptions) private var options 14 | @Environment(\.authenticatorTheme) var theme 15 | @ObservedObject private var validator: Validator 16 | private let label: String? 17 | private let placeholder: String 18 | private let content: Content 19 | 20 | init(_ label: String?, 21 | placeholder: String, 22 | validator: Validator, 23 | isFocused: Bool, 24 | content: () -> Content) { 25 | self.label = label 26 | self.placeholder = placeholder 27 | self.validator = validator 28 | self.isFocused = isFocused 29 | self.content = content() 30 | } 31 | 32 | var body: some View { 33 | VStack(alignment: .leading, spacing: theme.components.field.spacing.vertical) { 34 | if let label = label { 35 | SwiftUI.Text(label) 36 | .foregroundColor(foregroundColor) 37 | .font(theme.fonts.body) 38 | .accessibilityHidden(true) 39 | } 40 | 41 | content 42 | .background( 43 | RoundedRectangle(cornerRadius: theme.components.field.cornerRadius, style: .continuous) 44 | .fill(backgroundColor) 45 | ) 46 | .overlay( 47 | RoundedRectangle(cornerRadius: theme.components.field.cornerRadius) 48 | .stroke(borderColor, 49 | lineWidth: borderWidth) 50 | 51 | ) 52 | 53 | if let errorMessage = errorMessage { 54 | SwiftUI.Text(errorMessage) 55 | .font(theme.fonts.subheadline) 56 | .foregroundColor(foregroundColor) 57 | .transition(options.contentTransition) 58 | .accessibilityHidden(true) 59 | } 60 | 61 | } 62 | .accessibilityElement(children: .contain) 63 | .accessibilityLabel(accessibilityLabel) 64 | .animation(options.contentAnimation, value: validator.state) 65 | } 66 | 67 | private var backgroundColor: Color { 68 | isEnabled ? theme.components.field.backgroundColor : Color( 69 | light: theme.colors.background.disabled, 70 | dark: .clear 71 | ) 72 | } 73 | 74 | private var foregroundColor: Color { 75 | switch validator.state { 76 | case .normal: 77 | return theme.colors.foreground.secondary 78 | case .error: 79 | return theme.colors.foreground.error 80 | } 81 | } 82 | 83 | private var borderColor: Color { 84 | switch validator.state { 85 | case .normal: 86 | return isFocused ? 87 | theme.colors.border.interactive : theme.colors.border.primary 88 | 89 | case .error: 90 | return theme.colors.border.error 91 | } 92 | } 93 | 94 | private var borderWidth: CGFloat { 95 | let width = theme.components.field.borderWidth 96 | return isFocused ? width + 1 : width 97 | } 98 | 99 | private var title: String { 100 | return label ?? placeholder 101 | } 102 | 103 | private var errorMessage: String? { 104 | if case .error(let message) = validator.state, 105 | let message = message { 106 | return String(format: message, title) 107 | } 108 | return nil 109 | } 110 | 111 | private var accessibilityLabel: Text { 112 | if let errorMessage = errorMessage { 113 | return Text("\(errorMessage). \(title)") 114 | } 115 | 116 | return Text(title) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/VerifyUserView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | /// Represents the content being displayed when the ``Authenticator`` is in the ``AuthenticatorStep/verifyUser`` step. 12 | public struct VerifyUserView: View { 14 | @Environment(\.authenticatorState) private var authenticatorState 15 | @ObservedObject private var state: VerifyUserState 16 | private let headerContent: Header 17 | private let footerContent: Footer 18 | 19 | /// Creates a `VerifyUserView` 20 | /// - Parameter state: The ``VerifyUserState`` that is observed by this view 21 | /// - Parameter headerContent: The content displayed above the fields. Defaults to ``VerifyUserHeader`` 22 | /// - Parameter footerContent: The content displayed bellow the fields. Defaults to `SwiftUI.EmptyView` 23 | public init( 24 | state: VerifyUserState, 25 | @ViewBuilder headerContent: () -> Header = { 26 | VerifyUserHeader() 27 | }, 28 | @ViewBuilder footerContent: () -> Footer = { 29 | EmptyView() 30 | } 31 | ) { 32 | self._state = ObservedObject(wrappedValue: state) 33 | self.headerContent = headerContent() 34 | self.footerContent = footerContent() 35 | } 36 | 37 | public var body: some View { 38 | AuthenticatorView(isBusy: state.isBusy) { 39 | headerContent 40 | 41 | ForEach(state.unverifiedFields, id: \.self) { field in 42 | RadioButton( 43 | label: field.localizedTitle, 44 | isSelected: .constant(state.selectedField == field) 45 | ) { 46 | state.selectedField = field 47 | } 48 | .accessibilityAddTraits(state.selectedField == field ? .isSelected : .isButton) 49 | .animation(.none, value: state.selectedField) 50 | } 51 | 52 | Button("authenticator.verifyUser.button.verify".localized()) { 53 | Task { 54 | await verifyUser() 55 | } 56 | } 57 | .buttonStyle(.primary) 58 | .disabled(state.selectedField == nil) 59 | .opacity(state.selectedField == nil ? 0.5 : 1) 60 | 61 | Button("authenticator.verifyUser.button.skip".localized()) { 62 | Task { 63 | await skip() 64 | } 65 | } 66 | .buttonStyle(.link) 67 | 68 | footerContent 69 | } 70 | .messageBanner($state.message) 71 | .task { 72 | // If we somehow ended up in this view with no attributes to verify, automatically skip 73 | if state.unverifiedFields.isEmpty { 74 | await skip() 75 | } 76 | } 77 | } 78 | 79 | /// Sets a custom error mapping function for the `AuthError`s that are displayed 80 | /// - Parameter errorTransform: A closure that takes an `AuthError` and returns a ``AuthenticatorError`` that will be displayed. 81 | public func errorMap(_ errorTransform: @escaping (AuthError) -> AuthenticatorError?) -> Self { 82 | state.errorTransform = errorTransform 83 | return self 84 | } 85 | 86 | private func verifyUser() async { 87 | try? await state.verifyUser() 88 | } 89 | 90 | private func skip() async { 91 | try? await state.skip() 92 | } 93 | } 94 | 95 | /// Default header for the ``VerifyUserView``. It displays the view's title 96 | public struct VerifyUserHeader: View { 97 | @Environment(\.authenticatorTheme) private var theme 98 | 99 | public init() {} 100 | public var body: some View { 101 | DefaultHeader( 102 | title: "authenticator.verifyUser.title".localized() 103 | ) 104 | .font(theme.fonts.title3) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/Internal/ConfirmSignInWithCodeView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | struct ConfirmSignInWithCodeView: View { 13 | @Environment(\.authenticatorState) private var authenticatorState 14 | @StateObject private var codeValidator: Validator 15 | @ObservedObject private var state: ConfirmSignInWithCodeState 16 | private let headerContent: Header 17 | private let footerContent: Footer 18 | 19 | init( 20 | state: ConfirmSignInWithCodeState, 21 | @ViewBuilder headerContent: () -> Header = { 22 | EmptyView() 23 | }, 24 | @ViewBuilder footerContent: () -> Footer = { 25 | EmptyView() 26 | }, 27 | errorTransform: ((AuthError) -> AuthenticatorError?)? = nil 28 | ) { 29 | self.state = state 30 | self.headerContent = headerContent() 31 | self.footerContent = footerContent() 32 | self._codeValidator = StateObject(wrappedValue: Validator( 33 | using: FieldValidators.required 34 | )) 35 | } 36 | 37 | private var currentMFAType: AuthenticatorFactorType { 38 | switch authenticatorState.step { 39 | case .confirmSignInWithMFACode(let deliveryDetails), 40 | .confirmSignInWithOTP(let deliveryDetails): 41 | switch deliveryDetails?.destination { 42 | case .email: 43 | return .email 44 | case .phone: 45 | return .sms 46 | default: 47 | return .none 48 | } 49 | case .confirmSignInWithTOTPCode: 50 | return .totp 51 | default: 52 | return .none 53 | } 54 | } 55 | 56 | private var textFieldLabel: String { 57 | switch currentMFAType { 58 | case .sms, .none, .email: 59 | return "authenticator.field.code.label".localized() 60 | case .totp: 61 | return "authenticator.field.totp.code.label".localized() 62 | } 63 | } 64 | 65 | private var textFieldPlaceholder: String { 66 | switch currentMFAType { 67 | case .sms, .none, .email: 68 | return "authenticator.field.code.placeholder".localized() 69 | case .totp: 70 | return "authenticator.field.totp.code.placeholder".localized() 71 | } 72 | } 73 | 74 | private var submitButtonTitle: String { 75 | switch currentMFAType { 76 | case .sms, .none, .email: 77 | return "authenticator.confirmSignInWithCode.button.submit".localized() 78 | case .totp: 79 | return "authenticator.confirmSignInWithCode.totp.button.submit".localized() 80 | } 81 | } 82 | 83 | var body: some View { 84 | AuthenticatorView(isBusy: state.isBusy) { 85 | headerContent 86 | 87 | TextField( 88 | textFieldLabel, 89 | text: $state.confirmationCode, 90 | placeholder: textFieldPlaceholder, 91 | validator: codeValidator 92 | ) 93 | .textContentType(.oneTimeCode) 94 | #if os(iOS) 95 | .keyboardType(.default) 96 | #endif 97 | 98 | Button(submitButtonTitle) { 99 | Task { await confirmSignIn() } 100 | } 101 | .buttonStyle(.primary) 102 | 103 | footerContent 104 | } 105 | .messageBanner($state.message) 106 | .onSubmit { 107 | Task { 108 | await confirmSignIn() 109 | } 110 | } 111 | } 112 | 113 | private func confirmSignIn() async { 114 | guard codeValidator.validate() else { 115 | log.verbose("Code validation failed") 116 | return 117 | } 118 | 119 | try? await state.confirmSignIn() 120 | } 121 | } 122 | 123 | extension ConfirmSignInWithCodeView: AuthenticatorLogging {} 124 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/Internal/SignUpInputField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SignUpInputField: View { 11 | @Environment(\.authenticatorOptions) private var options 12 | @Environment(\.authenticatorTheme) var theme 13 | @ObservedObject private var field: SignUpState.Field 14 | @ObservedObject private var validator: Validator 15 | 16 | init( 17 | field: SignUpState.Field, 18 | validator: Validator 19 | ) { 20 | self.field = field 21 | self.validator = validator 22 | } 23 | 24 | var body: some View { 25 | Group { 26 | if let customField = field.field as? CustomSignUpField { 27 | customView(for: customField) 28 | } else if let baseField = field.field as? BaseSignUpField { 29 | regularView(for: baseField) 30 | } 31 | } 32 | } 33 | 34 | @ViewBuilder func regularView(for field: BaseSignUpField) -> some View { 35 | Group { 36 | switch field.inputType { 37 | case .text: 38 | TextField( 39 | field.displayedLabel, 40 | text: $field.value, 41 | placeholder: field.placeholder, 42 | validator: validator 43 | ) 44 | case .password: 45 | PasswordField( 46 | field.displayedLabel, 47 | text: $field.value, 48 | placeholder: field.placeholder, 49 | validator: validator 50 | ) 51 | case .date: 52 | DatePicker( 53 | field.displayedLabel, 54 | text: $field.value, 55 | placeholder: field.placeholder, 56 | validator: validator 57 | ) 58 | case .phoneNumber: 59 | PhoneNumberField( 60 | field.displayedLabel, 61 | text: $field.value, 62 | placeholder: field.placeholder, 63 | validator: validator 64 | ) 65 | } 66 | } 67 | .textContentType(field.attributeType.textContentType) 68 | #if os(iOS) 69 | .keyboardType(field.attributeType.keyboardType) 70 | #endif 71 | } 72 | 73 | @ViewBuilder func customView(for field: CustomSignUpField) -> some View { 74 | VStack(alignment: .leading, spacing: theme.components.field.spacing.vertical) { 75 | if let label = field.displayedLabel { 76 | HStack { 77 | SwiftUI.Text(label) 78 | .foregroundColor(foregroundColor) 79 | .font(theme.fonts.body) 80 | .accessibilityHidden(true) 81 | Spacer() 82 | } 83 | } 84 | AnyView( 85 | field.content($field.value) 86 | ) 87 | .onChange(of: self.field.value) { _ in 88 | validator.validate() 89 | } 90 | .onAppear { 91 | validator.value = $field.value 92 | } 93 | if case .error(let message) = validator.state, let errorMessage = message { 94 | AnyView( 95 | field.errorContent(String(format: errorMessage, field.label ?? "authenticator.validator.field".localized())) 96 | .font(theme.fonts.subheadline) 97 | ) 98 | .foregroundColor(foregroundColor) 99 | .transition(options.contentTransition) 100 | .accessibilityHidden(true) 101 | } 102 | } 103 | } 104 | 105 | private var foregroundColor: Color { 106 | switch validator.state { 107 | case .normal: 108 | return theme.colors.foreground.secondary 109 | case .error: 110 | return theme.colors.foreground.error 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/PromptToCreatePasskeyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import Amplify 9 | import SwiftUI 10 | 11 | /// Represents the content being displayed when the ``Authenticator`` is in the ``AuthenticatorStep/promptToCreatePasskey`` step. 12 | public struct PromptToCreatePasskeyView: View { 14 | @Environment(\.authenticatorState) private var authenticatorState 15 | @Environment(\.authenticatorTheme) var theme 16 | @ObservedObject private var state: PromptToCreatePasskeyState 17 | private let headerContent: Header 18 | private let footerContent: Footer 19 | 20 | /// Creates a `PromptToCreatePasskeyView` 21 | /// - Parameter state: The ``PromptToCreatePasskeyState`` that is observed by this view 22 | /// - Parameter headerContent: The content displayed above the fields. Defaults to ``PromptToCreatePasskeyHeader`` 23 | /// - Parameter footerContent: The content displayed bellow the fields. Defaults to ``PromptToCreatePasskeyFooter`` 24 | public init( 25 | state: PromptToCreatePasskeyState, 26 | @ViewBuilder headerContent: () -> Header = { 27 | PromptToCreatePasskeyHeader() 28 | }, 29 | @ViewBuilder footerContent: () -> Footer = { 30 | PromptToCreatePasskeyFooter() 31 | } 32 | ) { 33 | self._state = ObservedObject(wrappedValue: state) 34 | self.headerContent = headerContent() 35 | self.footerContent = footerContent() 36 | } 37 | 38 | public var body: some View { 39 | AuthenticatorView(isBusy: state.isBusy) { 40 | headerContent 41 | 42 | Text("authenticator.promptToCreatePasskey.description".localized()) 43 | .font(theme.fonts.body) 44 | .foregroundColor(theme.colors.foreground.primary) 45 | .multilineTextAlignment(.leading) 46 | .padding(.bottom, 16) 47 | 48 | // Passkey illustration 49 | Image("passkey", bundle: .module) 50 | .resizable() 51 | .scaledToFit() 52 | .frame(height: 120) 53 | .padding(.vertical, 24) 54 | 55 | Button("authenticator.promptToCreatePasskey.button.createPasskey".localized()) { 56 | Task { 57 | await createPasskey() 58 | } 59 | } 60 | .buttonStyle(.primary) 61 | 62 | Button("authenticator.promptToCreatePasskey.button.skip".localized()) { 63 | Task { 64 | await skip() 65 | } 66 | } 67 | .buttonStyle(.link) 68 | 69 | footerContent 70 | } 71 | .messageBanner($state.message) 72 | } 73 | 74 | /// Sets a custom error mapping function for the `AuthError`s that are displayed 75 | /// - Parameter errorTransform: A closure that takes an `AuthError` and returns a ``AuthenticatorError`` that will be displayed. 76 | public func errorMap(_ errorTransform: @escaping (AuthError) -> AuthenticatorError?) -> Self { 77 | state.errorTransform = errorTransform 78 | return self 79 | } 80 | 81 | private func createPasskey() async { 82 | try? await state.createPasskey() 83 | } 84 | 85 | private func skip() async { 86 | try? await state.skip() 87 | } 88 | } 89 | 90 | extension PromptToCreatePasskeyView: AuthenticatorLogging {} 91 | 92 | /// Default header for the ``PromptToCreatePasskeyView``. It displays the view's title 93 | public struct PromptToCreatePasskeyHeader: View { 94 | public init() {} 95 | public var body: some View { 96 | DefaultHeader( 97 | title: "authenticator.promptToCreatePasskey.title".localized() 98 | ) 99 | } 100 | } 101 | 102 | /// Default footer for the ``PromptToCreatePasskeyView``. 103 | public struct PromptToCreatePasskeyFooter: View { 104 | public init() {} 105 | public var body: some View { 106 | EmptyView() 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/Primitives/TextField.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | /// This field allows the user to enter any text-based input 11 | /// It applies Amplify UI theming 12 | struct TextField: View { 13 | @Environment(\.authenticatorTheme) var theme 14 | @ObservedObject private var validator: Validator 15 | @Binding private var text: String 16 | @FocusState private var isFocused: Bool 17 | private let label: String? 18 | private let placeholder: String 19 | private var image: Image? 20 | 21 | init(_ label: String, 22 | text: Binding, 23 | placeholder: String, 24 | validator: Validator? = nil) { 25 | self.label = label 26 | self._text = text 27 | self.placeholder = placeholder 28 | self.validator = validator ?? .init( 29 | using: FieldValidators.none 30 | ) 31 | self.validator.value = text 32 | } 33 | 34 | init(_ placeholder: String, 35 | text: Binding, 36 | validator: Validator? = nil) { 37 | self.label = nil 38 | self._text = text 39 | self.placeholder = placeholder 40 | self.validator = validator ?? .init( 41 | using: FieldValidators.none 42 | ) 43 | self.validator.value = text 44 | } 45 | 46 | var body: some View { 47 | AuthenticatorField( 48 | label, 49 | placeholder: placeholder, 50 | validator: validator, 51 | isFocused: isFocused 52 | ) { 53 | HStack { 54 | SwiftUI.TextField("", text: $text, prompt: createPlaceHolderView(label: placeholder)) 55 | .disableAutocorrection(true) 56 | .focused($isFocused) 57 | .onChange(of: text) { text in 58 | if validator.state != .normal || !text.isEmpty { 59 | validator.validate() 60 | } 61 | } 62 | .onChange(of: isFocused) { isFocused in 63 | if !isFocused { 64 | validator.validate() 65 | } 66 | } 67 | .textFieldStyle(.plain) 68 | .foregroundColor(foregroundColor) 69 | .frame(height: Platform.isMacOS ? 20 : 25) 70 | .padding([.top, .bottom, .leading], theme.components.field.padding) 71 | #if os(iOS) 72 | .autocapitalization(.none) 73 | #endif 74 | 75 | if shouldDisplayClearButton { 76 | ImageButton(.clear) { 77 | text = "" 78 | } 79 | .tintColor(clearButtonColor) 80 | .padding([.top, .bottom, .trailing], theme.components.field.padding) 81 | } 82 | } 83 | } 84 | } 85 | 86 | private func createPlaceHolderView(label: String) -> SwiftUI.Text { 87 | let textView = SwiftUI.Text(label) 88 | .foregroundColor(theme.colors.foreground.disabled.opacity(0.6)) 89 | .font(theme.fonts.body) 90 | _ = textView.accessibilityHidden(true) 91 | return textView 92 | } 93 | 94 | private var foregroundColor: Color { 95 | switch validator.state { 96 | case .normal: 97 | return theme.colors.foreground.secondary 98 | case .error: 99 | return theme.colors.foreground.error 100 | } 101 | } 102 | 103 | private var clearButtonColor: Color { 104 | switch validator.state { 105 | case .normal: 106 | return isFocused ? 107 | theme.colors.border.interactive : theme.colors.border.primary 108 | case .error: 109 | return theme.colors.border.error 110 | } 111 | } 112 | 113 | private var shouldDisplayClearButton: Bool { 114 | // Show the clear button when there's text and 115 | // the field is focused on non-macOS platforms 116 | return !text.isEmpty && (Platform.isMacOS || isFocused) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /Sources/Authenticator/Views/Internal/AuthenticatorMessageView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Copyright Amazon.com Inc. or its affiliates. 3 | // All Rights Reserved. 4 | // 5 | // SPDX-License-Identifier: Apache-2.0 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | func messageBanner(_ message: Binding) -> some View { 12 | self.modifier(AuthenticatorMessageModifier(message: message)) 13 | } 14 | } 15 | 16 | private struct AuthenticatorMessageModifier: ViewModifier { 17 | @Environment(\.authenticatorOptions) var options 18 | @Binding var message: AuthenticatorMessage? 19 | 20 | @State private var dismissTimer: Timer? { 21 | willSet { 22 | dismissTimer?.invalidate() 23 | } 24 | } 25 | 26 | func body(content: Content) -> some View { 27 | #if os(iOS) 28 | content 29 | .fullScreenCover(isPresented: .constant(message != nil)) { 30 | messageContent 31 | } 32 | #elseif os(macOS) 33 | ZStack { 34 | content 35 | messageContent 36 | } 37 | #endif 38 | } 39 | 40 | private func dismissErrorView() { 41 | dismissTimer = nil 42 | message = nil 43 | } 44 | 45 | @ViewBuilder private var messageContent: some View { 46 | VStack { 47 | if let message = message { 48 | Spacer() 49 | AuthenticatorMessageView( 50 | message: message, 51 | action: dismissErrorView 52 | ) 53 | .onAppear { 54 | dismissTimer = Timer.scheduledTimer( 55 | withTimeInterval: 5, 56 | repeats: false 57 | ) { _ in 58 | dismissErrorView() 59 | } 60 | } 61 | #if os(macOS) 62 | .transition(.move(edge: .bottom).combined(with: .opacity)) 63 | #endif 64 | } 65 | } 66 | #if os(iOS) 67 | .background(ClearBackgroundView() 68 | .onTapGesture { 69 | dismissErrorView() 70 | } 71 | ) 72 | #elseif os(macOS) 73 | .animation(options.contentAnimation, value: self.message != nil) 74 | #endif 75 | } 76 | } 77 | 78 | private struct AuthenticatorMessageView: View { 79 | @Environment(\.authenticatorTheme) private var theme 80 | let message: AuthenticatorMessage 81 | let action: () -> () 82 | 83 | var body: some View { 84 | HStack { 85 | Text(message.content) 86 | .font(theme.fonts.callout) 87 | Spacer() 88 | ImageButton(.close) { 89 | action() 90 | } 91 | .tintColor(foregroundColor) 92 | } 93 | .frame(maxWidth: .infinity) 94 | .foregroundColor(foregroundColor) 95 | .padding(theme.components.alert.padding/2) 96 | .background(backgroundColor) 97 | .cornerRadius(theme.components.alert.cornerRadius) 98 | .padding(theme.components.alert.padding/2) 99 | } 100 | 101 | private var foregroundColor: Color { 102 | switch message.style { 103 | case .error: 104 | return theme.colors.foreground.error 105 | case .info: 106 | return theme.colors.foreground.info 107 | default: 108 | return theme.colors.foreground.primary 109 | } 110 | } 111 | 112 | private var backgroundColor: Color { 113 | switch message.style { 114 | case .error: 115 | return theme.colors.background.error 116 | case .info: 117 | return theme.colors.background.info 118 | default: 119 | return theme.colors.background.primary 120 | } 121 | } 122 | } 123 | 124 | #if os(iOS) 125 | struct ClearBackgroundView: UIViewRepresentable { 126 | func makeUIView(context: Context) -> UIView { 127 | return InnerView() 128 | } 129 | 130 | func updateUIView(_ uiView: UIView, context: Context) { } 131 | 132 | private class InnerView: UIView { 133 | override func didMoveToWindow() { 134 | super.didMoveToWindow() 135 | superview?.superview?.backgroundColor = .clear 136 | } 137 | } 138 | } 139 | #endif 140 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.3.0 (2025-12-15) 4 | - Add support for passwordless auth flows 5 | 6 | ## 1.2.3 (2024-12-16) 7 | 8 | ### Bug Fixes 9 | - **Theming**: Adding support for customizing the TextFields' input and placeholder foreground colours (#100) 10 | - **Authenticator**: Avoiding signing out users when the token refresh fails due to no connectivity (#104) 11 | 12 | ## 1.2.2 (2024-11-26) 13 | 14 | ### Misc. Updates 15 | - Updating code to support Amplify 2.45+ 16 | 17 | ## 1.2.1 (2024-11-21) 18 | 19 | ### Misc. Updates 20 | - Pinning the Amplify version up to 2.44.x 21 | 22 | ## 1.2.0 (2024-10-31) 23 | 24 | ### Feature 25 | - **Authenticator**: Adding support for Email MFA (#96) 26 | 27 | ## 1.1.8 (2024-09-20) 28 | 29 | ### Bug Fixes 30 | - **Authenticator**: Adding new error localizations for limits exceeded (#96) 31 | 32 | ## 1.1.7 (2024-09-13) 33 | 34 | ### Bug Fixes 35 | - **Authenticator**: Allowing to only override the desired errors when invoking the errorMap functions (#93) 36 | 37 | ## 1.1.6 (2024-08-13) 38 | 39 | ### Bug Fixes 40 | - **Authenticator**: Properly handling expired sessions when loading the component (#87) 41 | 42 | ## 1.1.5 (2024-07-02) 43 | 44 | ### Bug Fixes 45 | - **Authenticator**: Setting corner radius according to the theme (#84) 46 | 47 | ## 1.1.4 (2024-06-07) 48 | 49 | ### Bug Fixes 50 | - **Authenticator**: Showing the proper message when there's connectivity issues (#82) 51 | 52 | ### Misc. Updates 53 | - Updating code to support Amplify 2.35+. (#82) 54 | 55 | ## 1.1.3 (2024-06-04) 56 | 57 | ### Bug Fixes 58 | - **SignUp**: Sign in fails when user is auto confirmed after sign up (#72) 59 | 60 | ### Misc. Updates 61 | - Pinning the Amplify version up to 2.34.x 62 | 63 | ## 1.1.2 (2024-04-26) 64 | 65 | ### Bug Fixes 66 | 67 | - **AuthenticatorState**: Making `move(to:)` public (#66) 68 | - **ConfirmSignUp**: Updating the state's `deliveryDetails` property when a new code is sent (#65) 69 | 70 | ## 1.1.1 (2024-03-11) 71 | 72 | ### Bug Fixes 73 | - Fixing phone numbers containing special characters being rejected by Cognito (See [#56](https://github.com/aws-amplify/amplify-ui-swift-authenticator/pull/56)) 74 | 75 | ### Misc. Updates 76 | - Using the new `sendVerificationCode` API (See [#54](https://github.com/aws-amplify/amplify-ui-swift-authenticator/pull/54)) 77 | 78 | ## 1.1.0 (2023-11-01) 79 | 80 | ### Features 81 | - Adding TOTP support (See [#31](https://github.com/aws-amplify/amplify-ui-swift-authenticator/pull/43)) 82 | 83 | ## 1.0.6 (2023-09-14) 84 | 85 | ### Misc. Updates 86 | - Updating code to support Amplify 2.16+. However, **TOTP** workflows are **not** yet supported. 87 | 88 | ## 1.0.5 (2023-08-31) 89 | 90 | ### Bug Fixes 91 | - Fixing required Sign Up attributes being displayed as optionals 92 | - Fixing Sign Up fields not being populated when providing a `signUpContent` 93 | - Fixing DatePicker being interactable while invisible, plus not displaying previous dates. 94 | 95 | ## 1.0.4 (2023-08-22) 96 | ### Bug Fixes 97 | - Adding missing label when displaying a `.custom()` Sign Up field. 98 | 99 | ## 1.0.3 (2023-08-17) 100 | 101 | ### Bug Fixes 102 | - Fixing wrong date format being submitted when displaying `SignUpField` of type `date` (See [#31](https://github.com/aws-amplify/amplify-ui-swift-authenticator/pull/31)) 103 | 104 | ## 1.0.2 (2023-07-25) 105 | 106 | ### Misc. Updates 107 | - Pinning the Amplify version up to 2.15.x 108 | 109 | ## 1.0.1 (2023-06-15) 110 | 111 | ### Bug Fixes 112 | - Fixing issues with Sign Up fields (See [#25](https://github.com/aws-amplify/amplify-ui-swift-authenticator/pull/25)). 113 | - Removing duplicated fields in the array provided to `Authenticator.signUpFields(_:)` 114 | - Preventing fields of type `.phoneNumber` from saving an incomplete phone number if only the dialling code is set. 115 | - Fixing Xcode 15 beta compilation error (See [#24](https://github.com/aws-amplify/amplify-ui-swift-authenticator/pull/24), thanks @RowbotNZ!) 116 | 117 | 118 | ## 1.0.0 (2023-05-24) 119 | 120 | ### Initial release of Amplify UI Authenticator for Swift UI 121 | 122 | Amplify Authenticator provides a complete drop-in implementation of an authentication flow for your application using [Amplify Authentication](https://docs.amplify.aws/lib/auth/getting-started/q/platform/ios/). 123 | 124 | More information on setting up and using the component is in the [documentation](https://ui.docs.amplify.aws/swift/connected-components/authenticator). 125 | --------------------------------------------------------------------------------