diff --git a/authenticator/api/authenticator.api b/authenticator/api/authenticator.api index 1ab01e9f..97828a93 100644 --- a/authenticator/api/authenticator.api +++ b/authenticator/api/authenticator.api @@ -1,3 +1,7 @@ +public abstract interface class com/amplifyframework/ui/authenticator/AuthenticatorActionState { + public abstract fun getAction ()Ljava/lang/Object; +} + public abstract interface class com/amplifyframework/ui/authenticator/AuthenticatorState { public abstract fun getMessages ()Lkotlinx/coroutines/flow/Flow; public abstract fun getStepState ()Lcom/amplifyframework/ui/authenticator/AuthenticatorStepState; @@ -5,7 +9,7 @@ public abstract interface class com/amplifyframework/ui/authenticator/Authentica public final class com/amplifyframework/ui/authenticator/AuthenticatorStateKt { public static final fun rememberAuthenticatorState (Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorInitialStep;Lkotlin/jvm/functions/Function1;Landroidx/compose/runtime/Composer;II)Lcom/amplifyframework/ui/authenticator/AuthenticatorState; - public static final fun rememberAuthenticatorState (Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorInitialStep;Lkotlin/jvm/functions/Function1;Lcom/amplifyframework/ui/authenticator/options/TotpOptions;Landroidx/compose/runtime/Composer;II)Lcom/amplifyframework/ui/authenticator/AuthenticatorState; + public static final fun rememberAuthenticatorState (Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorInitialStep;Lkotlin/jvm/functions/Function1;Lcom/amplifyframework/ui/authenticator/options/TotpOptions;Lcom/amplifyframework/ui/authenticator/data/AuthenticationFlow;Landroidx/compose/runtime/Composer;II)Lcom/amplifyframework/ui/authenticator/AuthenticatorState; } public abstract interface class com/amplifyframework/ui/authenticator/AuthenticatorStepState { @@ -41,6 +45,19 @@ public final class com/amplifyframework/ui/authenticator/LoadingState : com/ampl public synthetic fun getStep ()Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep; } +public abstract interface class com/amplifyframework/ui/authenticator/PasskeyCreatedState : com/amplifyframework/ui/authenticator/AuthenticatorActionState, com/amplifyframework/ui/authenticator/AuthenticatorStepState { + public abstract fun continueSignIn (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun getPasskeys ()Ljava/util/List; +} + +public abstract interface class com/amplifyframework/ui/authenticator/PasskeyCreatedState$Action { +} + +public final class com/amplifyframework/ui/authenticator/PasskeyCreatedState$Action$ContinueSignIn : com/amplifyframework/ui/authenticator/PasskeyCreatedState$Action { + public static final field $stable I + public fun ()V +} + public abstract interface class com/amplifyframework/ui/authenticator/PasswordResetConfirmState : com/amplifyframework/ui/authenticator/AuthenticatorStepState { public abstract fun getDeliveryDetails ()Lcom/amplifyframework/auth/AuthCodeDeliveryDetails; public abstract fun getForm ()Lcom/amplifyframework/ui/authenticator/forms/MutableFormState; @@ -54,6 +71,24 @@ public abstract interface class com/amplifyframework/ui/authenticator/PasswordRe public abstract fun submitPasswordReset (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class com/amplifyframework/ui/authenticator/PromptToCreatePasskeyState : com/amplifyframework/ui/authenticator/AuthenticatorActionState, com/amplifyframework/ui/authenticator/AuthenticatorStepState { + public abstract fun createPasskey (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public abstract fun skip (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/amplifyframework/ui/authenticator/PromptToCreatePasskeyState$Action { +} + +public final class com/amplifyframework/ui/authenticator/PromptToCreatePasskeyState$Action$CreatePasskey : com/amplifyframework/ui/authenticator/PromptToCreatePasskeyState$Action { + public static final field $stable I + public fun ()V +} + +public final class com/amplifyframework/ui/authenticator/PromptToCreatePasskeyState$Action$Skip : com/amplifyframework/ui/authenticator/PromptToCreatePasskeyState$Action { + public static final field $stable I + public fun ()V +} + public abstract interface class com/amplifyframework/ui/authenticator/SignInConfirmCustomState : com/amplifyframework/ui/authenticator/AuthenticatorStepState { public abstract fun confirmSignIn (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getAdditionalInfo ()Ljava/util/Map; @@ -75,6 +110,13 @@ public abstract interface class com/amplifyframework/ui/authenticator/SignInConf public abstract fun moveTo (Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorInitialStep;)V } +public abstract interface class com/amplifyframework/ui/authenticator/SignInConfirmPasswordState : com/amplifyframework/ui/authenticator/AuthenticatorStepState { + public abstract fun getForm ()Lcom/amplifyframework/ui/authenticator/forms/MutableFormState; + public abstract fun getUsername ()Ljava/lang/String; + public abstract fun moveTo (Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorInitialStep;)V + public abstract fun signIn (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public abstract interface class com/amplifyframework/ui/authenticator/SignInConfirmTotpCodeState : com/amplifyframework/ui/authenticator/AuthenticatorStepState { public abstract fun confirmSignIn (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public abstract fun getForm ()Lcom/amplifyframework/ui/authenticator/forms/MutableFormState; @@ -109,6 +151,29 @@ public abstract interface class com/amplifyframework/ui/authenticator/SignInCont public abstract fun moveTo (Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorInitialStep;)V } +public abstract interface class com/amplifyframework/ui/authenticator/SignInSelectAuthFactorState : com/amplifyframework/ui/authenticator/AuthenticatorActionState, com/amplifyframework/ui/authenticator/AuthenticatorStepState { + public abstract fun getAvailableAuthFactors ()Ljava/util/Set; + public abstract fun getForm ()Lcom/amplifyframework/ui/authenticator/forms/MutableFormState; + public abstract fun getUsername ()Ljava/lang/String; + public abstract fun moveTo (Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorInitialStep;)V + public abstract fun select (Lcom/amplifyframework/ui/authenticator/data/AuthFactor;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public abstract interface class com/amplifyframework/ui/authenticator/SignInSelectAuthFactorState$Action { +} + +public final class com/amplifyframework/ui/authenticator/SignInSelectAuthFactorState$Action$SelectFactor : com/amplifyframework/ui/authenticator/SignInSelectAuthFactorState$Action { + public static final field $stable I + public fun (Lcom/amplifyframework/ui/authenticator/data/AuthFactor;)V + public final fun component1 ()Lcom/amplifyframework/ui/authenticator/data/AuthFactor; + public final fun copy (Lcom/amplifyframework/ui/authenticator/data/AuthFactor;)Lcom/amplifyframework/ui/authenticator/SignInSelectAuthFactorState$Action$SelectFactor; + public static synthetic fun copy$default (Lcom/amplifyframework/ui/authenticator/SignInSelectAuthFactorState$Action$SelectFactor;Lcom/amplifyframework/ui/authenticator/data/AuthFactor;ILjava/lang/Object;)Lcom/amplifyframework/ui/authenticator/SignInSelectAuthFactorState$Action$SelectFactor; + public fun equals (Ljava/lang/Object;)Z + public final fun getFactor ()Lcom/amplifyframework/ui/authenticator/data/AuthFactor; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class com/amplifyframework/ui/authenticator/SignInState : com/amplifyframework/ui/authenticator/AuthenticatorStepState { public abstract fun getForm ()Lcom/amplifyframework/ui/authenticator/forms/MutableFormState; public abstract fun moveTo (Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorInitialStep;)V @@ -149,6 +214,109 @@ public abstract interface class com/amplifyframework/ui/authenticator/VerifyUser public abstract fun verifyUser (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } +public abstract interface class com/amplifyframework/ui/authenticator/data/AuthFactor { +} + +public final class com/amplifyframework/ui/authenticator/data/AuthFactor$EmailOtp : com/amplifyframework/ui/authenticator/data/AuthFactor { + public static final field $stable I + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/data/AuthFactor$EmailOtp; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/amplifyframework/ui/authenticator/data/AuthFactor$Password : com/amplifyframework/ui/authenticator/data/AuthFactor { + public static final field $stable I + public fun ()V + public fun (Z)V + public synthetic fun (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun copy (Z)Lcom/amplifyframework/ui/authenticator/data/AuthFactor$Password; + public static synthetic fun copy$default (Lcom/amplifyframework/ui/authenticator/data/AuthFactor$Password;ZILjava/lang/Object;)Lcom/amplifyframework/ui/authenticator/data/AuthFactor$Password; + public fun equals (Ljava/lang/Object;)Z + public final fun getSrp ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/amplifyframework/ui/authenticator/data/AuthFactor$SmsOtp : com/amplifyframework/ui/authenticator/data/AuthFactor { + public static final field $stable I + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/data/AuthFactor$SmsOtp; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/amplifyframework/ui/authenticator/data/AuthFactor$WebAuthn : com/amplifyframework/ui/authenticator/data/AuthFactor { + public static final field $stable I + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/data/AuthFactor$WebAuthn; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/amplifyframework/ui/authenticator/data/AuthenticationFlow { +} + +public final class com/amplifyframework/ui/authenticator/data/AuthenticationFlow$Password : com/amplifyframework/ui/authenticator/data/AuthenticationFlow { + public static final field $stable I + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/data/AuthenticationFlow$Password; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/amplifyframework/ui/authenticator/data/AuthenticationFlow$UserChoice : com/amplifyframework/ui/authenticator/data/AuthenticationFlow { + public static final field $stable I + public fun ()V + public fun (Lcom/amplifyframework/ui/authenticator/data/AuthFactor;Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompts;)V + public synthetic fun (Lcom/amplifyframework/ui/authenticator/data/AuthFactor;Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompts;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/amplifyframework/ui/authenticator/data/AuthFactor; + public final fun component2 ()Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompts; + public final fun copy (Lcom/amplifyframework/ui/authenticator/data/AuthFactor;Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompts;)Lcom/amplifyframework/ui/authenticator/data/AuthenticationFlow$UserChoice; + public static synthetic fun copy$default (Lcom/amplifyframework/ui/authenticator/data/AuthenticationFlow$UserChoice;Lcom/amplifyframework/ui/authenticator/data/AuthFactor;Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompts;ILjava/lang/Object;)Lcom/amplifyframework/ui/authenticator/data/AuthenticationFlow$UserChoice; + public fun equals (Ljava/lang/Object;)Z + public final fun getPasskeyPrompts ()Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompts; + public final fun getPreferredAuthFactor ()Lcom/amplifyframework/ui/authenticator/data/AuthFactor; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/amplifyframework/ui/authenticator/data/PasskeyPrompt { +} + +public final class com/amplifyframework/ui/authenticator/data/PasskeyPrompt$Always : com/amplifyframework/ui/authenticator/data/PasskeyPrompt { + public static final field $stable I + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt$Always; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/amplifyframework/ui/authenticator/data/PasskeyPrompt$Never : com/amplifyframework/ui/authenticator/data/PasskeyPrompt { + public static final field $stable I + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt$Never; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/amplifyframework/ui/authenticator/data/PasskeyPrompts { + public static final field $stable I + public fun ()V + public fun (Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt;Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt;)V + public synthetic fun (Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt;Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt; + public final fun component2 ()Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt; + public final fun copy (Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt;Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt;)Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompts; + public static synthetic fun copy$default (Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompts;Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt;Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt;ILjava/lang/Object;)Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompts; + public fun equals (Ljava/lang/Object;)Z + public final fun getAfterSignIn ()Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt; + public final fun getAfterSignUp ()Lcom/amplifyframework/ui/authenticator/data/PasskeyPrompt; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract class com/amplifyframework/ui/authenticator/enums/AuthenticatorInitialStep : com/amplifyframework/ui/authenticator/enums/AuthenticatorStep { public static final field $stable I } @@ -167,6 +335,16 @@ public final class com/amplifyframework/ui/authenticator/enums/AuthenticatorStep public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep$Loading; } +public final class com/amplifyframework/ui/authenticator/enums/AuthenticatorStep$PasskeyCreated : com/amplifyframework/ui/authenticator/enums/AuthenticatorStep { + public static final field $stable I + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep$PasskeyCreated; +} + +public final class com/amplifyframework/ui/authenticator/enums/AuthenticatorStep$PasskeyCreationPrompt : com/amplifyframework/ui/authenticator/enums/AuthenticatorStep { + public static final field $stable I + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep$PasskeyCreationPrompt; +} + public final class com/amplifyframework/ui/authenticator/enums/AuthenticatorStep$PasswordReset : com/amplifyframework/ui/authenticator/enums/AuthenticatorInitialStep { public static final field $stable I public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep$PasswordReset; @@ -197,6 +375,11 @@ public final class com/amplifyframework/ui/authenticator/enums/AuthenticatorStep public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep$SignInConfirmNewPassword; } +public final class com/amplifyframework/ui/authenticator/enums/AuthenticatorStep$SignInConfirmPassword : com/amplifyframework/ui/authenticator/enums/AuthenticatorStep { + public static final field $stable I + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep$SignInConfirmPassword; +} + public final class com/amplifyframework/ui/authenticator/enums/AuthenticatorStep$SignInConfirmTotpCode : com/amplifyframework/ui/authenticator/enums/AuthenticatorStep { public static final field $stable I public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep$SignInConfirmTotpCode; @@ -222,6 +405,11 @@ public final class com/amplifyframework/ui/authenticator/enums/AuthenticatorStep public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep$SignInContinueWithTotpSetup; } +public final class com/amplifyframework/ui/authenticator/enums/AuthenticatorStep$SignInSelectAuthFactor : com/amplifyframework/ui/authenticator/enums/AuthenticatorStep { + public static final field $stable I + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep$SignInSelectAuthFactor; +} + public final class com/amplifyframework/ui/authenticator/enums/AuthenticatorStep$SignUp : com/amplifyframework/ui/authenticator/enums/AuthenticatorInitialStep { public static final field $stable I public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep$SignUp; @@ -612,7 +800,8 @@ public abstract interface class com/amplifyframework/ui/authenticator/forms/Pass public abstract interface class com/amplifyframework/ui/authenticator/forms/SignUpFormBuilder { public abstract fun birthdate (Z)V public static synthetic fun birthdate$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V - public abstract fun confirmPassword ()V + public abstract fun confirmPassword (Z)V + public static synthetic fun confirmPassword$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V public abstract fun custom (Lcom/amplifyframework/ui/authenticator/forms/FieldKey;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;)V public static synthetic fun custom$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;Lcom/amplifyframework/ui/authenticator/forms/FieldKey;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)V public abstract fun date (Lcom/amplifyframework/ui/authenticator/forms/FieldKey;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V @@ -629,7 +818,8 @@ public abstract interface class com/amplifyframework/ui/authenticator/forms/Sign public static synthetic fun name$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V public abstract fun nickname (Z)V public static synthetic fun nickname$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V - public abstract fun password ()V + public abstract fun password (Z)V + public static synthetic fun password$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V public abstract fun phone (Lcom/amplifyframework/ui/authenticator/forms/FieldKey;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)V public static synthetic fun phone$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;Lcom/amplifyframework/ui/authenticator/forms/FieldKey;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public abstract fun phoneNumber (Z)V @@ -647,6 +837,7 @@ public abstract interface class com/amplifyframework/ui/authenticator/forms/Sign public final class com/amplifyframework/ui/authenticator/forms/SignUpFormBuilder$DefaultImpls { public static synthetic fun birthdate$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V + public static synthetic fun confirmPassword$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V public static synthetic fun custom$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;Lcom/amplifyframework/ui/authenticator/forms/FieldKey;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)V public static synthetic fun date$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;Lcom/amplifyframework/ui/authenticator/forms/FieldKey;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public static synthetic fun email$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V @@ -655,6 +846,7 @@ public final class com/amplifyframework/ui/authenticator/forms/SignUpFormBuilder public static synthetic fun middleName$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V public static synthetic fun name$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V public static synthetic fun nickname$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V + public static synthetic fun password$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V public static synthetic fun phone$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;Lcom/amplifyframework/ui/authenticator/forms/FieldKey;Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)V public static synthetic fun phoneNumber$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V public static synthetic fun preferredUsername$default (Lcom/amplifyframework/ui/authenticator/forms/SignUpFormBuilder;ZILjava/lang/Object;)V @@ -677,52 +869,63 @@ public final class com/amplifyframework/ui/authenticator/options/TotpOptions { public fun toString ()Ljava/lang/String; } +public final class com/amplifyframework/ui/authenticator/states/PromptToCreatePasskeyStateImpl : com/amplifyframework/ui/authenticator/PromptToCreatePasskeyState, com/amplifyframework/ui/authenticator/states/MutableActionState { + public static final field $stable I + public fun (Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public fun createPasskey (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun getAction ()Lcom/amplifyframework/ui/authenticator/PromptToCreatePasskeyState$Action; + public synthetic fun getAction ()Ljava/lang/Object; + public fun getStep ()Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep$PasskeyCreationPrompt; + public synthetic fun getStep ()Lcom/amplifyframework/ui/authenticator/enums/AuthenticatorStep; + public fun setAction (Lcom/amplifyframework/ui/authenticator/PromptToCreatePasskeyState$Action;)V + public synthetic fun setAction (Ljava/lang/Object;)V + public fun skip (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class com/amplifyframework/ui/authenticator/ui/AuthenticatorErrorKt { public static final fun AuthenticatorError (Lcom/amplifyframework/ui/authenticator/ErrorState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } public final class com/amplifyframework/ui/authenticator/ui/AuthenticatorKt { - public static final fun Authenticator (Landroidx/compose/ui/Modifier;Lcom/amplifyframework/ui/authenticator/AuthenticatorState;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V + public static final fun Authenticator (Landroidx/compose/ui/Modifier;Lcom/amplifyframework/ui/authenticator/AuthenticatorState;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;IIII)V } public final class com/amplifyframework/ui/authenticator/ui/AuthenticatorLoadingKt { public static final fun AuthenticatorLoading (Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } -public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorFormKt { - public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorFormKt; +public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorButtonKt { + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorButtonKt; public fun ()V - public final fun getLambda$559431668$authenticator_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-183151115$authenticator_release ()Lkotlin/jvm/functions/Function2; } public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorKt { public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$AuthenticatorKt; public fun ()V - public final fun getLambda$-1364811249$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-1534614155$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-1534995609$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-1589760345$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-173861027$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-1772037977$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-1828710841$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-35945593$authenticator_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$-655729473$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$-70309625$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$127509735$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$1684084125$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$1696024992$authenticator_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$1943575719$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$2094400221$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$614972265$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$634797031$authenticator_release ()Lkotlin/jvm/functions/Function3; - public final fun getLambda$711026261$authenticator_release ()Lkotlin/jvm/functions/Function2; - public final fun getLambda$792593797$authenticator_release ()Lkotlin/jvm/functions/Function3; -} - -public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$CommonFooterKt { - public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$CommonFooterKt; - public fun ()V - public final fun getLambda$-744346671$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-1098997081$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-1185569707$authenticator_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$-1321237603$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-1500805657$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-1564461377$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-489643147$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-829100313$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-901777241$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1086843975$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1121453405$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1279389511$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1463924539$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1517396039$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1704729959$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$178565481$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1944093671$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$2056608783$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$2077124263$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$425485189$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$484030880$authenticator_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$524324231$authenticator_release ()Lkotlin/jvm/functions/Function2; + public final fun getLambda$810365703$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$883421405$authenticator_release ()Lkotlin/jvm/functions/Function3; } public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$DateInputFieldKt { @@ -732,6 +935,13 @@ public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons public final fun getLambda$-1322695825$authenticator_release ()Lkotlin/jvm/functions/Function2; } +public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$PasskeyCreatedKt { + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$PasskeyCreatedKt; + public fun ()V + public final fun getLambda$-1577436357$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1794672713$authenticator_release ()Lkotlin/jvm/functions/Function3; +} + public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$PasswordResetConfirmKt { public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$PasswordResetConfirmKt; public fun ()V @@ -757,6 +967,13 @@ public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons public final fun getLambda$977961286$authenticator_release ()Lkotlin/jvm/functions/Function2; } +public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$PromptToCreatePasskeyKt { + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$PromptToCreatePasskeyKt; + public fun ()V + public final fun getLambda$280783377$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$445145695$authenticator_release ()Lkotlin/jvm/functions/Function3; +} + public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$SignInConfirmCustomKt { public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$SignInConfirmCustomKt; public fun ()V @@ -783,6 +1000,13 @@ public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons public final fun getLambda$2054432399$authenticator_release ()Lkotlin/jvm/functions/Function3; } +public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$SignInConfirmPasswordKt { + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$SignInConfirmPasswordKt; + public fun ()V + public final fun getLambda$-2137214933$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$1993390045$authenticator_release ()Lkotlin/jvm/functions/Function3; +} + public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$SignInConfirmTotpCodeKt { public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$SignInConfirmTotpCodeKt; public fun ()V @@ -828,6 +1052,13 @@ public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons public final fun getLambda$334014841$authenticator_release ()Lkotlin/jvm/functions/Function3; } +public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$SignInSelectAuthFactorKt { + public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$SignInSelectAuthFactorKt; + public fun ()V + public final fun getLambda$-600095515$authenticator_release ()Lkotlin/jvm/functions/Function3; + public final fun getLambda$-855193613$authenticator_release ()Lkotlin/jvm/functions/Function3; +} + public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons$SignUpConfirmKt { public static final field INSTANCE Lcom/amplifyframework/ui/authenticator/ui/ComposableSingletons$SignUpConfirmKt; public fun ()V @@ -864,6 +1095,10 @@ public final class com/amplifyframework/ui/authenticator/ui/ComposableSingletons public final fun getLambda$417795943$authenticator_release ()Lkotlin/jvm/functions/Function3; } +public final class com/amplifyframework/ui/authenticator/ui/PasskeyCreatedKt { + public static final fun PasskeyCreated (Lcom/amplifyframework/ui/authenticator/PasskeyCreatedState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + public final class com/amplifyframework/ui/authenticator/ui/PasswordResetConfirmKt { public static final fun PasswordResetConfirm (Lcom/amplifyframework/ui/authenticator/PasswordResetConfirmState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V public static final fun PasswordResetConfirmFooter (Lcom/amplifyframework/ui/authenticator/PasswordResetConfirmState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V @@ -874,6 +1109,10 @@ public final class com/amplifyframework/ui/authenticator/ui/PasswordResetKt { public static final fun PasswordResetFooter (Lcom/amplifyframework/ui/authenticator/PasswordResetState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } +public final class com/amplifyframework/ui/authenticator/ui/PromptToCreatePasskeyKt { + public static final fun PromptToCreatePasskey (Lcom/amplifyframework/ui/authenticator/PromptToCreatePasskeyState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V +} + public final class com/amplifyframework/ui/authenticator/ui/SignInConfirmCustomKt { public static final fun SignInConfirmCustom (Lcom/amplifyframework/ui/authenticator/SignInConfirmCustomState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V public static final fun SignInConfirmCustomFooter (Lcom/amplifyframework/ui/authenticator/SignInConfirmCustomState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V @@ -889,6 +1128,11 @@ public final class com/amplifyframework/ui/authenticator/ui/SignInConfirmNewPass public static final fun SignInConfirmNewPasswordFooter (Lcom/amplifyframework/ui/authenticator/SignInConfirmNewPasswordState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V } +public final class com/amplifyframework/ui/authenticator/ui/SignInConfirmPasswordKt { + public static final fun SignInConfirmPassword (Lcom/amplifyframework/ui/authenticator/SignInConfirmPasswordState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun SignInConfirmPasswordFooter (Lcom/amplifyframework/ui/authenticator/SignInConfirmPasswordState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + public final class com/amplifyframework/ui/authenticator/ui/SignInConfirmTotpCodeKt { public static final fun SignInConfirmTotpCode (Lcom/amplifyframework/ui/authenticator/SignInConfirmTotpCodeState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V public static final fun SignInConfirmTotpCodeFooter (Lcom/amplifyframework/ui/authenticator/SignInConfirmTotpCodeState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V @@ -919,6 +1163,11 @@ public final class com/amplifyframework/ui/authenticator/ui/SignInKt { public static final fun SignInFooter (Lcom/amplifyframework/ui/authenticator/SignInState;Landroidx/compose/ui/Modifier;ZLandroidx/compose/runtime/Composer;II)V } +public final class com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactorKt { + public static final fun SignInSelectAuthFactor (Lcom/amplifyframework/ui/authenticator/SignInSelectAuthFactorState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V + public static final fun SignInSelectAuthFactorFooter (Lcom/amplifyframework/ui/authenticator/SignInSelectAuthFactorState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V +} + public final class com/amplifyframework/ui/authenticator/ui/SignUpConfirmKt { public static final fun SignUpConfirm (Lcom/amplifyframework/ui/authenticator/SignUpConfirmState;Landroidx/compose/ui/Modifier;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Lkotlin/jvm/functions/Function3;Landroidx/compose/runtime/Composer;II)V public static final fun SignUpConfirmFooter (Lcom/amplifyframework/ui/authenticator/SignUpConfirmState;Landroidx/compose/ui/Modifier;Landroidx/compose/runtime/Composer;II)V diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorConfiguration.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorConfiguration.kt index 6c6a72e0..2af20eca 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorConfiguration.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorConfiguration.kt @@ -15,6 +15,7 @@ package com.amplifyframework.ui.authenticator +import com.amplifyframework.ui.authenticator.data.AuthenticationFlow import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep import com.amplifyframework.ui.authenticator.forms.SignUpFormBuilder import com.amplifyframework.ui.authenticator.options.TotpOptions @@ -22,5 +23,6 @@ import com.amplifyframework.ui.authenticator.options.TotpOptions internal data class AuthenticatorConfiguration( val initialStep: AuthenticatorInitialStep, val signUpForm: SignUpFormBuilder.() -> Unit, - val totpOptions: TotpOptions? + val totpOptions: TotpOptions?, + val authenticationFlow: AuthenticationFlow ) diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorState.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorState.kt index 92528be7..3893a160 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorState.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorState.kt @@ -22,12 +22,15 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel +import com.amplifyframework.ui.authenticator.data.AuthenticationFlow import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep import com.amplifyframework.ui.authenticator.forms.SignUpFormBuilder import com.amplifyframework.ui.authenticator.options.TotpOptions import com.amplifyframework.ui.authenticator.util.AuthenticatorMessage +import com.amplifyframework.ui.authenticator.util.findActivity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -46,18 +49,22 @@ import kotlinx.coroutines.flow.onEach fun rememberAuthenticatorState( initialStep: AuthenticatorInitialStep = AuthenticatorStep.SignIn, signUpForm: SignUpFormBuilder.() -> Unit = {}, - totpOptions: TotpOptions? = null + totpOptions: TotpOptions? = null, + authenticationFlow: AuthenticationFlow = AuthenticationFlow.Password ): AuthenticatorState { val viewModel = viewModel() val scope = rememberCoroutineScope() + val context = LocalContext.current + return remember { val configuration = AuthenticatorConfiguration( initialStep = initialStep, signUpForm = signUpForm, - totpOptions = totpOptions + totpOptions = totpOptions, + authenticationFlow = authenticationFlow ) - viewModel.start(configuration) + viewModel.start(configuration, context.findActivity()) AuthenticatorStateImpl(viewModel).also { state -> viewModel.stepState.onEach { state.stepState = it }.launchIn(scope) } @@ -102,9 +109,7 @@ interface AuthenticatorState { val messages: Flow } -internal class AuthenticatorStateImpl constructor( - private val viewModel: AuthenticatorViewModel -) : AuthenticatorState { +internal class AuthenticatorStateImpl constructor(private val viewModel: AuthenticatorViewModel) : AuthenticatorState { override var stepState by mutableStateOf(LoadingState) override val messages: Flow diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt index 483dc409..b70e37e8 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorStepState.kt @@ -23,6 +23,8 @@ import com.amplifyframework.auth.AuthUser import com.amplifyframework.auth.AuthUserAttribute import com.amplifyframework.auth.MFAType import com.amplifyframework.auth.result.AuthSignOutResult +import com.amplifyframework.auth.result.AuthWebAuthnCredential +import com.amplifyframework.ui.authenticator.data.AuthFactor import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep import com.amplifyframework.ui.authenticator.forms.MutableFormState @@ -38,6 +40,17 @@ interface AuthenticatorStepState { val step: AuthenticatorStep } +/** + * A state holder for the UI that has multiple possible actions that may be in progress. + */ +@Stable +interface AuthenticatorActionState { + /** + * The action in progress, or null if state is idle + */ + val action: T? +} + /** * The Authenticator is loading the current state of the user's Auth session. */ @@ -93,6 +106,73 @@ interface SignInState : AuthenticatorStepState { suspend fun signIn() } +/** + * The user has entered their username and must select the authentication factor they'd like to use to sign in + */ +@Stable +interface SignInSelectAuthFactorState : + AuthenticatorStepState, + AuthenticatorActionState { + + sealed interface Action { + /** + * User has selected an auth factor + */ + data class SelectAuthFactor(val factor: AuthFactor) : Action + } + + /** + * The input form state holder for this step. + */ + val form: MutableFormState + + /** + * The username entered in the SignIn step + */ + val username: String + + /** + * The available types to select how to sign in. + */ + val availableAuthFactors: Set + + /** + * Move the user to a different [AuthenticatorInitialStep]. + */ + fun moveTo(step: AuthenticatorInitialStep) + + /** + * Initiate a sign in with one of the available sign in types + */ + suspend fun select(authFactor: AuthFactor) +} + +/** + * A user has entered their username and must enter their password to continue signing in + */ +@Stable +interface SignInConfirmPasswordState : AuthenticatorStepState { + /** + * The input form state holder for this step. + */ + val form: MutableFormState + + /** + * The username entered in the SignIn step + */ + val username: String + + /** + * Move the user to a different [AuthenticatorInitialStep]. + */ + fun moveTo(step: AuthenticatorInitialStep) + + /** + * Initiate a sign in with the information entered into the [form]. + */ + suspend fun signIn() +} + /** * The user has completed the initial Sign In step, and needs to enter the confirmation code from an MFA * message to complete the sign in process. @@ -460,3 +540,59 @@ interface VerifyUserConfirmState : AuthenticatorStepState { */ fun skip() } + +/** + * The user is being shown a prompt to create a passkey, encouraging them to use this as a way to sign in quickly + * via biometrics + */ +@Stable +interface PromptToCreatePasskeyState : + AuthenticatorStepState, + AuthenticatorActionState { + sealed interface Action { + /** + * User is creating a passkey + */ + class CreatePasskey : Action + + /** + * User has selected the Skip button + */ + class Skip : Action + } + + /** + * Create a passkey + */ + suspend fun createPasskey() + + /** + * Skip passkey creation and continue to the next step + */ + suspend fun skip() +} + +/** + * The user is being shown a confirmation screen after creating a passkey + */ +@Stable +interface PasskeyCreatedState : + AuthenticatorStepState, + AuthenticatorActionState { + sealed interface Action { + /** + * User has selected the Done button + */ + class ContinueSignIn : Action + } + + /** + * A list of existing passkeys for this user, including the one they've just created + */ + val passkeys: List + + /** + * Continue to the next step + */ + suspend fun continueSignIn() +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModel.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModel.kt index b16a7d05..8144d6c9 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModel.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModel.kt @@ -15,11 +15,14 @@ package com.amplifyframework.ui.authenticator +import android.app.Activity import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.amplifyframework.AmplifyException import com.amplifyframework.auth.AuthChannelEventName import com.amplifyframework.auth.AuthException +import com.amplifyframework.auth.AuthUser import com.amplifyframework.auth.AuthUserAttribute import com.amplifyframework.auth.AuthUserAttributeKey import com.amplifyframework.auth.MFAType @@ -32,9 +35,12 @@ import com.amplifyframework.auth.cognito.exceptions.service.InvalidParameterExce import com.amplifyframework.auth.cognito.exceptions.service.InvalidPasswordException import com.amplifyframework.auth.cognito.exceptions.service.LimitExceededException import com.amplifyframework.auth.cognito.exceptions.service.PasswordResetRequiredException +import com.amplifyframework.auth.cognito.exceptions.service.UserCancelledException import com.amplifyframework.auth.cognito.exceptions.service.UserNotConfirmedException import com.amplifyframework.auth.cognito.exceptions.service.UserNotFoundException import com.amplifyframework.auth.cognito.exceptions.service.UsernameExistsException +import com.amplifyframework.auth.cognito.options.AWSCognitoAuthConfirmSignInOptions +import com.amplifyframework.auth.cognito.options.AWSCognitoAuthSignInOptions import com.amplifyframework.auth.exceptions.NotAuthorizedException import com.amplifyframework.auth.exceptions.SessionExpiredException import com.amplifyframework.auth.exceptions.UnknownException @@ -50,8 +56,15 @@ import com.amplifyframework.ui.authenticator.auth.AmplifyAuthConfiguration import com.amplifyframework.ui.authenticator.auth.toAttributeKey import com.amplifyframework.ui.authenticator.auth.toFieldKey import com.amplifyframework.ui.authenticator.auth.toVerifiedAttributeKey +import com.amplifyframework.ui.authenticator.data.AuthFactor +import com.amplifyframework.ui.authenticator.data.AuthenticationFlow +import com.amplifyframework.ui.authenticator.data.UserInfo +import com.amplifyframework.ui.authenticator.data.challengeResponse +import com.amplifyframework.ui.authenticator.data.toAuthFactors +import com.amplifyframework.ui.authenticator.data.toAuthFlowType import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep +import com.amplifyframework.ui.authenticator.enums.SignInSource import com.amplifyframework.ui.authenticator.forms.FieldError import com.amplifyframework.ui.authenticator.forms.FieldError.ConfirmationCodeIncorrect import com.amplifyframework.ui.authenticator.forms.FieldError.FieldValueExists @@ -65,6 +78,7 @@ import com.amplifyframework.ui.authenticator.states.BaseStateImpl import com.amplifyframework.ui.authenticator.states.StepStateFactory import com.amplifyframework.ui.authenticator.util.AmplifyResult import com.amplifyframework.ui.authenticator.util.AuthConfigurationResult +import com.amplifyframework.ui.authenticator.util.AuthFlowSessionExpiredMessage import com.amplifyframework.ui.authenticator.util.AuthProvider import com.amplifyframework.ui.authenticator.util.AuthenticatorMessage import com.amplifyframework.ui.authenticator.util.CannotSendCodeMessage @@ -75,13 +89,21 @@ import com.amplifyframework.ui.authenticator.util.InvalidLoginMessage import com.amplifyframework.ui.authenticator.util.LimitExceededMessage import com.amplifyframework.ui.authenticator.util.MissingConfigurationException import com.amplifyframework.ui.authenticator.util.NetworkErrorMessage +import com.amplifyframework.ui.authenticator.util.PasskeyCreationFailedMessage +import com.amplifyframework.ui.authenticator.util.PasskeyPromptCheck import com.amplifyframework.ui.authenticator.util.PasswordResetMessage import com.amplifyframework.ui.authenticator.util.RealAuthProvider import com.amplifyframework.ui.authenticator.util.UnableToResetPasswordMessage import com.amplifyframework.ui.authenticator.util.UnknownErrorMessage import com.amplifyframework.ui.authenticator.util.UnsupportedNextStepException +import com.amplifyframework.ui.authenticator.util.authFlow +import com.amplifyframework.ui.authenticator.util.callingActivity +import com.amplifyframework.ui.authenticator.util.getOrDefault +import com.amplifyframework.ui.authenticator.util.isAuthFlowSessionExpiredError import com.amplifyframework.ui.authenticator.util.isConnectivityIssue +import com.amplifyframework.ui.authenticator.util.preferredFirstFactor import com.amplifyframework.ui.authenticator.util.toFieldError +import java.lang.ref.WeakReference import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -91,8 +113,12 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jetbrains.annotations.VisibleForTesting -internal class AuthenticatorViewModel(application: Application, private val authProvider: AuthProvider) : - AndroidViewModel(application) { +internal class AuthenticatorViewModel( + application: Application, + private val authProvider: AuthProvider, + private val passkeyCheck: PasskeyPromptCheck = PasskeyPromptCheck(authProvider) +) : AndroidViewModel(application) { + // Constructor for compose viewModels provider constructor(application: Application) : this(application, RealAuthProvider()) @@ -121,7 +147,16 @@ internal class AuthenticatorViewModel(application: Application, private val auth // Is there a current Amplify call in progress that could result in a signed in event? private var expectingSignInEvent: Boolean = false - fun start(configuration: AuthenticatorConfiguration) { + // The current activity is used for WebAuthn sign-in when using passwordless functionality + private var activityReference: WeakReference = WeakReference(null) + private var activity: Activity? + get() = activityReference.get() + set(value) { + activityReference = WeakReference(value) + } + + fun start(configuration: AuthenticatorConfiguration, activity: Activity?) { + this.activity = activity if (::configuration.isInitialized) { return } @@ -144,6 +179,7 @@ internal class AuthenticatorViewModel(application: Application, private val auth } stateFactory = StepStateFactory( + configuration, authConfiguration, buildForm(configuration.signUpForm), ::moveTo @@ -187,22 +223,23 @@ internal class AuthenticatorViewModel(application: Application, private val auth //region SignUp @VisibleForTesting - suspend fun signUp(username: String, password: String, attributes: List) { + suspend fun signUp(username: String, password: String?, attributes: List) { viewModelScope.launch { val options = AuthSignUpOptions.builder().userAttributes(attributes).build() + val info = UserInfo(username = username, password = password, signInSource = SignInSource.AutoSignIn) when (val result = authProvider.signUp(username, password, options)) { is AmplifyResult.Error -> handleSignUpFailure(result.error) - is AmplifyResult.Success -> handleSignUpSuccess(username, password, result.data) + is AmplifyResult.Success -> handleSignUpSuccess(info, result.data) } }.join() } - private suspend fun confirmSignUp(username: String, password: String, code: String) { + private suspend fun confirmSignUp(info: UserInfo, code: String) { viewModelScope.launch { - when (val result = authProvider.confirmSignUp(username, code)) { + when (val result = authProvider.confirmSignUp(info.username, code)) { is AmplifyResult.Error -> handleSignUpConfirmFailure(result.error) - is AmplifyResult.Success -> handleSignUpSuccess(username, password, result.data) + is AmplifyResult.Success -> handleSignUpSuccess(info, result.data) } }.join() } @@ -221,18 +258,18 @@ internal class AuthenticatorViewModel(application: Application, private val auth private suspend fun handleSignUpConfirmFailure(error: AuthException) = handleAuthException(error) - private suspend fun handleSignUpSuccess(username: String, password: String, result: AuthSignUpResult) { + private suspend fun handleSignUpSuccess(info: UserInfo, result: AuthSignUpResult) { when (result.nextStep.signUpStep) { AuthSignUpStep.CONFIRM_SIGN_UP_STEP -> { val newState = stateFactory.newSignUpConfirmState( result.nextStep.codeDeliveryDetails, - onResendCode = { resendSignUpCode(username) }, - onSubmit = { confirmationCode -> confirmSignUp(username, password, confirmationCode) } + onResendCode = { resendSignUpCode(info.username) }, + onSubmit = { confirmationCode -> confirmSignUp(info, confirmationCode) } ) moveTo(newState) } - AuthSignUpStep.DONE -> handleSignedUp(username, password) - AuthSignUpStep.COMPLETE_AUTO_SIGN_IN -> handleAutoSignIn(username, password) + AuthSignUpStep.COMPLETE_AUTO_SIGN_IN -> handleAutoSignIn(info) + AuthSignUpStep.DONE -> handleSignedUp(info) else -> { // Generic error for any other next steps that may be added in the future val exception = AuthException( @@ -245,31 +282,26 @@ internal class AuthenticatorViewModel(application: Application, private val auth } } - private suspend fun handleAutoSignIn(username: String, password: String) { - startSignInJob { - when (val result = authProvider.autoSignIn()) { - is AmplifyResult.Error -> { - // If auto sign in fails then proceed with manually trying to sign in the user. If this also fails the - // user will end up back on the sign in screen. - logger.warn("Unable to complete auto-signIn") - handleSignedUp(username, password) - } - - is AmplifyResult.Success -> handleSignInSuccess(username, password, result.data) + private suspend fun handleAutoSignIn(info: UserInfo) = startSignInJob { + when (val result = authProvider.autoSignIn()) { + is AmplifyResult.Error -> { + // If auto sign in fails then proceed with manually trying to sign in the user. If this also fails the + // user will end up back on the sign in screen. + logger.warn("Unable to complete auto-signIn") + handleSignedUp(info) } + is AmplifyResult.Success -> handleSignInSuccess(info, result.data) } } - private suspend fun handleSignedUp(username: String, password: String) { - startSignInJob { - when (val result = authProvider.signIn(username, password)) { - is AmplifyResult.Error -> { - moveTo(AuthenticatorStep.SignIn) - handleSignInFailure(username, password, result.error) - } - - is AmplifyResult.Success -> handleSignInSuccess(username, password, result.data) + private suspend fun handleSignedUp(info: UserInfo) = startSignInJob { + val options = getSignInOptions() + when (val result = authProvider.signIn(info.username, info.password, options)) { + is AmplifyResult.Error -> { + moveTo(AuthenticatorStep.SignIn) + handleSignInFailure(info, result.error) } + is AmplifyResult.Success -> handleSignInSuccess(info, result.data) } } @@ -277,54 +309,86 @@ internal class AuthenticatorViewModel(application: Application, private val auth //region SignIn @VisibleForTesting - suspend fun signIn(username: String, password: String) { - startSignInJob { - when (val result = authProvider.signIn(username, password)) { - is AmplifyResult.Error -> handleSignInFailure(username, password, result.error) - is AmplifyResult.Success -> handleSignInSuccess(username, password, result.data) - } + suspend fun signIn(username: String, password: String?) { + val info = UserInfo( + username = username, + password = password, + signInSource = SignInSource.SignIn + ) + startSignIn(info) + } + + private suspend fun startSignIn(info: UserInfo, preferredFirstFactorOverride: AuthFactor? = null) = startSignInJob { + val options = getSignInOptions(preferredFirstFactorOverride = preferredFirstFactorOverride) + when (val result = authProvider.signIn(info.username, info.password, options)) { + is AmplifyResult.Error -> handleSignInFailure(info, result.error) + is AmplifyResult.Success -> handleSignInSuccess(info, result.data) } } - private suspend fun confirmSignIn(username: String, password: String, challengeResponse: String) { - startSignInJob { - when (val result = authProvider.confirmSignIn(challengeResponse)) { - is AmplifyResult.Error -> handleSignInFailure(username, password, result.error) - is AmplifyResult.Success -> handleSignInSuccess(username, password, result.data) - } + private fun getSignInOptions(preferredFirstFactorOverride: AuthFactor? = null) = + AWSCognitoAuthSignInOptions.builder() + .authFlow(configuration.authenticationFlow.toAuthFlowType()) + .callingActivity(activity) + .preferredFirstFactor(configuration.authenticationFlow, preferredFirstFactorOverride) + .build() + + private suspend fun confirmSignIn(info: UserInfo, challengeResponse: String) = startSignInJob { + val options = AWSCognitoAuthConfirmSignInOptions.builder() + .callingActivity(activity) + .build() + when (val result = authProvider.confirmSignIn(challengeResponse, options)) { + is AmplifyResult.Error -> handleConfirmSignInFailure(info, result.error) + is AmplifyResult.Success -> handleSignInSuccess(info, result.data) } } - private suspend fun setNewSignInPassword(username: String, password: String) { - startSignInJob { - when (val result = authProvider.confirmSignIn(password)) { - // an error here is more similar to a sign up error - is AmplifyResult.Error -> handleSignUpFailure(result.error) - is AmplifyResult.Success -> handleSignInSuccess(username, password, result.data) + private suspend fun setNewSignInPassword(info: UserInfo, newPassword: String) = startSignInJob { + val options = AWSCognitoAuthConfirmSignInOptions.builder() + .callingActivity(activity) + .build() + when (val result = authProvider.confirmSignIn(newPassword, options)) { + // an error here is more similar to a sign up error + is AmplifyResult.Error -> handleSignUpFailure(result.error) + is AmplifyResult.Success -> { + val newUserInfo = info.copy(password = newPassword) + handleSignInSuccess(newUserInfo, result.data) } } } - private suspend fun handleSignInFailure(username: String, password: String, error: AuthException) { + private suspend fun handleSignInFailure(info: UserInfo, error: AuthException) { // UserNotConfirmed and PasswordResetRequired are special cases where we need // to enter different flows when (error) { - is UserNotConfirmedException -> handleUnconfirmedSignIn(username, password) - is PasswordResetRequiredException -> handleResetRequiredSignIn(username) + is UserCancelledException -> Unit // This is an expected error, user can simply retry + is UserNotConfirmedException -> handleUnconfirmedSignIn(info) + is PasswordResetRequiredException -> handleResetRequiredSignIn(info.username) is NotAuthorizedException -> sendMessage(InvalidLoginMessage(error)) else -> handleAuthException(error) } } - private suspend fun handleUnconfirmedSignIn(username: String, password: String) { - when (val result = authProvider.resendSignUpCode(username)) { + private suspend fun handleConfirmSignInFailure(info: UserInfo, error: AuthException) { + if (configuration.authenticationFlow is AuthenticationFlow.UserChoice && + error.isAuthFlowSessionExpiredError() + ) { + moveTo(AuthenticatorStep.SignIn) + sendMessage(AuthFlowSessionExpiredMessage(error)) + } else { + handleSignInFailure(info, error) + } + } + + private suspend fun handleUnconfirmedSignIn(info: UserInfo) { + when (val result = authProvider.resendSignUpCode(info.username)) { is AmplifyResult.Error -> handleAuthException(result.error) is AmplifyResult.Success -> { val details = result.data val newState = stateFactory.newSignUpConfirmState( details, - onResendCode = { resendSignUpCode(username) }, - onSubmit = { confirmationCode -> confirmSignUp(username, password, confirmationCode) } + onResendCode = { resendSignUpCode(info.username) }, + onSubmit = { confirmationCode -> confirmSignUp(info, confirmationCode) } ) moveTo(newState) } @@ -338,106 +402,168 @@ internal class AuthenticatorViewModel(application: Application, private val auth } } - private suspend fun handleTotpSetupRequired( - username: String, - password: String, - totpSetupDetails: TOTPSetupDetails? - ) { + private suspend fun handleFactorSelectionRequired(info: UserInfo, availableFactors: Set?) { + if (availableFactors == null) { + val exception = + AuthException("Missing available AuthFactorTypes", AmplifyException.REPORT_BUG_TO_AWS_SUGGESTION) + handleGeneralFailure(exception) + return + } + + // Auto-select a single auth factor + if (availableFactors.size == 1) { + val newInfo = info.copy(selectedAuthFactor = availableFactors.first()) + confirmSignIn(newInfo, availableFactors.first().challengeResponse) + return + } + + // User has not selected an auth factor yet. + // We need to keep track of a mutating selection here as `onSelect` may be called multiple times as user + // retries after encountering an error (e.g. incorrect password, passkey error, etc). + var currentUserInfo = info.copy(selectedAuthFactor = null) + + val newState = stateFactory.newSignInSelectFactorState( + username = info.username, + availableFactors = availableFactors, + onSelect = { authFactor -> + val password = if (authFactor is AuthFactor.Password) { + val passwordField = (currentState as? BaseStateImpl)?.form?.fields?.get(Password) + passwordField?.state?.content + } else { + null + } + + // If a user has already previously selected an auth factor then we need to restart the sign in + // flow in order to select a factor again. + val flowRestartRequired = currentUserInfo.selectedAuthFactor != null + + currentUserInfo = currentUserInfo.copy(password = password, selectedAuthFactor = authFactor) + + if (flowRestartRequired) { + // Call signIn to restart the flow but select the same factor + startSignIn(currentUserInfo, preferredFirstFactorOverride = authFactor) + } else { + // Use confirmSignIn to select an auth factor for the first time + confirmSignIn(currentUserInfo, authFactor.challengeResponse) + } + } + ) + moveTo(newState) + } + + private fun handleTotpSetupRequired(info: UserInfo, totpSetupDetails: TOTPSetupDetails?) { if (totpSetupDetails == null) { - val exception = AuthException("Missing TOTPSetupDetails", "Please open a bug with Amplify") + val exception = AuthException("Missing TOTPSetupDetails", AmplifyException.REPORT_BUG_TO_AWS_SUGGESTION) handleGeneralFailure(exception) return } val issuer = configuration.totpOptions?.issuer ?: getAppName() - val setupUri = totpSetupDetails.getSetupURI(issuer, username).toString() + val setupUri = totpSetupDetails.getSetupURI(issuer, info.username).toString() val newState = stateFactory.newSignInContinueWithTotpSetupState( sharedSecret = totpSetupDetails.sharedSecret, setupUri = setupUri, - onSubmit = { confirmationCode -> confirmSignIn(username, password, confirmationCode) } + onSubmit = { confirmationCode -> confirmSignIn(info, confirmationCode) } ) moveTo(newState) } - private suspend fun handleMfaSetupSelectionRequired( - username: String, - password: String, - allowedMfaTypes: Set? - ) { + private suspend fun handleMfaSetupSelectionRequired(info: UserInfo, allowedMfaTypes: Set?) { if (allowedMfaTypes.isNullOrEmpty()) { - handleGeneralFailure(AuthException("Missing allowedMfaTypes", "Please open a bug with Amplify")) + handleGeneralFailure( + AuthException("Missing allowedMfaTypes", AmplifyException.REPORT_BUG_TO_AWS_SUGGESTION) + ) return } moveTo( stateFactory.newSignInContinueWithMfaSetupSelectionState( allowedMfaTypes = allowedMfaTypes, - onSubmit = { mfaType -> confirmSignIn(username, password, mfaType) } + onSubmit = { mfaType -> confirmSignIn(info, mfaType) } ) ) } - private suspend fun handleEmailMfaSetupRequired(username: String, password: String) { + private suspend fun handleEmailMfaSetupRequired(info: UserInfo) { moveTo( stateFactory.newSignInContinueWithEmailSetupState( - onSubmit = { mfaType -> confirmSignIn(username, password, mfaType) } + onSubmit = { mfaType -> confirmSignIn(info, mfaType) } ) ) } - private suspend fun handleMfaSelectionRequired(username: String, password: String, allowedMfaTypes: Set?) { + private suspend fun handleMfaSelectionRequired(info: UserInfo, allowedMfaTypes: Set?) { if (allowedMfaTypes.isNullOrEmpty()) { - handleGeneralFailure(AuthException("Missing allowedMfaTypes", "Please open a bug with Amplify")) + handleGeneralFailure( + AuthException("Missing allowedMfaTypes", AmplifyException.REPORT_BUG_TO_AWS_SUGGESTION) + ) return } moveTo( stateFactory.newSignInContinueWithMfaSelectionState( allowedMfaTypes = allowedMfaTypes, - onSubmit = { mfaType -> confirmSignIn(username, password, mfaType) } + onSubmit = { mfaType -> confirmSignIn(info, mfaType) } ) ) } - private suspend fun handleSignInSuccess(username: String, password: String, result: AuthSignInResult) { + private suspend fun handleSignInSuccess(info: UserInfo, result: AuthSignInResult) { when (val nextStep = result.nextStep.signInStep) { - AuthSignInStep.DONE -> checkVerificationMechanisms() + AuthSignInStep.DONE -> checkForPasskeyPrompt(info) AuthSignInStep.CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE, AuthSignInStep.CONFIRM_SIGN_IN_WITH_OTP -> moveTo( stateFactory.newSignInMfaState( - result.nextStep.codeDeliveryDetails - ) { confirmationCode -> confirmSignIn(username, password, confirmationCode) } + codeDeliveryDetails = result.nextStep.codeDeliveryDetails + ) { confirmationCode -> confirmSignIn(info, confirmationCode) } ) AuthSignInStep.CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE -> moveTo( stateFactory.newSignInConfirmCustomState( result.nextStep.codeDeliveryDetails, result.nextStep.additionalInfo ?: emptyMap() - ) { confirmationCode -> confirmSignIn(username, password, confirmationCode) } + ) { confirmationCode -> confirmSignIn(info, confirmationCode) } ) AuthSignInStep.CONFIRM_SIGN_IN_WITH_NEW_PASSWORD -> moveTo( stateFactory.newSignInConfirmNewPasswordState { newPassword -> - setNewSignInPassword(username, newPassword) + setNewSignInPassword(info, newPassword) } ) // This step isn't actually returned, it comes back as a PasswordResetRequiredException. // Handling here for future correctness - AuthSignInStep.RESET_PASSWORD -> handleResetRequiredSignIn(username) + AuthSignInStep.RESET_PASSWORD -> handleResetRequiredSignIn(info.username) // This step isn't actually returned, it comes back as a UserNotConfirmedException. // Handling here for future correctness - AuthSignInStep.CONFIRM_SIGN_UP -> handleUnconfirmedSignIn(username, password) + AuthSignInStep.CONFIRM_SIGN_UP -> handleUnconfirmedSignIn(info) AuthSignInStep.CONTINUE_SIGN_IN_WITH_MFA_SELECTION -> - handleMfaSelectionRequired(username, password, result.nextStep.allowedMFATypes) + handleMfaSelectionRequired(info, result.nextStep.allowedMFATypes) AuthSignInStep.CONTINUE_SIGN_IN_WITH_MFA_SETUP_SELECTION -> - handleMfaSetupSelectionRequired(username, password, result.nextStep.allowedMFATypes) + handleMfaSetupSelectionRequired(info, result.nextStep.allowedMFATypes) AuthSignInStep.CONTINUE_SIGN_IN_WITH_EMAIL_MFA_SETUP -> - handleEmailMfaSetupRequired(username, password) + handleEmailMfaSetupRequired(info) AuthSignInStep.CONTINUE_SIGN_IN_WITH_TOTP_SETUP -> - handleTotpSetupRequired(username, password, result.nextStep.totpSetupDetails) + handleTotpSetupRequired(info, result.nextStep.totpSetupDetails) AuthSignInStep.CONFIRM_SIGN_IN_WITH_TOTP_CODE -> moveTo( stateFactory.newSignInConfirmTotpCodeState { confirmationCode -> - confirmSignIn(username, password, confirmationCode) + confirmSignIn(info, confirmationCode) } ) + AuthSignInStep.CONTINUE_SIGN_IN_WITH_FIRST_FACTOR_SELECTION -> + handleFactorSelectionRequired( + info, + result.nextStep.availableFactors?.toAuthFactors() + ) + AuthSignInStep.CONFIRM_SIGN_IN_WITH_PASSWORD -> { + if (info.password != null) { + confirmSignIn(info, info.password) + } else { + moveTo( + stateFactory.newSignInConfirmPasswordState(username = info.username) { password -> + val newInfo = info.copy(password = password) + confirmSignIn(newInfo, password) + } + ) + } + } else -> { // Generic error for any other next steps that may be added in the future val exception = UnsupportedNextStepException(nextStep) @@ -447,6 +573,53 @@ internal class AuthenticatorViewModel(application: Application, private val auth } } + private suspend fun checkForPasskeyPrompt(info: UserInfo) { + if (passkeyCheck.shouldPromptForPasskey(userInfo = info, config = configuration)) { + moveTo( + stateFactory.newPasskeyPromptState( + onSubmit = { + val activityRef = activity + if (activityRef == null) { + // This shouldn't happen, it indicates a bug. If it does the user can retry or choose to + // skip + sendMessage( + UnknownErrorMessage( + AuthException( + message = "Missing activity reference", + recoverySuggestion = AmplifyException.REPORT_BUG_TO_AWS_SUGGESTION + ) + ) + ) + } else { + createPasskey(activityRef) + } + }, + onSkip = ::checkVerificationMechanisms + ) + ) + } else { + checkVerificationMechanisms() + } + } + + private suspend fun createPasskey(activityRef: Activity) { + when (val result = authProvider.createPasskey(activityRef)) { + is AmplifyResult.Error -> when (result.error) { + is UserCancelledException -> Unit // This is expected, user can retry or skip + else -> sendMessage(PasskeyCreationFailedMessage(result.error)) // User can retry/skip + } + is AmplifyResult.Success -> { + val passkeys = authProvider.getPasskeys().getOrDefault { emptyList() } + moveTo( + stateFactory.newPasskeyCreatedState( + passkeys = passkeys, + onDone = ::checkVerificationMechanisms + ) + ) + } + } + } + private suspend fun checkVerificationMechanisms() { val mechanisms = authConfiguration.verificationMechanisms if (mechanisms.isEmpty()) { @@ -499,7 +672,7 @@ internal class AuthenticatorViewModel(application: Application, private val auth logger.debug("Confirming password reset") when (val result = authProvider.confirmResetPassword(username, password, code)) { is AmplifyResult.Error -> handleResetPasswordError(result.error) - is AmplifyResult.Success -> handlePasswordResetComplete(username, password) + is AmplifyResult.Success -> handlePasswordResetComplete() } }.join() } @@ -523,19 +696,10 @@ internal class AuthenticatorViewModel(application: Application, private val auth } } - private suspend fun handlePasswordResetComplete(username: String? = null, password: String? = null) { + private suspend fun handlePasswordResetComplete() { logger.debug("Password reset complete") sendMessage(PasswordResetMessage) - if (username != null && password != null) { - startSignInJob { - when (val result = authProvider.signIn(username, password)) { - is AmplifyResult.Error -> moveTo(stateFactory.newSignInState(this::signIn)) - is AmplifyResult.Success -> handleSignInSuccess(username, password, result.data) - } - } - } else { - moveTo(stateFactory.newSignInState(this::signIn)) - } + moveTo(stateFactory.newSignInState(this::signIn)) } private suspend fun handleResetPasswordError(error: AuthException) = handleAuthException(error) @@ -634,14 +798,13 @@ internal class AuthenticatorViewModel(application: Application, private val auth is AmplifyResult.Error -> { if (result.error is SessionExpiredException) { logger.error(result.error.toString()) - logger.error("Current signed in user session has expired, signing out.") signOut() } else { handleGeneralFailure(result.error) } } - is AmplifyResult.Success -> moveTo(stateFactory.newSignedInState(result.data, this::signOut)) + is AmplifyResult.Success -> signInComplete(result.data) } } @@ -651,6 +814,10 @@ internal class AuthenticatorViewModel(application: Application, private val auth expectingSignInEvent = false } + private fun signInComplete(user: AuthUser) { + moveTo(stateFactory.newSignedInState(user, this::signOut)) + } + // Amplify has told us the user signed in. private suspend fun handleSignedInEvent() { if (!expectingSignInEvent && !inPostSignInState()) { diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/AuthFactor.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/AuthFactor.kt new file mode 100644 index 00000000..1db13d4b --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/AuthFactor.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.amplifyframework.ui.authenticator.data + +import com.amplifyframework.auth.AuthFactorType + +sealed interface AuthFactor { + data class Password(val srp: Boolean = true) : AuthFactor + data object EmailOtp : AuthFactor + data object SmsOtp : AuthFactor + data object WebAuthn : AuthFactor +} + +internal fun AuthFactor.toAuthFactorType() = when (this) { + AuthFactor.EmailOtp -> AuthFactorType.EMAIL_OTP + AuthFactor.SmsOtp -> AuthFactorType.SMS_OTP + AuthFactor.WebAuthn -> AuthFactorType.WEB_AUTHN + is AuthFactor.Password -> if (srp) AuthFactorType.PASSWORD_SRP else AuthFactorType.PASSWORD +} + +internal fun AuthFactorType.toAuthFactor() = when (this) { + AuthFactorType.PASSWORD -> AuthFactor.Password(srp = false) + AuthFactorType.PASSWORD_SRP -> AuthFactor.Password(srp = true) + AuthFactorType.EMAIL_OTP -> AuthFactor.EmailOtp + AuthFactorType.SMS_OTP -> AuthFactor.SmsOtp + AuthFactorType.WEB_AUTHN -> AuthFactor.WebAuthn +} + +internal val AuthFactor.challengeResponse: String + get() = this.toAuthFactorType().challengeResponse + +internal fun Collection.toAuthFactors(): Set { + // If both SRP and password are available then use SRP to sign in + var factors = this + if (this.contains(AuthFactorType.PASSWORD) && this.contains(AuthFactorType.PASSWORD_SRP)) { + factors = this - AuthFactorType.PASSWORD // remove password + } + return factors.map { it.toAuthFactor() }.toSet() +} +internal fun Collection.containsPassword() = any { it is AuthFactor.Password } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/AuthenticationFlow.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/AuthenticationFlow.kt new file mode 100644 index 00000000..c1e92c75 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/AuthenticationFlow.kt @@ -0,0 +1,73 @@ +/* + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.amplifyframework.ui.authenticator.data + +import com.amplifyframework.auth.cognito.options.AuthFlowType + +/** + * AuthenticationFlow represents the different styles of authentication supported by the Authenticator component. + */ +sealed interface AuthenticationFlow { + /** + * The standard password-based auth flow. The user will be prompted to enter a username and password on the SignIn + * screen. You can use this with either Password or PasswordSrp sign ins. + */ + data object Password : AuthenticationFlow + + /** + * A choice-based auth flow, where the user may log in via a password, a passkey, or a one-time-password (OTP) sent + * to their email or SMS. The user is first prompted to enter only their sign in attribute (username/email/phone) + * and then may be presented with options for how to log in. You must have ALLOW_USER_AUTH enabled as an + * authentication flow in your Cognito User Pool. + */ + data class UserChoice( + /** + * Specify an [AuthFactor] to use by default, if available to the user. + * + * For example, if you want any user with a registered passkey to sign in with that passkey without being + * prompted, then set this value to `AuthFactor.WebAuthn`. + * + * If this is null or the [AuthFactor] is not available to the user, they may go directly into a different + * [AuthFactor] (if they only have one available) or may be prompted to choose a factor (if they have multiple + * available). + * + * If this is set to [AuthFactor.Password] or [AuthFactor.PasswordSrp] then the user will be prompted for a + * password directly when signing in. Use these values only if you're certain that no users exist who don't + * have passwords. + */ + val preferredAuthFactor: AuthFactor? = null, + + /** + * Control when/if the user is prompted to create a passkey after logging in. + */ + val passkeyPrompts: PasskeyPrompts = PasskeyPrompts() + ) : AuthenticationFlow +} + +internal val AuthenticationFlow.signUpRequiresPassword: Boolean get() = when (this) { + is AuthenticationFlow.Password -> true + is AuthenticationFlow.UserChoice -> false +} + +internal val AuthenticationFlow.signInRequiresPassword: Boolean get() = when (this) { + is AuthenticationFlow.Password -> true + is AuthenticationFlow.UserChoice -> this.preferredAuthFactor is AuthFactor.Password +} + +internal fun AuthenticationFlow.toAuthFlowType() = when (this) { + is AuthenticationFlow.Password -> null // Use whatever is defined in the user's config file + is AuthenticationFlow.UserChoice -> AuthFlowType.USER_AUTH // Requires USER_AUTH +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/PasskeyPrompt.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/PasskeyPrompt.kt new file mode 100644 index 00000000..8cf753e7 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/PasskeyPrompt.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.amplifyframework.ui.authenticator.data + +/** + * Class that contains configuration values for when/if to show prompts to create passkeys to the user. + */ +data class PasskeyPrompts( + /** + * Show a prompt after a user who does not have a passkey registered signs in to the application. + */ + val afterSignIn: PasskeyPrompt = PasskeyPrompt.Always, + /** + * Show a prompt to create a passkey after the automatic sign in following a new user signing up. + */ + val afterSignUp: PasskeyPrompt = PasskeyPrompt.Always +) + +/** + * Possible selections for controlling passkey prompts. + */ +sealed interface PasskeyPrompt { + /** + * Never prompt users to create a passkey after signing in. + */ + data object Never : PasskeyPrompt + + /** + * Always prompt users to create a passkey after signing in if they don't already have an existing registered + * passkey. + */ + data object Always : PasskeyPrompt +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/UserInfo.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/UserInfo.kt new file mode 100644 index 00000000..a1a121d5 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/data/UserInfo.kt @@ -0,0 +1,16 @@ +package com.amplifyframework.ui.authenticator.data + +import com.amplifyframework.ui.authenticator.enums.SignInSource + +internal data class UserInfo( + val username: String, + val password: String?, + val signInSource: SignInSource, + val selectedAuthFactor: AuthFactor? = null +) { + override fun toString() = "UserInfo(" + + "username=$username, " + + "password=***, " + + "signInSource=$signInSource, " + + "selectedAuthFactor=$selectedAuthFactor)" +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/AuthenticatorStep.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/AuthenticatorStep.kt index 700efecf..e691ef2b 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/AuthenticatorStep.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/AuthenticatorStep.kt @@ -46,6 +46,16 @@ abstract class AuthenticatorStep internal constructor() { */ object SignIn : AuthenticatorInitialStep() + /** + * The user has entered their username and must select the authentication factor they'd like to use to sign in + */ + object SignInSelectAuthFactor : AuthenticatorStep() + + /** + * A user has entered their username and must enter their password to continue signing in + */ + object SignInConfirmPassword : AuthenticatorStep() + /** * The user has completed the initial Sign In step, and needs to enter the confirmation code from a custom * challenge to complete the sign in process. @@ -120,4 +130,15 @@ abstract class AuthenticatorStep internal constructor() { * The user has initiated verification of an account recovery mechanism (email, phone) and needs to provide a confirmation code. */ object VerifyUserConfirm : AuthenticatorStep() + + /** + * The user is being shown a prompt to create a passkey, encouraging them to use this as a way to sign in quickly + * via biometrics + */ + object PasskeyCreationPrompt : AuthenticatorStep() + + /** + * The user is being shown a confirmation screen after creating a passkey + */ + object PasskeyCreated : AuthenticatorStep() } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/SignInSource.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/SignInSource.kt new file mode 100644 index 00000000..7cfad434 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/enums/SignInSource.kt @@ -0,0 +1,9 @@ +package com.amplifyframework.ui.authenticator.enums + +internal enum class SignInSource { + // Standard sign in + SignIn, + + // Automatic sign in after completing sign up + AutoSignIn +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/forms/FieldValidator.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/forms/FieldValidator.kt index 88a818be..2e9cea96 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/forms/FieldValidator.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/forms/FieldValidator.kt @@ -51,7 +51,7 @@ internal operator fun FieldValidator.plus(other: FieldValidator): FieldValidator internal object FieldValidators { private val usernamePattern = """[\p{L}\p{M}\p{S}\p{N}\p{P}]+""".toPattern() - private val confirmationCodePattern = """\d{6}""".toPattern() + private val confirmationCodePattern = """\d{6}|\d{8}""".toPattern() private val specialRegex = """[\^\$\{}\*\.\[\]\{}\(\)\?\-"!@#%&/\\,><':;|_~`+=\s]+""".toRegex() private val numbersRegex = "\\d+".toRegex() private val upperRegex = "[A-Z]+".toRegex() @@ -63,25 +63,17 @@ internal object FieldValidators { */ val None: FieldValidator = { null } - internal fun required( - error: FieldError = FieldError.MissingRequired - ): FieldValidator = { + internal fun required(error: FieldError = FieldError.MissingRequired): FieldValidator = { if (content.isBlank()) error else null } - private fun matchingField( - other: FieldKey, - error: FieldError - ): FieldValidator = { + private fun matchingField(other: FieldKey, error: FieldError): FieldValidator = { if (content != formContent[other]) error else null } fun confirmPassword() = matchingField(Password, PasswordsDoNotMatch) - private fun pattern( - pattern: Pattern, - error: FieldError = InvalidFormat - ): FieldValidator = { + private fun pattern(pattern: Pattern, error: FieldError = InvalidFormat): FieldValidator = { if (content.isNotBlank() && !pattern.matcher(content).matches()) error else null } @@ -90,9 +82,7 @@ internal object FieldValidators { fun phoneNumber() = pattern(Patterns.PHONE) fun webUrl() = pattern(Patterns.WEB_URL) - fun date( - error: FieldError = InvalidFormat - ): FieldValidator = { + fun date(error: FieldError = InvalidFormat): FieldValidator = { if (content.isNotBlank()) { try { dateFormat.parse(content) @@ -107,9 +97,7 @@ internal object FieldValidators { internal fun confirmationCode() = pattern(confirmationCodePattern) - internal fun password( - criteria: PasswordCriteria - ): FieldValidator = { + internal fun password(criteria: PasswordCriteria): FieldValidator = { if (content.isNotBlank()) { val potentialErrors = mutableListOf() if (content.length < criteria.length) { diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/forms/FormBuilder.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/forms/FormBuilder.kt index f3896119..71029f53 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/forms/FormBuilder.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/forms/FormBuilder.kt @@ -23,9 +23,7 @@ import com.amplifyframework.ui.authenticator.auth.toFieldKey internal data class FormData(val fields: List) -internal fun buildForm(func: FormBuilderImpl.() -> Unit): FormData { - return FormBuilderImpl().apply(func).build() -} +internal fun buildForm(func: FormBuilderImpl.() -> Unit): FormData = FormBuilderImpl().apply(func).build() /** * Builder API for supplying custom form metadata for the signup form. @@ -39,12 +37,12 @@ interface SignUpFormBuilder { /** * Adds the standard password field. */ - fun password() + fun password(required: Boolean = true) /** * Adds the standard password confirmation field. */ - fun confirmPassword() + fun confirmPassword(required: Boolean = true) /** * Adds the standard email field. @@ -187,18 +185,23 @@ internal class FormBuilderImpl : SignUpFormBuilder { ) } - override fun password() = password(validator = FieldValidators.None) + override fun password(required: Boolean) = password( + required = required, + validator = FieldValidators.None + ) - fun password(validator: FieldValidator) { + fun password(validator: FieldValidator, required: Boolean = true) { this += FieldConfig.Password( key = FieldKey.Password, + required = required, validator = validator ) } - override fun confirmPassword() { + override fun confirmPassword(required: Boolean) { this += FieldConfig.Password( key = FieldKey.ConfirmPassword, + required = required, validator = FieldValidators.confirmPassword() ) } @@ -208,7 +211,7 @@ internal class FormBuilderImpl : SignUpFormBuilder { key = FieldKey.ConfirmationCode, validator = FieldValidators.confirmationCode(), keyboardType = KeyboardType.Number, - maxLength = 6 + maxLength = 8 ) } @@ -317,13 +320,7 @@ internal class FormBuilderImpl : SignUpFormBuilder { ) } - override fun date( - key: FieldKey, - label: String, - hint: String?, - required: Boolean, - validator: FieldValidator - ) { + override fun date(key: FieldKey, label: String, hint: String?, required: Boolean, validator: FieldValidator) { this += FieldConfig.Date( key = key, label = label, @@ -333,13 +330,7 @@ internal class FormBuilderImpl : SignUpFormBuilder { ) } - override fun phone( - key: FieldKey, - label: String, - hint: String?, - required: Boolean, - validator: FieldValidator - ) { + override fun phone(key: FieldKey, label: String, hint: String?, required: Boolean, validator: FieldValidator) { this += FieldConfig.PhoneNumber( key = key, label = label, @@ -392,10 +383,7 @@ internal class FormBuilderImpl : SignUpFormBuilder { fields.putAll(map) } - fun markRequiredFields( - signInMethod: SignInMethod, - requiredKeys: List - ) { + fun markRequiredFields(signInMethod: SignInMethod, requiredKeys: List) { fields.replaceAll { fieldKey, config -> if (fieldKey is FieldKey.UserAttributeKey && requiredKeys.contains(fieldKey.attributeKey)) { config.required() diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/MutableActionState.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/MutableActionState.kt new file mode 100644 index 00000000..b31d9e91 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/MutableActionState.kt @@ -0,0 +1,13 @@ +package com.amplifyframework.ui.authenticator.states + +import com.amplifyframework.ui.authenticator.AuthenticatorActionState + +internal interface MutableActionState : AuthenticatorActionState { + override var action: T? +} + +internal inline fun MutableActionState.withAction(action: T, func: () -> Unit) { + this.action = action + func() + this.action = null +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreatedStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreatedStateImpl.kt new file mode 100644 index 00000000..47fa13a8 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PasskeyCreatedStateImpl.kt @@ -0,0 +1,22 @@ +package com.amplifyframework.ui.authenticator.states + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.amplifyframework.auth.result.AuthWebAuthnCredential +import com.amplifyframework.ui.authenticator.PasskeyCreatedState +import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep + +internal class PasskeyCreatedStateImpl( + override val passkeys: List, + private val onDone: suspend () -> Unit +) : PasskeyCreatedState, + MutableActionState { + override val step: AuthenticatorStep = AuthenticatorStep.PasskeyCreated + + override var action: PasskeyCreatedState.Action? by mutableStateOf(null) + + override suspend fun continueSignIn() = withAction(PasskeyCreatedState.Action.ContinueSignIn()) { + onDone() + } +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PromptToCreatePasskeyStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PromptToCreatePasskeyStateImpl.kt new file mode 100644 index 00000000..300ce5aa --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/PromptToCreatePasskeyStateImpl.kt @@ -0,0 +1,28 @@ +package com.amplifyframework.ui.authenticator.states + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.amplifyframework.ui.authenticator.PromptToCreatePasskeyState +import com.amplifyframework.ui.authenticator.PromptToCreatePasskeyState.Action +import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class PromptToCreatePasskeyStateImpl(private val onSubmit: suspend () -> Unit, private val onSkip: suspend () -> Unit) : + PromptToCreatePasskeyState, + MutableActionState { + private val mutex = Mutex() + + override val step = AuthenticatorStep.PasskeyCreationPrompt + + override var action: Action? by mutableStateOf(null) + + override suspend fun createPasskey() = withAction(Action.CreatePasskey()) { + mutex.withLock { + onSubmit() + } + } + + override suspend fun skip() = withAction(Action.Skip()) { onSkip() } +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInConfirmMfaStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInConfirmMfaStateImpl.kt index d1335584..749dd6b4 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInConfirmMfaStateImpl.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInConfirmMfaStateImpl.kt @@ -25,7 +25,8 @@ internal class SignInConfirmMfaStateImpl( override val deliveryDetails: AuthCodeDeliveryDetails?, private val onSubmit: suspend (confirmationCode: String) -> Unit, private val onMoveTo: (step: AuthenticatorInitialStep) -> Unit -) : BaseStateImpl(), SignInConfirmMfaState { +) : BaseStateImpl(), + SignInConfirmMfaState { init { form.addFields { diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInConfirmPasswordStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInConfirmPasswordStateImpl.kt new file mode 100644 index 00000000..d8c49bfb --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInConfirmPasswordStateImpl.kt @@ -0,0 +1,33 @@ +package com.amplifyframework.ui.authenticator.states + +import com.amplifyframework.ui.authenticator.SignInConfirmPasswordState +import com.amplifyframework.ui.authenticator.auth.SignInMethod +import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep +import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep +import com.amplifyframework.ui.authenticator.forms.FieldKey + +internal class SignInConfirmPasswordStateImpl( + override val username: String, + val signInMethod: SignInMethod, + private val onSubmit: suspend (password: String) -> Unit, + private val onMoveTo: (step: AuthenticatorInitialStep) -> Unit +) : BaseStateImpl(), + SignInConfirmPasswordState { + + init { + form.addFields { + password() + } + } + + override val step: AuthenticatorStep = AuthenticatorStep.SignInConfirmPassword + override fun moveTo(step: AuthenticatorInitialStep) = onMoveTo(step) + + override suspend fun signIn() = doSubmit { + val password = form.getTrimmed(FieldKey.Password)!! + onSubmit(password) + } +} + +internal val SignInConfirmPasswordState.signInMethod: SignInMethod + get() = (this as SignInConfirmPasswordStateImpl).signInMethod diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInSelectAuthFactorStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInSelectAuthFactorStateImpl.kt new file mode 100644 index 00000000..eb907f2a --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInSelectAuthFactorStateImpl.kt @@ -0,0 +1,49 @@ +package com.amplifyframework.ui.authenticator.states + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState +import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState.Action +import com.amplifyframework.ui.authenticator.auth.SignInMethod +import com.amplifyframework.ui.authenticator.data.AuthFactor +import com.amplifyframework.ui.authenticator.data.containsPassword +import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep +import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep + +internal class SignInSelectAuthFactorStateImpl( + override val username: String, + val signInMethod: SignInMethod, + override val availableAuthFactors: Set, + private val onSubmit: suspend (authFactor: AuthFactor) -> Unit, + private val onMoveTo: (step: AuthenticatorInitialStep) -> Unit +) : BaseStateImpl(), + SignInSelectAuthFactorState, + MutableActionState { + override val step: AuthenticatorStep = AuthenticatorStep.SignInSelectAuthFactor + + override var action: Action? by mutableStateOf(null) + + init { + if (availableAuthFactors.containsPassword()) { + form.addFields { password() } + } + } + + override fun moveTo(step: AuthenticatorInitialStep) = onMoveTo(step) + + override suspend fun select(authFactor: AuthFactor) = withAction(Action.SelectAuthFactor(authFactor)) { + // Clear errors + form.fields.values.forEach { it.state.error = null } + + form.enabled = false + onSubmit(authFactor) + form.enabled = true + } +} + +internal fun SignInSelectAuthFactorState.getPasswordFactor(): AuthFactor = + availableAuthFactors.first { it is AuthFactor.Password } + +internal val SignInSelectAuthFactorState.signInMethod: SignInMethod + get() = (this as SignInSelectAuthFactorStateImpl).signInMethod diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInStateImpl.kt index 001a71e7..2ac25b49 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInStateImpl.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignInStateImpl.kt @@ -24,14 +24,17 @@ import com.amplifyframework.ui.authenticator.forms.FieldKey internal class SignInStateImpl( private val signInMethod: SignInMethod, - private val onSubmit: suspend (username: String, password: String) -> Unit, + showPasswordField: Boolean, + private val onSubmit: suspend (username: String, password: String?) -> Unit, private val onMoveTo: (step: AuthenticatorInitialStep) -> Unit ) : BaseStateImpl(), SignInState { init { form.addFields { fieldForSignInMethod(signInMethod) - password() + if (showPasswordField) { + password() + } } } @@ -40,7 +43,7 @@ internal class SignInStateImpl( override suspend fun signIn() = doSubmit { val username = form.getTrimmed(signInMethod.toFieldKey())!! - val password = form.getTrimmed(FieldKey.Password)!! + val password = form.getTrimmed(FieldKey.Password) onSubmit(username, password) } } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignUpStateImpl.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignUpStateImpl.kt index dfc52cba..02698854 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignUpStateImpl.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/SignUpStateImpl.kt @@ -31,18 +31,28 @@ import com.amplifyframework.ui.authenticator.forms.buildForm internal class SignUpStateImpl( private val signInMethod: SignInMethod, private val signUpAttributes: List, + requirePasswordField: Boolean, private val passwordCriteria: PasswordCriteria, private val signUpForm: FormData, - private val onSubmit: suspend (username: String, password: String, attributes: List) -> Unit, + private val onSubmit: suspend (username: String, password: String?, attributes: List) -> Unit, private val onMoveTo: (step: AuthenticatorInitialStep) -> Unit -) : BaseStateImpl(), SignUpState { +) : BaseStateImpl(), + SignUpState { init { val formData = buildForm { // First add all fields required by configuration in the standard order fieldForSignInMethod(signInMethod) - password(validator = FieldValidators.password(passwordCriteria)) - confirmPassword() + if (requirePasswordField) { + password(validator = FieldValidators.password(passwordCriteria)) + + // We don't add confirm password if the customer supplied a form with password and without confirmPassword + if (signUpForm.containsField(FieldKey.ConfirmPassword) || + !signUpForm.containsField(FieldKey.Password) + ) { + confirmPassword() + } + } signUpAttributes.forEach { attribute -> when (attribute) { AuthUserAttributeKey.birthdate() -> birthdate(required = true) @@ -77,8 +87,10 @@ internal class SignUpStateImpl( override suspend fun signUp() = doSubmit { val username = form.getTrimmed(signInMethod.toFieldKey())!! - val password = form.getTrimmed(FieldKey.Password)!! + val password = form.getTrimmed(FieldKey.Password).takeIf { !it.isNullOrBlank() } val attributes = form.getUserAttributes() onSubmit(username, password, attributes) } + + private fun FormData.containsField(key: FieldKey) = fields.any { it.key == key } } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/StepStateFactory.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/StepStateFactory.kt index d1df7f5e..d3139316 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/StepStateFactory.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/states/StepStateFactory.kt @@ -21,32 +21,46 @@ import com.amplifyframework.auth.AuthUserAttribute import com.amplifyframework.auth.AuthUserAttributeKey import com.amplifyframework.auth.MFAType import com.amplifyframework.auth.result.AuthSignOutResult +import com.amplifyframework.auth.result.AuthWebAuthnCredential +import com.amplifyframework.ui.authenticator.AuthenticatorConfiguration import com.amplifyframework.ui.authenticator.auth.AmplifyAuthConfiguration +import com.amplifyframework.ui.authenticator.data.AuthFactor +import com.amplifyframework.ui.authenticator.data.signInRequiresPassword +import com.amplifyframework.ui.authenticator.data.signUpRequiresPassword import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep import com.amplifyframework.ui.authenticator.forms.FormData internal class StepStateFactory( + private val configuration: AuthenticatorConfiguration, private val authConfiguration: AmplifyAuthConfiguration, private val signUpForm: FormData, private val onMoveTo: (step: AuthenticatorInitialStep) -> Unit ) { - fun newSignedInState( - user: AuthUser, - onSignOut: suspend () -> AuthSignOutResult - ) = SignedInStateImpl( + fun newSignedInState(user: AuthUser, onSignOut: suspend () -> AuthSignOutResult) = SignedInStateImpl( user = user, onSignOut = onSignOut ) - fun newSignInState( - onSubmit: suspend (username: String, password: String) -> Unit - ) = SignInStateImpl( + fun newSignInState(onSubmit: suspend (username: String, password: String?) -> Unit) = SignInStateImpl( signInMethod = authConfiguration.signInMethod, + showPasswordField = configuration.authenticationFlow.signInRequiresPassword, onSubmit = onSubmit, onMoveTo = onMoveTo ) + fun newSignInSelectFactorState( + username: String, + availableFactors: Set, + onSelect: suspend (AuthFactor) -> Unit + ) = SignInSelectAuthFactorStateImpl( + username = username, + signInMethod = authConfiguration.signInMethod, + availableAuthFactors = availableFactors, + onSubmit = onSelect, + onMoveTo = onMoveTo + ) + fun newSignInMfaState( codeDeliveryDetails: AuthCodeDeliveryDetails?, onSubmit: suspend (confirmationCode: String) -> Unit @@ -67,20 +81,26 @@ internal class StepStateFactory( onMoveTo = onMoveTo ) - fun newSignInConfirmNewPasswordState( - onSubmit: suspend (password: String) -> Unit - ) = SignInConfirmNewPasswordStateImpl( - passwordCriteria = authConfiguration.passwordCriteria, - onSubmit = onSubmit, - onMoveTo = onMoveTo - ) + fun newSignInConfirmNewPasswordState(onSubmit: suspend (password: String) -> Unit) = + SignInConfirmNewPasswordStateImpl( + passwordCriteria = authConfiguration.passwordCriteria, + onSubmit = onSubmit, + onMoveTo = onMoveTo + ) - fun newSignInConfirmTotpCodeState( - onSubmit: suspend (confirmationCode: String) -> Unit - ) = SignInConfirmTotpCodeStateImpl( - onSubmit = onSubmit, - onMoveTo = onMoveTo - ) + fun newSignInConfirmPasswordState(username: String, onSubmit: suspend (password: String) -> Unit) = + SignInConfirmPasswordStateImpl( + username = username, + signInMethod = authConfiguration.signInMethod, + onSubmit = onSubmit, + onMoveTo = onMoveTo + ) + + fun newSignInConfirmTotpCodeState(onSubmit: suspend (confirmationCode: String) -> Unit) = + SignInConfirmTotpCodeStateImpl( + onSubmit = onSubmit, + onMoveTo = onMoveTo + ) fun newSignInContinueWithMfaSetupSelectionState( allowedMfaTypes: Set, @@ -91,12 +111,11 @@ internal class StepStateFactory( onMoveTo = onMoveTo ) - fun newSignInContinueWithEmailSetupState( - onSubmit: suspend (email: String) -> Unit - ) = SignInContinueWithEmailSetupStateImpl( - onSubmit = onSubmit, - onMoveTo = onMoveTo - ) + fun newSignInContinueWithEmailSetupState(onSubmit: suspend (email: String) -> Unit) = + SignInContinueWithEmailSetupStateImpl( + onSubmit = onSubmit, + onMoveTo = onMoveTo + ) fun newSignInContinueWithMfaSelectionState( allowedMfaTypes: Set, @@ -119,10 +138,11 @@ internal class StepStateFactory( ) fun newSignUpState( - onSubmit: suspend (username: String, password: String, attributes: List) -> Unit + onSubmit: suspend (username: String, password: String?, attributes: List) -> Unit ) = SignUpStateImpl( signInMethod = authConfiguration.signInMethod, signUpAttributes = authConfiguration.signUpAttributes, + requirePasswordField = configuration.authenticationFlow.signUpRequiresPassword, passwordCriteria = authConfiguration.passwordCriteria, signUpForm = signUpForm, onSubmit = onSubmit, @@ -140,9 +160,7 @@ internal class StepStateFactory( onMoveTo = onMoveTo ) - fun newResetPasswordState( - onSubmit: suspend (username: String) -> Unit - ) = PasswordResetStateImpl( + fun newResetPasswordState(onSubmit: suspend (username: String) -> Unit) = PasswordResetStateImpl( signInMethod = authConfiguration.signInMethod, onSubmit = onSubmit, onMoveTo = onMoveTo @@ -179,4 +197,16 @@ internal class StepStateFactory( onResendCode = onResendCode, onSkip = onSkip ) + + fun newPasskeyPromptState(onSubmit: suspend () -> Unit, onSkip: suspend () -> Unit) = + PromptToCreatePasskeyStateImpl( + onSubmit = onSubmit, + onSkip = onSkip + ) + + fun newPasskeyCreatedState(passkeys: List, onDone: suspend () -> Unit) = + PasskeyCreatedStateImpl( + passkeys = passkeys, + onDone = onDone + ) } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/strings/StringResolver.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/strings/StringResolver.kt index 5281edc1..3e4c1314 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/strings/StringResolver.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/strings/StringResolver.kt @@ -39,7 +39,7 @@ internal open class StringResolver { @Composable @ReadOnlyComposable open fun label(config: FieldConfig): String { - var label = title(config) + var label = fieldName(config) if (!config.required) { label = stringResource(R.string.amplify_ui_authenticator_field_optional, label) } @@ -48,7 +48,11 @@ internal open class StringResolver { @Composable @ReadOnlyComposable - private fun title(config: FieldConfig): String = config.label ?: when (config.key) { + private fun fieldName(config: FieldConfig): String = config.label ?: fieldName(config.key) + + @Composable + @ReadOnlyComposable + fun fieldName(key: FieldKey): String = when (key) { FieldKey.ConfirmPassword -> stringResource(R.string.amplify_ui_authenticator_field_label_password_confirm) FieldKey.ConfirmationCode -> stringResource(R.string.amplify_ui_authenticator_field_label_confirmation_code) FieldKey.Password -> stringResource(R.string.amplify_ui_authenticator_field_label_password) @@ -89,39 +93,38 @@ internal open class StringResolver { is FieldError.InvalidPassword -> { var errorText = stringResource(R.string.amplify_ui_authenticator_field_password_requirements) error.errors.forEach { - errorText += "\n" + - when (it) { - is PasswordError.InvalidPasswordLength -> - pluralStringResource( - id = R.plurals.amplify_ui_authenticator_field_password_too_short, - count = it.minimumLength, - it.minimumLength - ) - PasswordError.InvalidPasswordMissingSpecial -> - stringResource(R.string.amplify_ui_authenticator_field_password_missing_special) - PasswordError.InvalidPasswordMissingNumber -> - stringResource(R.string.amplify_ui_authenticator_field_password_missing_number) - PasswordError.InvalidPasswordMissingUpper -> - stringResource(R.string.amplify_ui_authenticator_field_password_missing_upper) - PasswordError.InvalidPasswordMissingLower -> - stringResource(R.string.amplify_ui_authenticator_field_password_missing_lower) - else -> "" - } + errorText += "\n" + when (it) { + is PasswordError.InvalidPasswordLength -> + pluralStringResource( + id = R.plurals.amplify_ui_authenticator_field_password_too_short, + count = it.minimumLength, + it.minimumLength + ) + PasswordError.InvalidPasswordMissingSpecial -> + stringResource(R.string.amplify_ui_authenticator_field_password_missing_special) + PasswordError.InvalidPasswordMissingNumber -> + stringResource(R.string.amplify_ui_authenticator_field_password_missing_number) + PasswordError.InvalidPasswordMissingUpper -> + stringResource(R.string.amplify_ui_authenticator_field_password_missing_upper) + PasswordError.InvalidPasswordMissingLower -> + stringResource(R.string.amplify_ui_authenticator_field_password_missing_lower) + else -> "" + } } errorText } FieldError.PasswordsDoNotMatch -> stringResource(R.string.amplify_ui_authenticator_field_warn_unmatched_password) FieldError.MissingRequired -> { - val label = title(config) + val label = fieldName(config) stringResource(R.string.amplify_ui_authenticator_field_warn_empty, label) } FieldError.InvalidFormat -> { - val label = title(config) + val label = fieldName(config) stringResource(R.string.amplify_ui_authenticator_field_warn_invalid_format, label) } FieldError.FieldValueExists -> { - val label = title(config) + val label = fieldName(config) stringResource(R.string.amplify_ui_authenticator_field_warn_existing, label) } FieldError.ConfirmationCodeIncorrect -> { @@ -129,7 +132,7 @@ internal open class StringResolver { } is FieldError.Custom -> error.message FieldError.NotFound -> { - val label = title(config) + val label = fieldName(config) stringResource(R.string.amplify_ui_authenticator_field_warn_not_found, label) } else -> "" @@ -155,6 +158,10 @@ internal open class StringResolver { @ReadOnlyComposable fun label(config: FieldConfig) = LocalStringResolver.current.label(config = config) + @Composable + @ReadOnlyComposable + fun fieldName(key: FieldKey) = LocalStringResolver.current.fieldName(key = key) + @Composable @ReadOnlyComposable fun hint(config: FieldConfig) = LocalStringResolver.current.hint(config = config) diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/Authenticator.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/Authenticator.kt index ef00f113..d54e8d92 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/Authenticator.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/Authenticator.kt @@ -41,16 +41,20 @@ import com.amplifyframework.ui.authenticator.AuthenticatorState import com.amplifyframework.ui.authenticator.AuthenticatorStepState import com.amplifyframework.ui.authenticator.ErrorState import com.amplifyframework.ui.authenticator.LoadingState +import com.amplifyframework.ui.authenticator.PasskeyCreatedState import com.amplifyframework.ui.authenticator.PasswordResetConfirmState import com.amplifyframework.ui.authenticator.PasswordResetState +import com.amplifyframework.ui.authenticator.PromptToCreatePasskeyState import com.amplifyframework.ui.authenticator.SignInConfirmCustomState import com.amplifyframework.ui.authenticator.SignInConfirmMfaState import com.amplifyframework.ui.authenticator.SignInConfirmNewPasswordState +import com.amplifyframework.ui.authenticator.SignInConfirmPasswordState import com.amplifyframework.ui.authenticator.SignInConfirmTotpCodeState import com.amplifyframework.ui.authenticator.SignInContinueWithEmailSetupState import com.amplifyframework.ui.authenticator.SignInContinueWithMfaSelectionState import com.amplifyframework.ui.authenticator.SignInContinueWithMfaSetupSelectionState import com.amplifyframework.ui.authenticator.SignInContinueWithTotpSetupState +import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState import com.amplifyframework.ui.authenticator.SignInState import com.amplifyframework.ui.authenticator.SignUpConfirmState import com.amplifyframework.ui.authenticator.SignUpState @@ -119,6 +123,10 @@ fun Authenticator( passwordResetConfirmContent: @Composable (state: PasswordResetConfirmState) -> Unit = { PasswordResetConfirm(it) }, verifyUserContent: @Composable (state: VerifyUserState) -> Unit = { VerifyUser(it) }, verifyUserConfirmContent: @Composable (state: VerifyUserConfirmState) -> Unit = { VerifyUserConfirm(it) }, + signInSelectAuthFactorContent: @Composable (SignInSelectAuthFactorState) -> Unit = { SignInSelectAuthFactor(it) }, + signInConfirmPasswordContent: @Composable (SignInConfirmPasswordState) -> Unit = { SignInConfirmPassword(it) }, + promptToCreatePasskeyContent: @Composable (PromptToCreatePasskeyState) -> Unit = { PromptToCreatePasskey(it) }, + passkeyCreatedContent: @Composable (PasskeyCreatedState) -> Unit = { PasskeyCreated(it) }, errorContent: @Composable (state: ErrorState) -> Unit = { AuthenticatorError(it) }, headerContent: @Composable () -> Unit = {}, footerContent: @Composable () -> Unit = {}, @@ -147,6 +155,7 @@ fun Authenticator( when (targetState) { is LoadingState -> loadingContent() is SignInState -> signInContent(targetState) + is SignInSelectAuthFactorState -> signInSelectAuthFactorContent(targetState) is SignInConfirmMfaState -> signInConfirmMfaContent(targetState) is SignInConfirmCustomState -> signInConfirmCustomContent(targetState) is SignInConfirmNewPasswordState -> signInConfirmNewPasswordContent( @@ -154,6 +163,7 @@ fun Authenticator( ) is SignInConfirmTotpCodeState -> signInConfirmTotpCodeContent(targetState) + is SignInConfirmPasswordState -> signInConfirmPasswordContent(targetState) is SignInContinueWithTotpSetupState -> signInContinueWithTotpSetupContent(targetState) is SignInContinueWithEmailSetupState -> signInContinueWithEmailSetupContent(targetState) is SignInContinueWithMfaSetupSelectionState -> @@ -167,7 +177,9 @@ fun Authenticator( is SignUpConfirmState -> signUpConfirmContent(targetState) is VerifyUserState -> verifyUserContent(targetState) is VerifyUserConfirmState -> verifyUserConfirmContent(targetState) - else -> Unit + is PromptToCreatePasskeyState -> promptToCreatePasskeyContent(targetState) + is PasskeyCreatedState -> passkeyCreatedContent(targetState) + else -> error("Unimplemented state") } footerContent() } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorButton.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorButton.kt new file mode 100644 index 00000000..0eac5752 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorButton.kt @@ -0,0 +1,60 @@ +package com.amplifyframework.ui.authenticator.ui + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.amplifyframework.ui.authenticator.R + +internal enum class ButtonStyle { + Primary, + Secondary +} + +/** + * The button displayed in Authenticator. + * @param onClick The click handler for the button + * @param loading True to show the [loadingIndicator] content, false to show the button label. + * @param modifier The [Modifier] for the composable. + * @param label The label for the button + * @param loadingIndicator The content to show when loading. + */ +@Composable +internal fun AuthenticatorButton( + onClick: () -> Unit, + loading: Boolean, + modifier: Modifier = Modifier, + label: String = stringResource(R.string.amplify_ui_authenticator_button_submit), + loadingIndicator: @Composable () -> Unit = { LoadingIndicator() }, + enabled: Boolean = true, + style: ButtonStyle = ButtonStyle.Primary +) { + if (style == ButtonStyle.Primary) { + Button( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + enabled = enabled && !loading + ) { + if (loading) { + loadingIndicator() + } else { + Text(label) + } + } + } else { + OutlinedButton( + modifier = modifier.fillMaxWidth(), + onClick = onClick, + enabled = enabled && !loading + ) { + if (loading) { + loadingIndicator() + } else { + Text(label) + } + } + } +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorField.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorField.kt index cf9554fb..18bd2854 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorField.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorField.kt @@ -23,6 +23,8 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import com.amplifyframework.ui.authenticator.forms.FieldConfig @@ -79,13 +81,16 @@ internal fun AuthenticatorFieldError( error: FieldError?, modifier: Modifier = Modifier ) { + var lastError by remember { mutableStateOf(null) } + if (error != null) lastError = error + AnimatedVisibility( modifier = modifier, visible = error != null, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically() ) { - val text = error?.let { StringResolver.error(config = fieldConfig, error = it) } ?: "" + val text = lastError?.let { StringResolver.error(config = fieldConfig, error = it) } ?: "" Text(text = text) } } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorForm.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorForm.kt index 57b31c6a..37c26d46 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorForm.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/AuthenticatorForm.kt @@ -20,15 +20,11 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.amplifyframework.ui.authenticator.R import com.amplifyframework.ui.authenticator.forms.MutableFormState /** @@ -37,10 +33,7 @@ import com.amplifyframework.ui.authenticator.forms.MutableFormState * @param modifier The Modifier for the composable. */ @Composable -internal fun AuthenticatorForm( - state: MutableFormState, - modifier: Modifier = Modifier -) { +internal fun AuthenticatorForm(state: MutableFormState, modifier: Modifier = Modifier) { Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -57,32 +50,3 @@ internal fun AuthenticatorForm( Spacer(modifier = Modifier.size(16.dp)) } } - -/** - * The button displayed in Authenticator. - * @param onClick The click handler for the button - * @param loading True to show the [loadingIndicator] content, false to show the button label. - * @param modifier The [Modifier] for the composable. - * @param label The label for the button - * @param loadingIndicator The content to show when loading. - */ -@Composable -internal fun AuthenticatorButton( - onClick: () -> Unit, - loading: Boolean, - modifier: Modifier = Modifier, - label: String = stringResource(R.string.amplify_ui_authenticator_button_submit), - loadingIndicator: @Composable () -> Unit = { LoadingIndicator() } -) { - Button( - modifier = modifier.fillMaxWidth(), - onClick = onClick, - enabled = !loading - ) { - if (loading) { - loadingIndicator() - } else { - Text(label) - } - } -} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/CommonFooter.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/CommonFooter.kt index 381f4051..d4be20f8 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/CommonFooter.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/CommonFooter.kt @@ -14,7 +14,8 @@ import com.amplifyframework.ui.authenticator.R @Composable internal fun BackToSignInFooter( onClickBackToSignIn: () -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + label: String = stringResource(R.string.amplify_ui_authenticator_button_back_to_signin) ) { Box( modifier = modifier.fillMaxWidth(), @@ -24,7 +25,7 @@ internal fun BackToSignInFooter( modifier = Modifier.testTag(TestTags.BackToSignInButton), onClick = onClickBackToSignIn ) { - Text(stringResource(R.string.amplify_ui_authenticator_button_back_to_signin)) + Text(label) } } } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/DividerWithText.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/DividerWithText.kt new file mode 100644 index 00000000..7ae67fe8 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/DividerWithText.kt @@ -0,0 +1,47 @@ +package com.amplifyframework.ui.authenticator.ui + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +internal fun DividerWithText( + text: String, + modifier: Modifier = Modifier, + textStyle: TextStyle = MaterialTheme.typography.bodyMedium, + textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + dividerColor: Color = MaterialTheme.colorScheme.outline, + thickness: Dp = 1.dp, + textPadding: Dp = 16.dp +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + HorizontalDivider( + modifier = Modifier.weight(1f), + color = dividerColor, + thickness = thickness + ) + Text( + text = text, + style = textStyle, + color = textColor, + modifier = Modifier.padding(horizontal = textPadding) + ) + HorizontalDivider( + modifier = Modifier.weight(1f), + color = dividerColor, + thickness = thickness + ) + } +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreated.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreated.kt new file mode 100644 index 00000000..356a3388 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreated.kt @@ -0,0 +1,85 @@ +package com.amplifyframework.ui.authenticator.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.amplifyframework.auth.result.AuthWebAuthnCredential +import com.amplifyframework.ui.authenticator.PasskeyCreatedState +import com.amplifyframework.ui.authenticator.PasskeyCreatedState.Action +import com.amplifyframework.ui.authenticator.R +import kotlinx.coroutines.launch + +@Composable +fun PasskeyCreated( + state: PasskeyCreatedState, + modifier: Modifier = Modifier, + headerContent: @Composable (PasskeyCreatedState) -> Unit = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Image( + painter = painterResource(R.drawable.authenticator_success), + contentDescription = null, + modifier = Modifier.size(32.dp) + ) + AuthenticatorTitle(stringResource(R.string.amplify_ui_authenticator_title_passkey_created)) + } + }, + footerContent: @Composable (PasskeyCreatedState) -> Unit = { } +) { + val scope = rememberCoroutineScope() + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + headerContent(state) + + if (state.passkeys.isNotEmpty()) { + Text( + stringResource(R.string.amplify_ui_authenticator_existing_passkeys), + style = MaterialTheme.typography.titleSmall + ) + Spacer(modifier = Modifier.size(8.dp)) + Card { + Column(modifier = Modifier.padding(16.dp).fillMaxWidth()) { + state.passkeys.forEachIndexed { index, passkey -> + Passkey(passkey) + if (index != state.passkeys.size - 1) { + HorizontalDivider(modifier = Modifier.padding(vertical = 16.dp)) + } + } + } + } + Spacer(modifier = Modifier.size(16.dp)) + } + + AuthenticatorButton( + modifier = Modifier.testTag(TestTags.ContinueButton), + onClick = { scope.launch { state.continueSignIn() } }, + loading = state.action is Action.ContinueSignIn, + label = stringResource(R.string.amplify_ui_authenticator_button_continue) + ) + + footerContent(state) + } +} + +@Composable +private fun Passkey(credential: AuthWebAuthnCredential) { + Text(credential.friendlyName ?: stringResource(R.string.amplify_ui_authenticator_unknown_passkey)) +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PromptToCreatePasskey.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PromptToCreatePasskey.kt new file mode 100644 index 00000000..8f54c24d --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/PromptToCreatePasskey.kt @@ -0,0 +1,76 @@ +package com.amplifyframework.ui.authenticator.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.amplifyframework.ui.authenticator.PromptToCreatePasskeyState +import com.amplifyframework.ui.authenticator.PromptToCreatePasskeyState.Action +import com.amplifyframework.ui.authenticator.R +import kotlinx.coroutines.launch + +@Composable +fun PromptToCreatePasskey( + state: PromptToCreatePasskeyState, + modifier: Modifier = Modifier, + headerContent: @Composable (PromptToCreatePasskeyState) -> Unit = { + AuthenticatorTitle(stringResource(R.string.amplify_ui_authenticator_title_prompt_for_passkey)) + }, + footerContent: @Composable (PromptToCreatePasskeyState) -> Unit = {} +) { + val scope = rememberCoroutineScope() + val action = state.action + + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + headerContent(state) + + Text(stringResource(R.string.amplify_ui_authenticator_passkey_prompt_content)) + + Spacer(modifier = Modifier.size(16.dp)) + + Image( + painter = painterResource(R.drawable.authenticator_passkey), + contentDescription = null, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(modifier = Modifier.size(16.dp)) + + AuthenticatorButton( + onClick = { + scope.launch { state.createPasskey() } + }, + loading = action is Action.CreatePasskey, + enabled = action == null, + label = stringResource(R.string.amplify_ui_authenticator_button_create_passkey), + modifier = Modifier.testTag(TestTags.CreatePasskeyButton) + ) + + AuthenticatorButton( + modifier = Modifier.fillMaxWidth().testTag(TestTags.SkipPasskeyButton), + onClick = { + scope.launch { state.skip() } + }, + loading = action is Action.Skip, + enabled = action == null, + label = stringResource(R.string.amplify_ui_authenticator_button_skip_passkey), + style = ButtonStyle.Secondary + ) + + footerContent(state) + } +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInConfirmPassword.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInConfirmPassword.kt new file mode 100644 index 00000000..7a7eca74 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInConfirmPassword.kt @@ -0,0 +1,70 @@ +package com.amplifyframework.ui.authenticator.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.amplifyframework.ui.authenticator.R +import com.amplifyframework.ui.authenticator.SignInConfirmPasswordState +import com.amplifyframework.ui.authenticator.auth.toFieldKey +import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep +import com.amplifyframework.ui.authenticator.forms.FieldKey +import com.amplifyframework.ui.authenticator.states.signInMethod +import com.amplifyframework.ui.authenticator.strings.StringResolver +import com.amplifyframework.ui.authenticator.util.AuthenticatorUiConstants +import kotlinx.coroutines.launch + +@Composable +fun SignInConfirmPassword( + state: SignInConfirmPasswordState, + modifier: Modifier = Modifier, + headerContent: @Composable (state: SignInConfirmPasswordState) -> Unit = { + AuthenticatorTitle(stringResource(R.string.amplify_ui_authenticator_title_signin_confirm_password)) + }, + footerContent: @Composable (state: SignInConfirmPasswordState) -> Unit = { SignInConfirmPasswordFooter(it) } +) { + val scope = rememberCoroutineScope() + Column( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + headerContent(state) + val usernameLabel = StringResolver.fieldName(state.signInMethod.toFieldKey()) + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .testTag(FieldKey.Username.testTag), + value = state.username, + onValueChange = {}, + label = { Text(usernameLabel) }, + enabled = false + ) + Spacer(modifier = Modifier.size(AuthenticatorUiConstants.spaceBetweenFields)) + AuthenticatorForm( + state = state.form + ) + AuthenticatorButton( + onClick = { scope.launch { state.signIn() } }, + loading = !state.form.enabled, + label = stringResource(R.string.amplify_ui_authenticator_button_signin), + modifier = Modifier.testTag(TestTags.SignInButton) + ) + footerContent(state) + } +} + +@Composable +fun SignInConfirmPasswordFooter(state: SignInConfirmPasswordState, modifier: Modifier = Modifier) = BackToSignInFooter( + modifier = modifier, + onClickBackToSignIn = { state.moveTo(AuthenticatorStep.SignIn) } +) diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactor.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactor.kt new file mode 100644 index 00000000..fc160b38 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactor.kt @@ -0,0 +1,113 @@ +package com.amplifyframework.ui.authenticator.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.amplifyframework.ui.authenticator.R +import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState +import com.amplifyframework.ui.authenticator.SignInSelectAuthFactorState.Action +import com.amplifyframework.ui.authenticator.auth.toFieldKey +import com.amplifyframework.ui.authenticator.data.AuthFactor +import com.amplifyframework.ui.authenticator.data.containsPassword +import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep +import com.amplifyframework.ui.authenticator.forms.FieldKey +import com.amplifyframework.ui.authenticator.states.getPasswordFactor +import com.amplifyframework.ui.authenticator.states.signInMethod +import com.amplifyframework.ui.authenticator.strings.StringResolver +import com.amplifyframework.ui.authenticator.util.AuthenticatorUiConstants +import kotlinx.coroutines.launch + +@Composable +fun SignInSelectAuthFactor( + state: SignInSelectAuthFactorState, + modifier: Modifier = Modifier, + headerContent: @Composable (SignInSelectAuthFactorState) -> Unit = { + AuthenticatorTitle(stringResource(R.string.amplify_ui_authenticator_title_select_factor)) + }, + footerContent: @Composable (SignInSelectAuthFactorState) -> Unit = { SignInSelectAuthFactorFooter(it) } +) { + Column( + modifier = modifier.fillMaxWidth().padding(horizontal = 16.dp) + ) { + headerContent(state) + + val usernameLabel = StringResolver.fieldName(state.signInMethod.toFieldKey()) + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .testTag(FieldKey.Username.testTag), + value = state.username, + onValueChange = {}, + label = { Text(usernameLabel) }, + enabled = false + ) + Spacer(modifier = Modifier.size(AuthenticatorUiConstants.spaceBetweenFields)) + AuthenticatorForm( + state = state.form + ) + + if (state.availableAuthFactors.containsPassword()) { + AuthFactorButton(authFactor = state.getPasswordFactor(), state = state) + if (state.availableAuthFactors.size > 1) { + DividerWithText( + text = stringResource(R.string.amplify_ui_authenticator_or), + modifier = Modifier.fillMaxWidth(0.5f).align(Alignment.CenterHorizontally) + ) + } + } + + if (state.availableAuthFactors.contains(AuthFactor.WebAuthn)) { + AuthFactorButton(authFactor = AuthFactor.WebAuthn, state = state) + } + if (state.availableAuthFactors.contains(AuthFactor.EmailOtp)) { + AuthFactorButton(authFactor = AuthFactor.EmailOtp, state = state) + } + if (state.availableAuthFactors.contains(AuthFactor.SmsOtp)) { + AuthFactorButton(authFactor = AuthFactor.SmsOtp, state = state) + } + footerContent(state) + } +} + +@Composable +fun SignInSelectAuthFactorFooter(state: SignInSelectAuthFactorState, modifier: Modifier = Modifier) = + BackToSignInFooter( + modifier = modifier, + onClickBackToSignIn = { state.moveTo(AuthenticatorStep.SignIn) } + ) + +@Composable +private fun AuthFactorButton( + authFactor: AuthFactor, + state: SignInSelectAuthFactorState, + modifier: Modifier = Modifier +) { + val action = state.action + val scope = rememberCoroutineScope() + AuthenticatorButton( + onClick = { scope.launch { state.select(authFactor) } }, + loading = action is Action.SelectAuthFactor && action.factor == authFactor, + enabled = action == null, + label = stringResource(authFactor.signInResourceId), + modifier = modifier.testTag(authFactor.testTag) + ) +} + +private val AuthFactor.signInResourceId: Int + get() = when (this) { + is AuthFactor.Password -> R.string.amplify_ui_authenticator_button_signin_password + AuthFactor.SmsOtp -> R.string.amplify_ui_authenticator_button_signin_sms + AuthFactor.EmailOtp -> R.string.amplify_ui_authenticator_button_signin_email + AuthFactor.WebAuthn -> R.string.amplify_ui_authenticator_button_signin_passkey + } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/TestTags.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/TestTags.kt index a63665a5..e99d1775 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/TestTags.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/ui/TestTags.kt @@ -15,6 +15,7 @@ package com.amplifyframework.ui.authenticator.ui +import com.amplifyframework.ui.authenticator.data.AuthFactor import com.amplifyframework.ui.authenticator.forms.FieldKey @Suppress("ConstPropertyName") @@ -27,10 +28,26 @@ internal object TestTags { const val ForgotPasswordButton = "ForgotPasswordButton" const val CreateAccountButton = "CreateAccountButton" const val PasswordResetButton = "PasswordResetButton" + const val ContinueButton = "ContinueButton" + const val CreatePasskeyButton = "CreatePasskeyButton" + const val SkipPasskeyButton = "SkipPasskeyButton" const val AuthenticatorTitle = "AuthenticatorTitle" + const val AuthFactorPassword = "AuthFactorPassword" + const val AuthFactorSms = "AuthFactorSms" + const val AuthFactorEmail = "AuthFactorEmail" + const val AuthFactorPasskey = "AuthFactorPasskey" + const val ShowPasswordIcon = "ShowPasswordIcon" } internal val FieldKey.testTag: String get() = this.toString() + +internal val AuthFactor.testTag: String + get() = when (this) { + is AuthFactor.Password -> TestTags.AuthFactorPassword + AuthFactor.SmsOtp -> TestTags.AuthFactorSms + AuthFactor.EmailOtp -> TestTags.AuthFactorEmail + AuthFactor.WebAuthn -> TestTags.AuthFactorPasskey + } diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AmplifyExtensions.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AmplifyExtensions.kt new file mode 100644 index 00000000..aaff8f39 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AmplifyExtensions.kt @@ -0,0 +1,50 @@ +package com.amplifyframework.ui.authenticator.util + +import android.app.Activity +import aws.sdk.kotlin.services.cognitoidentityprovider.model.NotAuthorizedException +import aws.smithy.kotlin.runtime.ServiceException +import com.amplifyframework.auth.AuthException +import com.amplifyframework.auth.cognito.options.AWSCognitoAuthConfirmSignInOptions +import com.amplifyframework.auth.cognito.options.AWSCognitoAuthSignInOptions +import com.amplifyframework.auth.cognito.options.AuthFlowType +import com.amplifyframework.ui.authenticator.data.AuthFactor +import com.amplifyframework.ui.authenticator.data.AuthenticationFlow +import com.amplifyframework.ui.authenticator.data.toAuthFactorType + +internal fun AWSCognitoAuthSignInOptions.CognitoBuilder.preferredFirstFactor( + authenticationFlow: AuthenticationFlow, + override: AuthFactor? +) = apply { + if (authenticationFlow is AuthenticationFlow.UserChoice) { + val factor = override ?: authenticationFlow.preferredAuthFactor + preferredFirstFactor(factor?.toAuthFactorType()) + } +} + +internal fun AWSCognitoAuthSignInOptions.CognitoBuilder.authFlow(authFlow: AuthFlowType?) = apply { + authFlow?.let { authFlowType(it) } +} + +internal fun AWSCognitoAuthSignInOptions.CognitoBuilder.callingActivity(activity: Activity?) = apply { + activity?.let { callingActivity(it) } +} + +internal fun AWSCognitoAuthConfirmSignInOptions.CognitoBuilder.callingActivity(activity: Activity?) = apply { + activity?.let { callingActivity(it) } +} + +internal fun AuthException.isAuthFlowSessionExpiredError(): Boolean { + val sdkException = getCauseOrNull() + if (sdkException == null) return false + return sdkException.sdkErrorMetadata.errorType == ServiceException.ErrorType.Client && + sdkException.message.contains("session") +} + +internal inline fun AuthException.getCauseOrNull(): T? { + var causedBy = this.cause + while (causedBy != null && causedBy != this) { + if (causedBy is T) return causedBy + causedBy = causedBy.cause + } + return null +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AmplifyResult.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AmplifyResult.kt new file mode 100644 index 00000000..fa1acddc --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AmplifyResult.kt @@ -0,0 +1,13 @@ +package com.amplifyframework.ui.authenticator.util + +import com.amplifyframework.auth.AuthException + +internal sealed interface AmplifyResult { + data class Success(val data: T) : AmplifyResult + data class Error(val error: AuthException) : AmplifyResult +} + +internal inline fun AmplifyResult.getOrDefault(crossinline provider: () -> T) = when (this) { + is AmplifyResult.Error -> provider() + is AmplifyResult.Success -> this.data +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthProvider.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthProvider.kt index 6892de62..be6b80e1 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthProvider.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthProvider.kt @@ -15,10 +15,10 @@ package com.amplifyframework.ui.authenticator.util +import android.app.Activity import com.amplifyframework.auth.AWSCognitoAuthMetadataType import com.amplifyframework.auth.AuthChannelEventName import com.amplifyframework.auth.AuthCodeDeliveryDetails -import com.amplifyframework.auth.AuthException import com.amplifyframework.auth.AuthSession import com.amplifyframework.auth.AuthUser import com.amplifyframework.auth.AuthUserAttribute @@ -27,11 +27,14 @@ import com.amplifyframework.auth.cognito.AWSCognitoAuthPlugin import com.amplifyframework.auth.cognito.PasswordProtectionSettings import com.amplifyframework.auth.cognito.UsernameAttribute import com.amplifyframework.auth.cognito.VerificationMechanism as AmplifyVerificationMechanism +import com.amplifyframework.auth.options.AuthConfirmSignInOptions +import com.amplifyframework.auth.options.AuthSignInOptions import com.amplifyframework.auth.options.AuthSignUpOptions import com.amplifyframework.auth.result.AuthResetPasswordResult import com.amplifyframework.auth.result.AuthSignInResult import com.amplifyframework.auth.result.AuthSignOutResult import com.amplifyframework.auth.result.AuthSignUpResult +import com.amplifyframework.auth.result.AuthWebAuthnCredential import com.amplifyframework.core.Amplify import com.amplifyframework.hub.HubChannel import com.amplifyframework.hub.HubEvent @@ -52,11 +55,14 @@ import kotlinx.coroutines.flow.callbackFlow * An abstraction of the Amplify.Auth API that allows us to use coroutines with no exceptions */ internal interface AuthProvider { - suspend fun signIn(username: String, password: String): AmplifyResult + suspend fun signIn(username: String, password: String?, options: AuthSignInOptions): AmplifyResult - suspend fun confirmSignIn(challengeResponse: String): AmplifyResult + suspend fun confirmSignIn( + challengeResponse: String, + options: AuthConfirmSignInOptions + ): AmplifyResult - suspend fun signUp(username: String, password: String, options: AuthSignUpOptions): AmplifyResult + suspend fun signUp(username: String, password: String?, options: AuthSignUpOptions): AmplifyResult suspend fun confirmSignUp(username: String, code: String): AmplifyResult @@ -76,6 +82,10 @@ internal interface AuthProvider { suspend fun fetchAuthSession(): AmplifyResult + suspend fun createPasskey(activity: Activity): AmplifyResult + + suspend fun getPasskeys(): AmplifyResult> + suspend fun fetchUserAttributes(): AmplifyResult> suspend fun confirmUserAttribute(key: AuthUserAttributeKey, confirmationCode: String): AmplifyResult @@ -106,24 +116,28 @@ internal class RealAuthProvider : AuthProvider { cognitoPlugin?.addToUserAgent(AWSCognitoAuthMetadataType.Authenticator, BuildConfig.VERSION_NAME) } - override suspend fun signIn(username: String, password: String) = suspendCoroutine { continuation -> - Amplify.Auth.signIn( - username, - password, - { continuation.resume(AmplifyResult.Success(it)) }, - { continuation.resume(AmplifyResult.Error(it)) } - ) - } + override suspend fun signIn(username: String, password: String?, options: AuthSignInOptions) = + suspendCoroutine { continuation -> + Amplify.Auth.signIn( + username, + password, + options, + { continuation.resume(AmplifyResult.Success(it)) }, + { continuation.resume(AmplifyResult.Error(it)) } + ) + } - override suspend fun confirmSignIn(challengeResponse: String) = suspendCoroutine { continuation -> - Amplify.Auth.confirmSignIn( - challengeResponse, - { continuation.resume(AmplifyResult.Success(it)) }, - { continuation.resume(AmplifyResult.Error(it)) } - ) - } + override suspend fun confirmSignIn(challengeResponse: String, options: AuthConfirmSignInOptions) = + suspendCoroutine { continuation -> + Amplify.Auth.confirmSignIn( + challengeResponse, + options, + { continuation.resume(AmplifyResult.Success(it)) }, + { continuation.resume(AmplifyResult.Error(it)) } + ) + } - override suspend fun signUp(username: String, password: String, options: AuthSignUpOptions) = + override suspend fun signUp(username: String, password: String?, options: AuthSignUpOptions) = suspendCoroutine { continuation -> Amplify.Auth.signUp( username, @@ -188,6 +202,21 @@ internal class RealAuthProvider : AuthProvider { ) } + override suspend fun createPasskey(activity: Activity) = suspendCoroutine { continuation -> + Amplify.Auth.associateWebAuthnCredential( + activity, + { continuation.resume(AmplifyResult.Success(Unit)) }, + { continuation.resume(AmplifyResult.Error(it)) } + ) + } + + override suspend fun getPasskeys(): AmplifyResult> = suspendCoroutine { continuation -> + Amplify.Auth.listWebAuthnCredentials( + { continuation.resume(AmplifyResult.Success(it.credentials)) }, + { continuation.resume(AmplifyResult.Error(it)) } + ) + } + override suspend fun fetchUserAttributes() = suspendCoroutine { continuation -> Amplify.Auth.fetchUserAttributes( { continuation.resume(AmplifyResult.Success(it)) }, @@ -277,8 +306,3 @@ internal class RealAuthProvider : AuthProvider { requiresLower = requiresLower ) } - -internal sealed interface AmplifyResult { - data class Success(val data: T) : AmplifyResult - data class Error(val error: AuthException) : AmplifyResult -} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorMessage.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorMessage.kt index 48f18e10..fded3678 100644 --- a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorMessage.kt +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorMessage.kt @@ -148,3 +148,18 @@ internal class NetworkErrorMessage(override val cause: AuthException) : internal class LimitExceededMessage(override val cause: AuthException) : AuthenticatorMessageImpl(R.string.amplify_ui_authenticator_error_limit_exceeded), AuthenticatorMessage.Error + +/** + * The user's authentication flow session has expired, indicating that they took too long to provide an auth factor. + * The session duration is configured in your user pool's app client. + */ +internal class AuthFlowSessionExpiredMessage(override val cause: AuthException) : + AuthenticatorMessageImpl(R.string.amplify_ui_authenticator_error_session_expired), + AuthenticatorMessage.Error + +/** + * The passkey creation failed + */ +internal class PasskeyCreationFailedMessage(override val cause: AuthException) : + AuthenticatorMessageImpl(R.string.amplify_ui_authenticator_error_passkey_creation), + AuthenticatorMessage.Error diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorUiConstants.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorUiConstants.kt new file mode 100644 index 00000000..4a27ed17 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/AuthenticatorUiConstants.kt @@ -0,0 +1,7 @@ +package com.amplifyframework.ui.authenticator.util + +import androidx.compose.ui.unit.dp + +internal object AuthenticatorUiConstants { + val spaceBetweenFields = 8.dp +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/ContextExtensions.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/ContextExtensions.kt new file mode 100644 index 00000000..a0bba44f --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/ContextExtensions.kt @@ -0,0 +1,14 @@ +package com.amplifyframework.ui.authenticator.util + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +/** + * Allows us to get the Activity reference from Compose LocalContext + */ +internal tailrec fun Context.findActivity(): Activity? = when (this) { + is Activity -> this + is ContextWrapper -> this.baseContext.findActivity() + else -> null +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/OsBuild.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/OsBuild.kt new file mode 100644 index 00000000..4f9c00d4 --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/OsBuild.kt @@ -0,0 +1,9 @@ +package com.amplifyframework.ui.authenticator.util + +import android.os.Build + +// Facade for android.os.Build to facilitate testing +internal class OsBuild { + val sdkInt: Int + get() = Build.VERSION.SDK_INT +} diff --git a/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/PasskeyPromptCheck.kt b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/PasskeyPromptCheck.kt new file mode 100644 index 00000000..09dbc09e --- /dev/null +++ b/authenticator/src/main/java/com/amplifyframework/ui/authenticator/util/PasskeyPromptCheck.kt @@ -0,0 +1,36 @@ +package com.amplifyframework.ui.authenticator.util + +import com.amplifyframework.ui.authenticator.AuthenticatorConfiguration +import com.amplifyframework.ui.authenticator.data.AuthenticationFlow +import com.amplifyframework.ui.authenticator.data.PasskeyPrompt +import com.amplifyframework.ui.authenticator.data.UserInfo +import com.amplifyframework.ui.authenticator.enums.SignInSource + +// Utility class for checking whether a user should be shown a passkey prompt +internal class PasskeyPromptCheck(private val authProvider: AuthProvider, private val osBuild: OsBuild = OsBuild()) { + suspend fun shouldPromptForPasskey(userInfo: UserInfo, config: AuthenticatorConfiguration): Boolean { + // Ensure that userHasPasskey is the last check so that the network request can be short-circuited by + // the local-only checks. + val authFlow = config.authenticationFlow + return authFlow is AuthenticationFlow.UserChoice && + deviceSupportsPasskeyCreation() && + passkeyPromptsEnabled(userInfo, authFlow) && + !userHasPasskey() + } + + // Passkey creation supported starting with API 28 + private fun deviceSupportsPasskeyCreation() = osBuild.sdkInt >= 28 + + // Check whether passkey prompts are enabled by configuration + private fun passkeyPromptsEnabled(userInfo: UserInfo, authFlow: AuthenticationFlow.UserChoice): Boolean = + when (userInfo.signInSource) { + SignInSource.SignIn -> authFlow.passkeyPrompts.afterSignIn == PasskeyPrompt.Always + SignInSource.AutoSignIn -> authFlow.passkeyPrompts.afterSignUp == PasskeyPrompt.Always + } + + // Check if the user already has a passkey registered + private suspend fun userHasPasskey() = when (val result = authProvider.getPasskeys()) { + is AmplifyResult.Error -> true // Assume user already has passkey on error so we don't incorrectly prompt them + is AmplifyResult.Success -> result.data.isNotEmpty() + } +} diff --git a/authenticator/src/main/res/drawable/authenticator_passkey.xml b/authenticator/src/main/res/drawable/authenticator_passkey.xml new file mode 100644 index 00000000..91c21dee --- /dev/null +++ b/authenticator/src/main/res/drawable/authenticator_passkey.xml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/authenticator/src/main/res/drawable/authenticator_success.xml b/authenticator/src/main/res/drawable/authenticator_success.xml new file mode 100644 index 00000000..9a86c70a --- /dev/null +++ b/authenticator/src/main/res/drawable/authenticator_success.xml @@ -0,0 +1,9 @@ + + + diff --git a/authenticator/src/main/res/values/buttons.xml b/authenticator/src/main/res/values/buttons.xml index 2e654d2d..4991c7e8 100644 --- a/authenticator/src/main/res/values/buttons.xml +++ b/authenticator/src/main/res/values/buttons.xml @@ -18,6 +18,10 @@ Submit Continue Sign In + Sign In with Password + Sign In with SMS + Sign In with Email + Sign In with Passkey Change Password Create Account Forgot Password? @@ -26,4 +30,7 @@ Send Code Skip Copy Key + Start Over + Create a Passkey + Continue without a Passkey diff --git a/authenticator/src/main/res/values/errors.xml b/authenticator/src/main/res/values/errors.xml index b3f46095..1780d289 100644 --- a/authenticator/src/main/res/values/errors.xml +++ b/authenticator/src/main/res/values/errors.xml @@ -16,11 +16,14 @@ Username or Password is incorrect + + Passkey creation failed User password cannot be reset in the current state Could not send verification code Code has expired Please check your connectivity You\'ve reached the request limit. Please try again later. + Your session has expired. Please try to sign in again. Sorry, something went wrong diff --git a/authenticator/src/main/res/values/strings.xml b/authenticator/src/main/res/values/strings.xml index 755ec635..03d3eca2 100644 --- a/authenticator/src/main/res/values/strings.xml +++ b/authenticator/src/main/res/values/strings.xml @@ -36,4 +36,14 @@ Text Message (SMS) Authenticator App (TOTP) Email Message + + + Passkeys are WebAuthn credentials that validate your identity using biometric data like touch or facial recognition or device authentication like passwords or PINs, serving as a secure password replacement. + + + Existing Passkeys + Unknown Provider + + + or diff --git a/authenticator/src/main/res/values/titles.xml b/authenticator/src/main/res/values/titles.xml index e3db56de..5ac623bb 100644 --- a/authenticator/src/main/res/values/titles.xml +++ b/authenticator/src/main/res/values/titles.xml @@ -16,6 +16,8 @@ Sign In + Choose how to sign in + Enter your password Verify your sign-in Change your password to sign in Create Account @@ -27,4 +29,6 @@ Choose your preferred two-factor authentication method to set up Choose your two-factor authentication method Add Email for Two-Factor Authentication + Sign in faster with Passkey + Passkey created successfully! diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModelTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModelTest.kt index 5e464327..7d677495 100644 --- a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModelTest.kt +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/AuthenticatorViewModelTest.kt @@ -32,6 +32,7 @@ import com.amplifyframework.auth.result.step.AuthSignInStep import com.amplifyframework.auth.result.step.AuthSignUpStep import com.amplifyframework.hub.HubEvent import com.amplifyframework.ui.authenticator.auth.VerificationMechanism +import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep import com.amplifyframework.ui.authenticator.util.AmplifyResult import com.amplifyframework.ui.authenticator.util.AmplifyResult.Error @@ -85,8 +86,8 @@ class AuthenticatorViewModelTest { @Test fun `start only executes once`() = runTest { - viewModel.start(mockAuthenticatorConfiguration()) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() + viewModel.start() advanceUntilIdle() // fetchAuthSession only called by the first start @@ -99,7 +100,7 @@ class AuthenticatorViewModelTest { fun `missing configuration results in an error`() = runTest { coEvery { authProvider.getConfiguration() } returns AuthConfigurationResult.Missing - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() advanceUntilIdle() coVerify(exactly = 0) { authProvider.fetchAuthSession() } @@ -110,7 +111,7 @@ class AuthenticatorViewModelTest { fun `invalid configuration results in an error`() = runTest { coEvery { authProvider.getConfiguration() } returns AuthConfigurationResult.Invalid("Invalid") - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() advanceUntilIdle() coVerify(exactly = 0) { authProvider.fetchAuthSession() } @@ -121,7 +122,7 @@ class AuthenticatorViewModelTest { fun `fetchAuthSession error during start results in an error`() = runTest { coEvery { authProvider.fetchAuthSession() } returns AmplifyResult.Error(mockAuthException()) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() advanceUntilIdle() coVerify(exactly = 1) { authProvider.fetchAuthSession() } @@ -133,7 +134,7 @@ class AuthenticatorViewModelTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true)) coEvery { authProvider.getCurrentUser() } returns AmplifyResult.Error(mockAuthException()) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() advanceUntilIdle() coVerify(exactly = 1) { @@ -148,7 +149,7 @@ class AuthenticatorViewModelTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true)) coEvery { authProvider.getCurrentUser() } returns AmplifyResult.Error(SessionExpiredException()) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() advanceUntilIdle() coVerify(exactly = 1) { @@ -163,7 +164,7 @@ class AuthenticatorViewModelTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = true)) coEvery { authProvider.getCurrentUser() } returns Success(mockAuthUser()) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() advanceUntilIdle() coVerify(exactly = 1) { @@ -177,7 +178,7 @@ class AuthenticatorViewModelTest { fun `initial step is SignIn`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() advanceUntilIdle() viewModel.currentStep shouldBe AuthenticatorStep.SignIn @@ -189,14 +190,14 @@ class AuthenticatorViewModelTest { @Test fun `TOTPSetup next step shows error if totpSetupDetails is null`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success( + coEvery { authProvider.signIn(any(), any(), any()) } returns Success( mockSignInResult( signInStep = AuthSignInStep.CONTINUE_SIGN_IN_WITH_TOTP_SETUP, totpSetupDetails = null ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.Error @@ -205,14 +206,14 @@ class AuthenticatorViewModelTest { @Test fun `TOTPSetup next step shows SignInContinueWithTotpSetup screen`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success( + coEvery { authProvider.signIn(any(), any(), any()) } returns Success( mockSignInResult( signInStep = AuthSignInStep.CONTINUE_SIGN_IN_WITH_TOTP_SETUP, totpSetupDetails = mockk(relaxed = true) ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignInContinueWithTotpSetup @@ -221,11 +222,11 @@ class AuthenticatorViewModelTest { @Test fun `TOTP Code next step shows the SignInConfirmTotpCode screen`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success( + coEvery { authProvider.signIn(any(), any(), any()) } returns Success( mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_IN_WITH_TOTP_CODE) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignInConfirmTotpCode @@ -234,11 +235,11 @@ class AuthenticatorViewModelTest { @Test fun `SMS MFA Code next step shows the SignInConfirmMfa screen`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success( + coEvery { authProvider.signIn(any(), any(), any()) } returns Success( mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_IN_WITH_SMS_MFA_CODE) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignInConfirmMfa @@ -247,11 +248,11 @@ class AuthenticatorViewModelTest { @Test fun `Custom Challenge next step shows the SignInConfirmCustomAuth screen`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success( + coEvery { authProvider.signIn(any(), any(), any()) } returns Success( mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_IN_WITH_CUSTOM_CHALLENGE) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignInConfirmCustomAuth @@ -260,11 +261,11 @@ class AuthenticatorViewModelTest { @Test fun `New Password next step shows the SignInConfirmNewPassword screen`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success( + coEvery { authProvider.signIn(any(), any(), any()) } returns Success( mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_IN_WITH_NEW_PASSWORD) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignInConfirmNewPassword @@ -273,12 +274,12 @@ class AuthenticatorViewModelTest { @Test fun `Confirm SignUp next step, get error from resendSignUpCode, stays in SignIn screen`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success( + coEvery { authProvider.signIn(any(), any(), any()) } returns Success( mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_UP) ) - coEvery { authProvider.resendSignUpCode(any()) } returns AmplifyResult.Error(mockAuthException()) + coEvery { authProvider.resendSignUpCode(any()) } returns Error(mockAuthException()) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignIn @@ -287,12 +288,12 @@ class AuthenticatorViewModelTest { @Test fun `Confirm SignUp next step shows the SignUpConfirm screen`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success( + coEvery { authProvider.signIn(any(), any(), any()) } returns Success( mockSignInResult(signInStep = AuthSignInStep.CONFIRM_SIGN_UP) ) coEvery { authProvider.resendSignUpCode(any()) } returns Success(mockk()) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignUpConfirm @@ -301,14 +302,14 @@ class AuthenticatorViewModelTest { @Test fun `MFA selection next step shows error if allowedMFATypes is null`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success( + coEvery { authProvider.signIn(any(), any(), any()) } returns Success( mockSignInResult( signInStep = AuthSignInStep.CONTINUE_SIGN_IN_WITH_MFA_SELECTION, allowedMFATypes = null ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.Error @@ -317,14 +318,14 @@ class AuthenticatorViewModelTest { @Test fun `MFA selection next step shows error if allowedMFATypes is empty`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success( + coEvery { authProvider.signIn(any(), any(), any()) } returns Success( mockSignInResult( signInStep = AuthSignInStep.CONTINUE_SIGN_IN_WITH_MFA_SELECTION, allowedMFATypes = emptySet() ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.Error @@ -333,14 +334,14 @@ class AuthenticatorViewModelTest { @Test fun `MFA Selection next step shows the SignInContinueWithMfaSelection screen`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success( + coEvery { authProvider.signIn(any(), any(), any()) } returns Success( mockSignInResult( signInStep = AuthSignInStep.CONTINUE_SIGN_IN_WITH_MFA_SELECTION, allowedMFATypes = setOf(MFAType.TOTP, MFAType.SMS) ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignInContinueWithMfaSelection @@ -349,7 +350,7 @@ class AuthenticatorViewModelTest { @Test fun `user attribute verification screen is shown if user has no verified attributes`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success(mockSignInResult()) + coEvery { authProvider.signIn(any(), any(), any()) } returns Success(mockSignInResult()) coEvery { authProvider.getConfiguration() } returns mockAmplifyAuthConfiguration( verificationMechanisms = setOf(VerificationMechanism.Email) ) @@ -357,7 +358,7 @@ class AuthenticatorViewModelTest { mockUserAttributes(email() to "email", emailVerified() to "false") ) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.VerifyUser @@ -366,7 +367,7 @@ class AuthenticatorViewModelTest { @Test fun `user attribute verification screen is not shown if user has verified attributes`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success(mockSignInResult()) + coEvery { authProvider.signIn(any(), any(), any()) } returns Success(mockSignInResult()) coEvery { authProvider.getConfiguration() } returns mockAmplifyAuthConfiguration( verificationMechanisms = setOf(VerificationMechanism.Email) ) @@ -374,7 +375,7 @@ class AuthenticatorViewModelTest { mockUserAttributes(email() to "email", emailVerified() to "true") // email is already verified ) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignedIn @@ -383,7 +384,7 @@ class AuthenticatorViewModelTest { @Test fun `user attribute verification screen is not shown if there are no verification mechanisms`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success(mockSignInResult()) + coEvery { authProvider.signIn(any(), any(), any()) } returns Success(mockSignInResult()) coEvery { authProvider.getConfiguration() } returns mockAmplifyAuthConfiguration( verificationMechanisms = emptySet() // no verification mechanisms ) @@ -391,7 +392,7 @@ class AuthenticatorViewModelTest { mockUserAttributes(email() to "email", emailVerified() to "false") ) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignedIn @@ -400,14 +401,14 @@ class AuthenticatorViewModelTest { @Test fun `user attribute verification screen is not shown if cannot fetch user attributes`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success(mockSignInResult()) + coEvery { authProvider.signIn(any(), any(), any()) } returns Success(mockSignInResult()) coEvery { authProvider.getConfiguration() } returns mockAmplifyAuthConfiguration( verificationMechanisms = setOf(VerificationMechanism.Email) ) // cannot fetch user attributes - coEvery { authProvider.fetchUserAttributes() } returns AmplifyResult.Error(mockk(relaxed = true)) + coEvery { authProvider.fetchUserAttributes() } returns Error(mockk(relaxed = true)) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignedIn @@ -416,13 +417,13 @@ class AuthenticatorViewModelTest { @Test fun `user attribute verification screen is not shown if user does not have the required attributes`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success(mockSignInResult()) + coEvery { authProvider.signIn(any(), any(), any()) } returns Success(mockSignInResult()) coEvery { authProvider.getConfiguration() } returns mockAmplifyAuthConfiguration( verificationMechanisms = setOf(VerificationMechanism.Email) ) coEvery { authProvider.fetchUserAttributes() } returns Success(mockUserAttributes()) // no email attribute - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.SignedIn @@ -431,7 +432,7 @@ class AuthenticatorViewModelTest { @Test fun `signing in with no internet results in network error message`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns + coEvery { authProvider.signIn(any(), any(), any()) } returns Error( mockk { every { cause } returns @@ -441,7 +442,7 @@ class AuthenticatorViewModelTest { } ) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.shouldEmitMessage { viewModel.signIn("username", "password") @@ -455,7 +456,7 @@ class AuthenticatorViewModelTest { fun `moves to SignedInState when receiving SignedIn event`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() runCurrent() viewModel.currentStep shouldBe AuthenticatorStep.SignIn @@ -466,12 +467,12 @@ class AuthenticatorViewModelTest { @Test fun `does not advance to signed in if sign in is in progress when SignedIn event is received`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } coAnswers { + coEvery { authProvider.signIn(any(), any(), any()) } coAnswers { delay(1000) // delay so that the sign in does not complete until the clock is advanced Success(mockSignInResult()) } - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() runCurrent() viewModel.currentStep shouldBe AuthenticatorStep.SignIn @@ -489,7 +490,7 @@ class AuthenticatorViewModelTest { @Test fun `does not advance to SignedIn when SignedIn event is received in a post-sign-in state`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success(mockSignInResult()) + coEvery { authProvider.signIn(any(), any(), any()) } returns Success(mockSignInResult()) coEvery { authProvider.getConfiguration() } returns mockAmplifyAuthConfiguration( verificationMechanisms = setOf(VerificationMechanism.Email) ) @@ -497,7 +498,7 @@ class AuthenticatorViewModelTest { mockUserAttributes(email() to "email", emailVerified() to "false") ) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.VerifyUser @@ -514,7 +515,7 @@ class AuthenticatorViewModelTest { coEvery { authProvider.signUp("username", "password", any()) } returns Success(result) coEvery { authProvider.autoSignIn() } returns Success(mockSignInResult()) - viewModel.start(mockAuthenticatorConfiguration()) + viewModel.start() viewModel.signUp("username", "password", emptyList()) advanceUntilIdle() @@ -527,13 +528,13 @@ class AuthenticatorViewModelTest { @Test fun `Sign in with temporary password requires password reset`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) - coEvery { authProvider.signIn(any(), any()) } returns Success( + coEvery { authProvider.signIn(any(), any(), any()) } returns Success( mockSignInResult( signInStep = AuthSignInStep.RESET_PASSWORD ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.SignIn)) + viewModel.start() viewModel.signIn("username", "password") viewModel.currentStep shouldBe AuthenticatorStep.PasswordReset @@ -548,7 +549,7 @@ class AuthenticatorViewModelTest { AuthNextResetPasswordStep(AuthResetPasswordStep.DONE, emptyMap(), null) ) ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.PasswordReset)) + viewModel.start(AuthenticatorStep.PasswordReset) viewModel.resetPassword("username") viewModel.currentStep shouldBe AuthenticatorStep.SignIn @@ -564,14 +565,14 @@ class AuthenticatorViewModelTest { } } ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.PasswordReset)) + viewModel.start(AuthenticatorStep.PasswordReset) viewModel.resetPassword("username") viewModel.currentStep shouldBe AuthenticatorStep.PasswordReset } @Test - fun `Password reset confirmation succeeds, sign in succeeds, state should be signed in`() = runTest { + fun `Password reset confirmation succeeds, state should be sign in`() = runTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) coEvery { authProvider.resetPassword(any()) } returns Success( AuthResetPasswordResult( @@ -581,13 +582,12 @@ class AuthenticatorViewModelTest { ) coEvery { authProvider.confirmResetPassword(any(), any(), any()) } returns Success(Unit) - coEvery { authProvider.signIn(any(), any()) } returns Success(mockSignInResult()) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.PasswordReset)) + viewModel.start(AuthenticatorStep.PasswordReset) viewModel.resetPassword("username") viewModel.confirmResetPassword("username", "password", "code") - viewModel.currentStep shouldBe AuthenticatorStep.SignedIn + viewModel.currentStep shouldBe AuthenticatorStep.SignIn } @Test @@ -608,7 +608,7 @@ class AuthenticatorViewModelTest { } ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.PasswordReset)) + viewModel.start(AuthenticatorStep.PasswordReset) viewModel.resetPassword("username") viewModel.confirmResetPassword("username", "password", "code") @@ -626,7 +626,7 @@ class AuthenticatorViewModelTest { ) coEvery { authProvider.confirmResetPassword(any(), any(), any()) } returns Success(Unit) - coEvery { authProvider.signIn(any(), any()) } returns Error( + coEvery { authProvider.signIn(any(), any(), any()) } returns Error( mockk { every { cause } returns mockk { every { cause } returns mockk() @@ -634,7 +634,7 @@ class AuthenticatorViewModelTest { } ) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.PasswordReset)) + viewModel.start(AuthenticatorStep.PasswordReset) viewModel.resetPassword("username") viewModel.confirmResetPassword("username", "password", "code") @@ -646,15 +646,21 @@ class AuthenticatorViewModelTest { coEvery { authProvider.fetchAuthSession() } returns Success(mockAuthSession(isSignedIn = false)) coEvery { authProvider.resetPassword(any()) } returns Error(LimitExceededException(null)) - viewModel.start(mockAuthenticatorConfiguration(initialStep = AuthenticatorStep.PasswordReset)) + viewModel.start(AuthenticatorStep.PasswordReset) viewModel.shouldEmitMessage { viewModel.resetPassword("username") } } + //endregion //region helpers private val AuthenticatorViewModel.currentStep: AuthenticatorStep get() = stepState.value.step + + private fun AuthenticatorViewModel.start(step: AuthenticatorInitialStep = AuthenticatorStep.SignIn) = start( + configuration = mockAuthenticatorConfiguration(initialStep = step), + activity = null + ) //endregion } diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/MockAuthenticatorData.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/MockAuthenticatorData.kt index 071b06be..c27cec1e 100644 --- a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/MockAuthenticatorData.kt +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/MockAuthenticatorData.kt @@ -35,6 +35,7 @@ import com.amplifyframework.ui.authenticator.auth.AmplifyAuthConfiguration import com.amplifyframework.ui.authenticator.auth.PasswordCriteria import com.amplifyframework.ui.authenticator.auth.SignInMethod import com.amplifyframework.ui.authenticator.auth.VerificationMechanism +import com.amplifyframework.ui.authenticator.data.AuthenticationFlow import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep import com.amplifyframework.ui.authenticator.forms.SignUpFormBuilder @@ -45,11 +46,13 @@ import io.mockk.mockk internal fun mockAuthenticatorConfiguration( initialStep: AuthenticatorInitialStep = AuthenticatorStep.SignIn, signUpForm: SignUpFormBuilder.() -> Unit = {}, - totpOptions: TotpOptions? = null + totpOptions: TotpOptions? = null, + authenticationFlow: AuthenticationFlow = AuthenticationFlow.Password ) = AuthenticatorConfiguration( initialStep = initialStep, signUpForm = signUpForm, - totpOptions = totpOptions + totpOptions = totpOptions, + authenticationFlow = authenticationFlow ) internal fun mockAmplifyAuthConfiguration( @@ -116,10 +119,7 @@ internal fun mockNextSignInStep( availableFactors ) -internal fun mockSignUpResult( - nextStep: AuthNextSignUpStep, - userId: String = "userId" -) = AuthSignUpResult( +internal fun mockSignUpResult(nextStep: AuthNextSignUpStep, userId: String = "userId") = AuthSignUpResult( nextStep.signUpStep != AuthSignUpStep.CONFIRM_SIGN_UP_STEP, nextStep, userId diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/testUtil/MockStates.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/testUtil/MockStates.kt index b37831f5..60f0f5e6 100644 --- a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/testUtil/MockStates.kt +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/testUtil/MockStates.kt @@ -18,31 +18,46 @@ package com.amplifyframework.ui.authenticator.testUtil import com.amplifyframework.auth.AuthCodeDeliveryDetails import com.amplifyframework.auth.AuthUserAttributeKey import com.amplifyframework.auth.MFAType +import com.amplifyframework.auth.result.AuthWebAuthnCredential +import com.amplifyframework.ui.authenticator.PasskeyCreatedState +import com.amplifyframework.ui.authenticator.PromptToCreatePasskeyState import com.amplifyframework.ui.authenticator.auth.PasswordCriteria import com.amplifyframework.ui.authenticator.auth.SignInMethod +import com.amplifyframework.ui.authenticator.data.AuthFactor import com.amplifyframework.ui.authenticator.enums.AuthenticatorInitialStep import com.amplifyframework.ui.authenticator.forms.FormData import com.amplifyframework.ui.authenticator.mockAuthCodeDeliveryDetails +import com.amplifyframework.ui.authenticator.states.PasskeyCreatedStateImpl import com.amplifyframework.ui.authenticator.states.PasswordResetConfirmStateImpl import com.amplifyframework.ui.authenticator.states.PasswordResetStateImpl +import com.amplifyframework.ui.authenticator.states.PromptToCreatePasskeyStateImpl import com.amplifyframework.ui.authenticator.states.SignInConfirmMfaStateImpl +import com.amplifyframework.ui.authenticator.states.SignInConfirmPasswordStateImpl import com.amplifyframework.ui.authenticator.states.SignInConfirmTotpCodeStateImpl import com.amplifyframework.ui.authenticator.states.SignInContinueWithEmailSetupStateImpl import com.amplifyframework.ui.authenticator.states.SignInContinueWithMfaSelectionStateImpl import com.amplifyframework.ui.authenticator.states.SignInContinueWithMfaSetupSelectionStateImpl import com.amplifyframework.ui.authenticator.states.SignInContinueWithTotpSetupStateImpl +import com.amplifyframework.ui.authenticator.states.SignInSelectAuthFactorStateImpl import com.amplifyframework.ui.authenticator.states.SignInStateImpl import com.amplifyframework.ui.authenticator.states.SignUpStateImpl -internal fun mockSignInState() = SignInStateImpl( - signInMethod = SignInMethod.Username, - onSubmit = { _, _ -> }, - onMoveTo = { } -) +internal fun mockSignInState(signInMethod: SignInMethod = SignInMethod.Username, showPasswordField: Boolean = true) = + SignInStateImpl( + signInMethod = signInMethod, + showPasswordField = showPasswordField, + onSubmit = { _, _ -> }, + onMoveTo = { } + ) -internal fun mockSignUpState() = SignUpStateImpl( - signInMethod = SignInMethod.Username, - signUpAttributes = listOf(AuthUserAttributeKey.email()), +internal fun mockSignUpState( + signInMethod: SignInMethod = SignInMethod.Username, + signUpAttributes: List = listOf(AuthUserAttributeKey.email()), + requirePasswordField: Boolean = true +) = SignUpStateImpl( + signInMethod = signInMethod, + signUpAttributes = signUpAttributes, + requirePasswordField = requirePasswordField, passwordCriteria = PasswordCriteria(8, false, false, false, false), signUpForm = FormData(emptyList()), onSubmit = { _, _, _ -> }, @@ -105,13 +120,12 @@ internal fun mockSignInContinueWithTotpSetupState( onMoveTo = onMoveTo ) -internal fun mockSignInConfirmMfaState( - deliveryDetails: AuthCodeDeliveryDetails = mockAuthCodeDeliveryDetails() -) = SignInConfirmMfaStateImpl( - deliveryDetails = deliveryDetails, - onSubmit = { }, - onMoveTo = { } -) +internal fun mockSignInConfirmMfaState(deliveryDetails: AuthCodeDeliveryDetails = mockAuthCodeDeliveryDetails()) = + SignInConfirmMfaStateImpl( + deliveryDetails = deliveryDetails, + onSubmit = { }, + onMoveTo = { } + ) internal fun mockSignInContinueWithMfaSetupSelectionState( allowedMfaTypes: Set = setOf(MFAType.TOTP, MFAType.SMS, MFAType.EMAIL) @@ -120,3 +134,48 @@ internal fun mockSignInContinueWithMfaSetupSelectionState( onSubmit = { }, onMoveTo = { } ) + +internal fun mockPasskeyCreatedState( + passkeys: List = emptyList(), + onDone: suspend () -> Unit = {}, + action: PasskeyCreatedState.Action? = null +) = PasskeyCreatedStateImpl( + passkeys = passkeys, + onDone = onDone +).apply { this.action = action } + +internal fun mockPasskeyCreationPromptState( + onSubmit: suspend () -> Unit = {}, + onSkip: suspend () -> Unit = {}, + action: PromptToCreatePasskeyState.Action? = null +) = PromptToCreatePasskeyStateImpl( + onSubmit = onSubmit, + onSkip = onSkip +).apply { this.action = action } + +internal fun mockSignInConfirmPasswordState( + username: String = "testuser", + signInMethod: SignInMethod = SignInMethod.Username, + onSubmit: suspend (String) -> Unit = { }, + onMoveTo: (AuthenticatorInitialStep) -> Unit = { } +) = SignInConfirmPasswordStateImpl( + username = username, + signInMethod = signInMethod, + onSubmit = onSubmit, + onMoveTo = onMoveTo +) + +internal fun mockSignInSelectAuthFactorState( + username: String = "testuser", + signInMethod: SignInMethod = SignInMethod.Username, + availableAuthFactors: Set = + setOf(AuthFactor.Password(), AuthFactor.SmsOtp, AuthFactor.EmailOtp, AuthFactor.WebAuthn), + onSelect: suspend (AuthFactor) -> Unit = { }, + onMoveTo: (AuthenticatorInitialStep) -> Unit = { } +) = SignInSelectAuthFactorStateImpl( + username = username, + signInMethod = signInMethod, + availableAuthFactors = availableAuthFactors, + onSubmit = onSelect, + onMoveTo = onMoveTo +) diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreatedTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreatedTest.kt new file mode 100644 index 00000000..5b3f7558 --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreatedTest.kt @@ -0,0 +1,97 @@ +package com.amplifyframework.ui.authenticator.ui + +import com.amplifyframework.auth.result.AuthWebAuthnCredential +import com.amplifyframework.ui.authenticator.PasskeyCreatedState.Action +import com.amplifyframework.ui.authenticator.testUtil.AuthenticatorUiTest +import com.amplifyframework.ui.authenticator.testUtil.mockPasskeyCreatedState +import com.amplifyframework.ui.authenticator.ui.robots.passkeyCreated +import com.amplifyframework.ui.testing.ScreenshotTest +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import org.junit.Test + +class PasskeyCreatedTest : AuthenticatorUiTest() { + + @Test + fun `title is Passkey created successfully`() { + setContent { + PasskeyCreated(state = mockPasskeyCreatedState()) + } + passkeyCreated { + hasTitle("Passkey created successfully!") + } + } + + @Test + fun `button is Continue`() { + setContent { + PasskeyCreated(state = mockPasskeyCreatedState()) + } + passkeyCreated { + hasContinueButton("Continue") + } + } + + @Test + fun `clicking continue calls done`() { + val onDone = mockk Unit>(relaxed = true) + setContent { + PasskeyCreated(state = mockPasskeyCreatedState(onDone = onDone)) + } + passkeyCreated { + clickContinueButton() + } + coVerify { onDone() } + } + + @Test + fun `displays existing passkeys when present`() { + val passkey = mockk { + every { friendlyName } returns "Test Passkey" + } + setContent { + PasskeyCreated(state = mockPasskeyCreatedState(passkeys = listOf(passkey))) + } + passkeyCreated { + hasPasskeyText("Existing Passkeys") + hasPasskeyText("Test Passkey") + } + } + + @Test + @ScreenshotTest + fun `with one passkey`() { + val passkey = mockk { + every { friendlyName } returns "Test Passkey" + } + setContent { + PasskeyCreated(state = mockPasskeyCreatedState(passkeys = listOf(passkey))) + } + } + + @Test + @ScreenshotTest + fun `with multiple passkeys`() { + val passkeys = listOf( + mockk { every { friendlyName } returns "Test Passkey 1" }, + mockk { every { friendlyName } returns "Test Passkey 2" } + ) + setContent { + PasskeyCreated(state = mockPasskeyCreatedState(passkeys = passkeys)) + } + } + + @Test + @ScreenshotTest + fun `done selected`() { + val passkey = mockk { + every { friendlyName } returns "Test Passkey" + } + setContent { + PasskeyCreated( + state = mockPasskeyCreatedState(passkeys = listOf(passkey), action = Action.ContinueSignIn()) + ) + } + } +} diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPromptTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPromptTest.kt new file mode 100644 index 00000000..bdf2f0ba --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/PasskeyCreationPromptTest.kt @@ -0,0 +1,95 @@ +package com.amplifyframework.ui.authenticator.ui + +import com.amplifyframework.ui.authenticator.PromptToCreatePasskeyState.Action +import com.amplifyframework.ui.authenticator.testUtil.AuthenticatorUiTest +import com.amplifyframework.ui.authenticator.testUtil.mockPasskeyCreationPromptState +import com.amplifyframework.ui.authenticator.ui.robots.passkeyCreationPrompt +import com.amplifyframework.ui.testing.ScreenshotTest +import io.mockk.coVerify +import io.mockk.mockk +import org.junit.Test + +class PasskeyCreationPromptTest : AuthenticatorUiTest() { + + @Test + fun `title is Sign in faster with Passkey`() { + setContent { + PromptToCreatePasskey(state = mockPasskeyCreationPromptState()) + } + passkeyCreationPrompt { + hasTitle("Sign in faster with Passkey") + } + } + + @Test + fun `has create passkey button`() { + setContent { + PromptToCreatePasskey(state = mockPasskeyCreationPromptState()) + } + passkeyCreationPrompt { + hasCreatePasskeyButton("Create a Passkey") + } + } + + @Test + fun `has skip passkey button`() { + setContent { + PromptToCreatePasskey(state = mockPasskeyCreationPromptState()) + } + passkeyCreationPrompt { + hasSkipPasskeyButton("Continue without a Passkey") + } + } + + @Test + fun `clicking create passkey calls createPasskey`() { + val onSubmit = mockk Unit>(relaxed = true) + setContent { + PromptToCreatePasskey(state = mockPasskeyCreationPromptState(onSubmit = onSubmit)) + } + passkeyCreationPrompt { + clickCreatePasskeyButton() + } + coVerify { onSubmit() } + } + + @Test + fun `clicking skip calls skip`() { + val onSkip = mockk Unit>(relaxed = true) + setContent { + PromptToCreatePasskey(state = mockPasskeyCreationPromptState(onSkip = onSkip)) + } + passkeyCreationPrompt { + clickSkipPasskeyButton() + } + coVerify { onSkip() } + } + + @Test + @ScreenshotTest + fun `default state`() { + setContent { + PromptToCreatePasskey(state = mockPasskeyCreationPromptState()) + } + } + + @Test + @ScreenshotTest + fun `creating passkey`() { + setContent { + PromptToCreatePasskey( + state = mockPasskeyCreationPromptState(action = Action.CreatePasskey()) + ) + } + } + + @Test + @ScreenshotTest + fun `skipping passkey creation`() { + setContent { + PromptToCreatePasskey( + state = mockPasskeyCreationPromptState(action = Action.Skip()) + ) + } + } +} diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignInConfirmPasswordTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignInConfirmPasswordTest.kt new file mode 100644 index 00000000..fe1fb7f4 --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignInConfirmPasswordTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.amplifyframework.ui.authenticator.ui + +import com.amplifyframework.ui.authenticator.auth.SignInMethod +import com.amplifyframework.ui.authenticator.testUtil.AuthenticatorUiTest +import com.amplifyframework.ui.authenticator.testUtil.mockSignInConfirmPasswordState +import com.amplifyframework.ui.authenticator.ui.robots.signInConfirmPassword +import com.amplifyframework.ui.testing.ScreenshotTest +import org.junit.Test + +class SignInConfirmPasswordTest : AuthenticatorUiTest() { + + @Test + fun `title is Enter your password`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState()) + } + signInConfirmPassword { + hasTitle("Enter your password") + } + } + + @Test + fun `button is Sign In`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState()) + } + signInConfirmPassword { + hasSubmitButton("Sign In") + } + } + + @Test + fun `username field is populated with username`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState(username = "testuser")) + } + signInConfirmPassword { + hasUsername("testuser") + } + } + + @Test + fun `has back to sign in footer`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState()) + } + signInConfirmPassword { + hasBackToSignInButton() + } + } + + @Test + @ScreenshotTest + fun `default state`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState()) + } + } + + @Test + @ScreenshotTest + fun `ready to submit`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState()) + } + signInConfirmPassword { + setPassword("password123") + } + } + + @Test + @ScreenshotTest + fun `ready to submit with email`() { + setContent { + SignInConfirmPassword( + state = mockSignInConfirmPasswordState(username = "test@test.com", signInMethod = SignInMethod.Email) + ) + } + signInConfirmPassword { + setPassword("password123") + } + } + + @Test + @ScreenshotTest + fun `ready to submit with phonenumber`() { + setContent { + SignInConfirmPassword( + state = mockSignInConfirmPasswordState( + username = "+19021231234", + signInMethod = SignInMethod.PhoneNumber + ) + ) + } + signInConfirmPassword { + setPassword("password123") + } + } + + @Test + @ScreenshotTest + fun `password visible`() { + setContent { + SignInConfirmPassword(state = mockSignInConfirmPasswordState()) + } + signInConfirmPassword { + setPassword("password123") + clickShowPassword() + } + } +} diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactorTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactorTest.kt new file mode 100644 index 00000000..618b35a1 --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignInSelectAuthFactorTest.kt @@ -0,0 +1,206 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.amplifyframework.ui.authenticator.ui + +import com.amplifyframework.ui.authenticator.auth.SignInMethod +import com.amplifyframework.ui.authenticator.data.AuthFactor +import com.amplifyframework.ui.authenticator.testUtil.AuthenticatorUiTest +import com.amplifyframework.ui.authenticator.testUtil.mockSignInSelectAuthFactorState +import com.amplifyframework.ui.authenticator.ui.robots.signInSelectAuthFactor +import com.amplifyframework.ui.testing.ScreenshotTest +import io.kotest.matchers.types.shouldBeInstanceOf +import io.mockk.mockk +import io.mockk.verify +import org.junit.Test + +class SignInSelectAuthFactorTest : AuthenticatorUiTest() { + + @Test + fun `title is Choose how to sign in`() { + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState()) + } + signInSelectAuthFactor { + hasTitle("Choose how to sign in") + } + } + + @Test + fun `username field is populated`() { + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState(username = "testuser")) + } + signInSelectAuthFactor { + hasUsername("testuser") + } + } + + @Test + fun `shows password button when available`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState(availableAuthFactors = setOf(AuthFactor.Password())) + ) + } + signInSelectAuthFactor { + hasPasswordButton() + } + } + + @Test + fun `shows passkey button when available`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState( + availableAuthFactors = setOf(AuthFactor.WebAuthn) + ) + ) + } + signInSelectAuthFactor { + hasPasskeyButton() + } + } + + @Test + fun `shows email button when available`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState( + availableAuthFactors = setOf(AuthFactor.EmailOtp) + ) + ) + } + signInSelectAuthFactor { + hasEmailButton() + } + } + + @Test + fun `shows sms button when available`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState( + availableAuthFactors = setOf(AuthFactor.SmsOtp) + ) + ) + } + signInSelectAuthFactor { + hasSmsButton() + } + } + + @Test + fun `selects password`() { + val onSelect = mockk<(AuthFactor) -> Unit>(relaxed = true) + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState(onSelect = onSelect)) + } + signInSelectAuthFactor { + clickOnAuthFactor(AuthFactor.Password()) + } + verify { + onSelect(withArg { it.shouldBeInstanceOf() }) + } + } + + @Test + fun `selects sms otp`() { + val onSelect = mockk<(AuthFactor) -> Unit>(relaxed = true) + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState(onSelect = onSelect)) + } + signInSelectAuthFactor { + clickOnAuthFactor(AuthFactor.SmsOtp) + } + verify { + onSelect(AuthFactor.SmsOtp) + } + } + + @Test + fun `selects email otp`() { + val onSelect = mockk<(AuthFactor) -> Unit>(relaxed = true) + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState(onSelect = onSelect)) + } + signInSelectAuthFactor { + clickOnAuthFactor(AuthFactor.EmailOtp) + } + verify { + onSelect(AuthFactor.EmailOtp) + } + } + + @Test + fun `selects passkey`() { + val onSelect = mockk<(AuthFactor) -> Unit>(relaxed = true) + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState(onSelect = onSelect)) + } + signInSelectAuthFactor { + clickOnAuthFactor(AuthFactor.WebAuthn) + } + verify { + onSelect(AuthFactor.WebAuthn) + } + } + + @Test + @ScreenshotTest + fun `default state with all factors`() { + setContent { + SignInSelectAuthFactor(state = mockSignInSelectAuthFactorState()) + } + } + + @Test + @ScreenshotTest + fun `default state with all factors with phone number`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState( + username = "+19021234567", + signInMethod = SignInMethod.PhoneNumber + ) + ) + } + } + + @Test + @ScreenshotTest + fun `default state with all factors with email`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState( + username = "test@test.com", + signInMethod = SignInMethod.Email + ) + ) + } + } + + @Test + @ScreenshotTest + fun `no password`() { + setContent { + SignInSelectAuthFactor( + state = mockSignInSelectAuthFactorState( + availableAuthFactors = setOf(AuthFactor.EmailOtp, AuthFactor.SmsOtp) + ) + ) + } + } +} diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignUpTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignUpTest.kt index a1256705..e0dc01ad 100644 --- a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignUpTest.kt +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/SignUpTest.kt @@ -17,6 +17,8 @@ package com.amplifyframework.ui.authenticator.ui import androidx.compose.ui.autofill.AutofillManager import androidx.compose.ui.autofill.ContentType +import com.amplifyframework.auth.AuthUserAttributeKey +import com.amplifyframework.ui.authenticator.auth.SignInMethod import com.amplifyframework.ui.authenticator.enums.AuthenticatorStep import com.amplifyframework.ui.authenticator.forms.FieldError import com.amplifyframework.ui.authenticator.forms.FieldKey @@ -193,4 +195,26 @@ class SignUpTest : AuthenticatorUiTest() { } state.form.setFieldError(FieldKey.Email, FieldError.InvalidFormat) } + + @Test + @ScreenshotTest + fun `passwordless with email`() { + val state = mockSignUpState(signInMethod = SignInMethod.Email, requirePasswordField = false) + setContent { + SignUp(state = state) + } + } + + @Test + @ScreenshotTest + fun `passwordless with username`() { + val state = mockSignUpState( + signInMethod = SignInMethod.Username, + signUpAttributes = listOf(AuthUserAttributeKey.email()), + requirePasswordField = false + ) + setContent { + SignUp(state = state) + } + } } diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/PasskeyCreatedRobot.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/PasskeyCreatedRobot.kt new file mode 100644 index 00000000..7c3985de --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/PasskeyCreatedRobot.kt @@ -0,0 +1,14 @@ +package com.amplifyframework.ui.authenticator.ui.robots + +import androidx.compose.ui.test.junit4.ComposeTestRule +import com.amplifyframework.ui.authenticator.ui.TestTags +import com.amplifyframework.ui.testing.ComposeTest + +fun ComposeTest.passkeyCreated(func: PasskeyCreatedRobot.() -> Unit) = PasskeyCreatedRobot(composeTestRule).func() + +class PasskeyCreatedRobot(rule: ComposeTestRule) : ScreenLevelRobot(rule) { + fun hasContinueButton(expected: String) = assertExists(TestTags.ContinueButton, expected) + fun hasPasskeyText(text: String) = assertExists(text) + + fun clickContinueButton() = clickOnTag(TestTags.ContinueButton) +} diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/PasskeyCreationPromptRobot.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/PasskeyCreationPromptRobot.kt new file mode 100644 index 00000000..8562606c --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/PasskeyCreationPromptRobot.kt @@ -0,0 +1,16 @@ +package com.amplifyframework.ui.authenticator.ui.robots + +import androidx.compose.ui.test.junit4.ComposeTestRule +import com.amplifyframework.ui.authenticator.ui.TestTags +import com.amplifyframework.ui.testing.ComposeTest + +fun ComposeTest.passkeyCreationPrompt(func: PasskeyCreationPromptRobot.() -> Unit) = + PasskeyCreationPromptRobot(composeTestRule).func() + +class PasskeyCreationPromptRobot(rule: ComposeTestRule) : ScreenLevelRobot(rule) { + fun hasCreatePasskeyButton(expected: String) = assertExists(TestTags.CreatePasskeyButton, expected) + fun hasSkipPasskeyButton(expected: String) = assertExists(TestTags.SkipPasskeyButton, expected) + fun clickCreatePasskeyButton() = clickOnTag(TestTags.CreatePasskeyButton) + fun clickSkipPasskeyButton() = clickOnTag(TestTags.SkipPasskeyButton) + fun hasPromptText(text: String) = assertExists(text) +} diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/SignInConfirmPasswordRobot.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/SignInConfirmPasswordRobot.kt new file mode 100644 index 00000000..fec97e7f --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/SignInConfirmPasswordRobot.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.amplifyframework.ui.authenticator.ui.robots + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import com.amplifyframework.ui.authenticator.forms.FieldKey +import com.amplifyframework.ui.authenticator.ui.TestTags +import com.amplifyframework.ui.authenticator.ui.testTag +import com.amplifyframework.ui.testing.ComposeTest + +fun ComposeTest.signInConfirmPassword(func: SignInConfirmPasswordRobot.() -> Unit) = + SignInConfirmPasswordRobot(composeTestRule).func() + +class SignInConfirmPasswordRobot(rule: ComposeTestRule) : ScreenLevelRobot(rule) { + fun hasSubmitButton(expected: String) = assertExists(TestTags.SignInButton, expected) + fun hasUsername(expected: String) = composeTestRule.onNode( + hasTestTag(FieldKey.Username.testTag) and hasText(expected) + ).assertExists() + fun hasBackToSignInButton() = composeTestRule.onNode(hasTestTag(TestTags.BackToSignInButton)).assertExists() + + fun setPassword(value: String) = setFieldContent(FieldKey.Password, value) + fun clickShowPassword() = clickOnShowIcon(FieldKey.Password) + fun clickSubmitButton() = clickOnTag(TestTags.SignInButton) + fun clickBackToSignIn() = clickOnTag(TestTags.BackToSignInButton) +} diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/SignInSelectAuthFactorRobot.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/SignInSelectAuthFactorRobot.kt new file mode 100644 index 00000000..23341a3e --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/ui/robots/SignInSelectAuthFactorRobot.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2023 Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package com.amplifyframework.ui.authenticator.ui.robots + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.ComposeTestRule +import com.amplifyframework.ui.authenticator.data.AuthFactor +import com.amplifyframework.ui.authenticator.forms.FieldKey +import com.amplifyframework.ui.authenticator.ui.TestTags +import com.amplifyframework.ui.authenticator.ui.testTag +import com.amplifyframework.ui.testing.ComposeTest + +fun ComposeTest.signInSelectAuthFactor(func: SignInSelectAuthFactorRobot.() -> Unit) = + SignInSelectAuthFactorRobot(composeTestRule).func() + +class SignInSelectAuthFactorRobot(rule: ComposeTestRule) : ScreenLevelRobot(rule) { + fun hasUsername(expected: String) = composeTestRule.onNode( + hasTestTag(FieldKey.Username.testTag) and hasText(expected) + ).assertExists() + + fun hasPasswordButton() = composeTestRule.onNode(hasTestTag(TestTags.AuthFactorPassword)).assertExists() + fun hasPasskeyButton() = composeTestRule.onNode(hasTestTag(TestTags.AuthFactorPasskey)).assertExists() + fun hasEmailButton() = composeTestRule.onNode(hasTestTag(TestTags.AuthFactorEmail)).assertExists() + fun hasSmsButton() = composeTestRule.onNode(hasTestTag(TestTags.AuthFactorSms)).assertExists() + + fun clickOnAuthFactor(factor: AuthFactor) = clickOnTag(factor.testTag) +} diff --git a/authenticator/src/test/java/com/amplifyframework/ui/authenticator/util/PasskeyPromptCheckTest.kt b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/util/PasskeyPromptCheckTest.kt new file mode 100644 index 00000000..8b960a86 --- /dev/null +++ b/authenticator/src/test/java/com/amplifyframework/ui/authenticator/util/PasskeyPromptCheckTest.kt @@ -0,0 +1,149 @@ +package com.amplifyframework.ui.authenticator.util + +import com.amplifyframework.auth.exceptions.UnknownException +import com.amplifyframework.ui.authenticator.AuthenticatorConfiguration +import com.amplifyframework.ui.authenticator.data.AuthenticationFlow +import com.amplifyframework.ui.authenticator.data.PasskeyPrompt +import com.amplifyframework.ui.authenticator.data.PasskeyPrompts +import com.amplifyframework.ui.authenticator.data.UserInfo +import com.amplifyframework.ui.authenticator.enums.SignInSource +import io.kotest.matchers.booleans.shouldBeFalse +import io.kotest.matchers.booleans.shouldBeTrue +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class PasskeyPromptCheckTest { + + private val authProvider = mockk { + coEvery { getPasskeys() } returns AmplifyResult.Success(emptyList()) + } + private val osBuild = mockk { + every { sdkInt } returns 30 + } + private val passkeyPromptCheck = PasskeyPromptCheck(authProvider, osBuild) + + @Test + fun `shouldPromptForPasskey returns false when auth flow is not UserChoice`() = runTest { + val userInfo = mockUserInfo() + val config = mockAuthenticatorConfiguration(authFlow = AuthenticationFlow.Password) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeFalse() + } + + @Test + fun `shouldPromptForPasskey returns false when passkey prompts are disabled for SignIn`() = runTest { + val userInfo = mockUserInfo(source = SignInSource.SignIn) + val config = mockAuthenticatorConfiguration( + authFlow = AuthenticationFlow.UserChoice( + passkeyPrompts = PasskeyPrompts(afterSignIn = PasskeyPrompt.Never) + ) + ) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeFalse() + } + + @Test + fun `shouldPromptForPasskey returns false when passkey prompts are disabled for AutoSignIn`() = runTest { + val userInfo = mockUserInfo(source = SignInSource.AutoSignIn) + val config = mockAuthenticatorConfiguration( + authFlow = AuthenticationFlow.UserChoice( + passkeyPrompts = PasskeyPrompts(afterSignUp = PasskeyPrompt.Never) + ) + ) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeFalse() + } + + @Test + fun `shouldPromptForPasskey returns false when user already has passkey`() = runTest { + val userInfo = mockUserInfo() + val config = mockAuthenticatorConfiguration() + + coEvery { authProvider.getPasskeys() } returns AmplifyResult.Success(listOf(mockk())) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeFalse() + } + + @Test + fun `shouldPromptForPasskey returns false when getPasskeys returns error`() = runTest { + val userInfo = mockUserInfo() + val config = mockAuthenticatorConfiguration() + + coEvery { authProvider.getPasskeys() } returns AmplifyResult.Error( + UnknownException("Network error") + ) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeFalse() + } + + @Test + fun `shouldPromptForPasskey returns false when os version is below 28`() = runTest { + val userInfo = mockUserInfo() + val config = mockAuthenticatorConfiguration() + + every { osBuild.sdkInt } returns 27 + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeFalse() + } + + @Test + fun `shouldPromptForPasskey returns true when os version is 28`() = runTest { + val userInfo = mockUserInfo() + val config = mockAuthenticatorConfiguration() + + every { osBuild.sdkInt } returns 28 + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeTrue() + } + + @Test + fun `shouldPromptForPasskey returns true for autoSignIn`() = runTest { + val userInfo = mockUserInfo(source = SignInSource.AutoSignIn) + val config = mockAuthenticatorConfiguration( + authFlow = AuthenticationFlow.UserChoice( + passkeyPrompts = PasskeyPrompts( + afterSignIn = PasskeyPrompt.Never, + afterSignUp = PasskeyPrompt.Always + ) + ) + ) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeTrue() + } + + @Test + fun `shouldPromptForPasskey returns true for normal signIn`() = runTest { + val userInfo = mockUserInfo(source = SignInSource.SignIn) + val config = mockAuthenticatorConfiguration( + authFlow = AuthenticationFlow.UserChoice( + passkeyPrompts = PasskeyPrompts( + afterSignIn = PasskeyPrompt.Always, + afterSignUp = PasskeyPrompt.Never + ) + ) + ) + + val result = passkeyPromptCheck.shouldPromptForPasskey(userInfo, config) + result.shouldBeTrue() + } + + private fun mockUserInfo(source: SignInSource = SignInSource.SignIn) = mockk { + every { signInSource } returns source + } + + private fun mockAuthenticatorConfiguration(authFlow: AuthenticationFlow = AuthenticationFlow.UserChoice()) = + mockk { + every { authenticationFlow } returns authFlow + } +} diff --git a/authenticator/src/test/screenshots/PasskeyCreatedTest_done-selected.png b/authenticator/src/test/screenshots/PasskeyCreatedTest_done-selected.png new file mode 100644 index 00000000..9382b6bf Binary files /dev/null and b/authenticator/src/test/screenshots/PasskeyCreatedTest_done-selected.png differ diff --git a/authenticator/src/test/screenshots/PasskeyCreatedTest_with-multiple-passkeys.png b/authenticator/src/test/screenshots/PasskeyCreatedTest_with-multiple-passkeys.png new file mode 100644 index 00000000..f13ce085 Binary files /dev/null and b/authenticator/src/test/screenshots/PasskeyCreatedTest_with-multiple-passkeys.png differ diff --git a/authenticator/src/test/screenshots/PasskeyCreatedTest_with-one-passkey.png b/authenticator/src/test/screenshots/PasskeyCreatedTest_with-one-passkey.png new file mode 100644 index 00000000..9f04397e Binary files /dev/null and b/authenticator/src/test/screenshots/PasskeyCreatedTest_with-one-passkey.png differ diff --git a/authenticator/src/test/screenshots/PasskeyCreationPromptTest_creating-passkey.png b/authenticator/src/test/screenshots/PasskeyCreationPromptTest_creating-passkey.png new file mode 100644 index 00000000..6efcecf4 Binary files /dev/null and b/authenticator/src/test/screenshots/PasskeyCreationPromptTest_creating-passkey.png differ diff --git a/authenticator/src/test/screenshots/PasskeyCreationPromptTest_default-state.png b/authenticator/src/test/screenshots/PasskeyCreationPromptTest_default-state.png new file mode 100644 index 00000000..8ab6bbb6 Binary files /dev/null and b/authenticator/src/test/screenshots/PasskeyCreationPromptTest_default-state.png differ diff --git a/authenticator/src/test/screenshots/PasskeyCreationPromptTest_skipping-passkey-creation.png b/authenticator/src/test/screenshots/PasskeyCreationPromptTest_skipping-passkey-creation.png new file mode 100644 index 00000000..5c9a9432 Binary files /dev/null and b/authenticator/src/test/screenshots/PasskeyCreationPromptTest_skipping-passkey-creation.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_default-state.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_default-state.png new file mode 100644 index 00000000..a4bc38db Binary files /dev/null and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_default-state.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_password-visible.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_password-visible.png new file mode 100644 index 00000000..5ab509c6 Binary files /dev/null and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_password-visible.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-email.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-email.png new file mode 100644 index 00000000..eb1791e9 Binary files /dev/null and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-email.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-phonenumber.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-phonenumber.png new file mode 100644 index 00000000..fdeb4365 Binary files /dev/null and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit-with-phonenumber.png differ diff --git a/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit.png b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit.png new file mode 100644 index 00000000..5a27f96b Binary files /dev/null and b/authenticator/src/test/screenshots/SignInConfirmPasswordTest_ready-to-submit.png differ diff --git a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-email.png b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-email.png new file mode 100644 index 00000000..2ea4c4cd Binary files /dev/null and b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-email.png differ diff --git a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-phone-number.png b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-phone-number.png new file mode 100644 index 00000000..65297758 Binary files /dev/null and b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors-with-phone-number.png differ diff --git a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors.png b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors.png new file mode 100644 index 00000000..7da1050f Binary files /dev/null and b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_default-state-with-all-factors.png differ diff --git a/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_no-password.png b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_no-password.png new file mode 100644 index 00000000..73844633 Binary files /dev/null and b/authenticator/src/test/screenshots/SignInSelectAuthFactorTest_no-password.png differ diff --git a/authenticator/src/test/screenshots/SignUpTest_passwordless-with-email.png b/authenticator/src/test/screenshots/SignUpTest_passwordless-with-email.png new file mode 100644 index 00000000..7b82be8e Binary files /dev/null and b/authenticator/src/test/screenshots/SignUpTest_passwordless-with-email.png differ diff --git a/authenticator/src/test/screenshots/SignUpTest_passwordless-with-username.png b/authenticator/src/test/screenshots/SignUpTest_passwordless-with-username.png new file mode 100644 index 00000000..56bb9b62 Binary files /dev/null and b/authenticator/src/test/screenshots/SignUpTest_passwordless-with-username.png differ diff --git a/build-logic/plugins/src/main/kotlin/com/amplify/ui/ConfigureAndroid.kt b/build-logic/plugins/src/main/kotlin/com/amplify/ui/ConfigureAndroid.kt index ef26c6a0..f350c553 100644 --- a/build-logic/plugins/src/main/kotlin/com/amplify/ui/ConfigureAndroid.kt +++ b/build-logic/plugins/src/main/kotlin/com/amplify/ui/ConfigureAndroid.kt @@ -21,7 +21,7 @@ internal fun Project.configureAndroid(extension: CommonExtension<*, *, *, *, *, val sdkVersionName = findProperty("VERSION_NAME") ?: rootProject.findProperty("VERSION_NAME") extension.apply { - compileSdk = 35 + compileSdk = 36 buildFeatures { buildConfig = true @@ -64,4 +64,4 @@ internal fun Project.configureAndroid(extension: CommonExtension<*, *, *, *, *, kotlinCompilerExtensionVersion = "1.5.3" } } -} \ No newline at end of file +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index af287f4c..8e7a4b6d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] accompanist = "0.28.0" agp = "8.11.1" -amplify = "2.29.2" +amplify = "2.30.3" appcompat = "1.6.1" androidx-core = "1.9.0" androidx-datastore = "1.1.7" diff --git a/samples/authenticator/app/src/main/java/com/amplifyframework/ui/sample/authenticator/AuthenticatorSampleApp.kt b/samples/authenticator/app/src/main/java/com/amplifyframework/ui/sample/authenticator/AuthenticatorSampleApp.kt index 537cbad5..b8a3b111 100644 --- a/samples/authenticator/app/src/main/java/com/amplifyframework/ui/sample/authenticator/AuthenticatorSampleApp.kt +++ b/samples/authenticator/app/src/main/java/com/amplifyframework/ui/sample/authenticator/AuthenticatorSampleApp.kt @@ -20,11 +20,14 @@ import android.util.Log import com.amplifyframework.AmplifyException import com.amplifyframework.auth.cognito.AWSCognitoAuthPlugin import com.amplifyframework.core.Amplify +import com.amplifyframework.logging.AndroidLoggingPlugin +import com.amplifyframework.logging.LogLevel class AuthenticatorSampleApp : Application() { override fun onCreate() { super.onCreate() try { + Amplify.addPlugin(AndroidLoggingPlugin(LogLevel.VERBOSE)) Amplify.addPlugin(AWSCognitoAuthPlugin()) Amplify.configure(applicationContext) } catch (e: AmplifyException) { diff --git a/samples/authenticator/app/src/main/java/com/amplifyframework/ui/sample/authenticator/MainActivity.kt b/samples/authenticator/app/src/main/java/com/amplifyframework/ui/sample/authenticator/MainActivity.kt index f3f731c3..fde43ec8 100644 --- a/samples/authenticator/app/src/main/java/com/amplifyframework/ui/sample/authenticator/MainActivity.kt +++ b/samples/authenticator/app/src/main/java/com/amplifyframework/ui/sample/authenticator/MainActivity.kt @@ -15,7 +15,9 @@ package com.amplifyframework.ui.sample.authenticator +import android.app.Activity import android.os.Bundle +import android.util.Log import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement @@ -43,9 +45,12 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import com.amplifyframework.core.Amplify import com.amplifyframework.ui.authenticator.AuthenticatorState import com.amplifyframework.ui.authenticator.SignedInState +import com.amplifyframework.ui.authenticator.data.AuthenticationFlow import com.amplifyframework.ui.authenticator.rememberAuthenticatorState import com.amplifyframework.ui.authenticator.ui.Authenticator import com.amplifyframework.ui.sample.authenticator.data.ThemeDatastore @@ -62,7 +67,14 @@ class MainActivity : ComponentActivity() { val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() - val authenticatorState = rememberAuthenticatorState() + val authenticatorState = rememberAuthenticatorState( + authenticationFlow = AuthenticationFlow.UserChoice(), + signUpForm = { + email(required = true) + password(required = false) + phoneNumber(required = false) + } + ) ApplyTheme(theme = currentTheme, darkMode = darkMode) { ModalNavigationDrawer( @@ -126,10 +138,30 @@ fun SampleAppContent(drawerState: DrawerState, authenticatorState: Authenticator @Composable fun SignedInContent(state: SignedInState) { val scope = rememberCoroutineScope() + val activity = LocalContext.current as Activity Column { Text("You've signed in as ${state.user.username}!") Button(onClick = { scope.launch { state.signOut() } }) { Text("Sign Out") } + + Button(onClick = { + Amplify.Auth.associateWebAuthnCredential(activity, { }, ::logError) + }) { + Text("Register Passkey") + } + Button(onClick = { + Amplify.Auth.listWebAuthnCredentials({ + if (it.credentials.isNotEmpty()) { + Amplify.Auth.deleteWebAuthnCredential(it.credentials.first().credentialId, { }, ::logError) + } + }, ::logError) + }) { + Text("Delete Passkey") + } } } + +fun logError(e: Exception) { + Log.e("Sample", "Failed", e) +} diff --git a/samples/authenticator/app/src/main/java/com/amplifyframework/ui/sample/authenticator/data/ThemeDatastore.kt b/samples/authenticator/app/src/main/java/com/amplifyframework/ui/sample/authenticator/data/ThemeDatastore.kt index 123dc171..33d99fb6 100644 --- a/samples/authenticator/app/src/main/java/com/amplifyframework/ui/sample/authenticator/data/ThemeDatastore.kt +++ b/samples/authenticator/app/src/main/java/com/amplifyframework/ui/sample/authenticator/data/ThemeDatastore.kt @@ -13,9 +13,7 @@ import kotlinx.coroutines.flow.map class ThemeDatastore(context: Context) { - private val Context.dataStore: DataStore by preferencesDataStore(name = "theme") - - private val datastore = context.dataStore + private val datastore = initDatastore(context) private val darkModeKey = booleanPreferencesKey("darkMode") private val themeKey = stringPreferencesKey("theme") @@ -40,4 +38,16 @@ class ThemeDatastore(context: Context) { SupportedTheme.valueOf(name) } -} \ No newline at end of file + companion object { + private val Context.dataStore: DataStore by preferencesDataStore(name = "theme") + + private lateinit var dataStore: DataStore + + private fun initDatastore(context: Context): DataStore { + if (!::dataStore.isInitialized) { + dataStore = context.dataStore + } + return dataStore + } + } +}