From d105a47e0b18c347ed845731731b95f8bde57234 Mon Sep 17 00:00:00 2001 From: Manmohan Gupta Date: Fri, 13 Nov 2026 13:15:59 +0530 Subject: [PATCH 1/2] Add an email code as an MFA factor The second-factor list now offers an email address the server names, next to push and a phone code. The code screen is shared with the phone code, so a wrong code, Back, "Verify another way" and Resend behave the same. Factors are listed as iOS lists them: push, text, voice call, then email. Co-Authored-By: Claude --- EXAMPLES.md | 16 +- .../repository/EmbeddedLoginRepositoryImpl.kt | 2 +- .../feature/mfachallenge/MfaMessages.kt | 44 +++++- .../feature/mfachallenge/MfaScreens.kt | 5 + .../feature/mfachallenge/MfaStep.kt | 33 +++- .../mfachallenge/ResolvedMfaMessages.kt | 16 ++ .../data/mapper/MfaFactorMapper.kt | 19 ++- .../data/repository/MfaRepositoryImpl.kt | 34 ++-- .../mfachallenge/domain/model/MfaFactor.kt | 16 +- .../domain/repository/MfaRepository.kt | 5 +- .../mfachallenge/presentation/MfaContract.kt | 22 +-- .../mfachallenge/presentation/MfaEngine.kt | 7 +- .../mfachallenge/presentation/MfaHost.kt | 7 +- .../presentation/MfaMessagesResolver.kt | 28 ++++ .../mfachallenge/presentation/MfaReducer.kt | 66 ++++---- .../presentation/MfaStepMapper.kt | 66 +++++--- .../screens/MfaStandardScreens.kt | 144 ++++++++++++++--- .../src/main/res/drawable/ic_mfa_email.xml | 18 +++ .../src/main/res/values/strings.xml | 11 ++ .../EmbeddedLoginRepositoryImplTest.kt | 6 +- .../feature/mfachallenge/MfaMessagesTest.kt | 38 +++++ .../feature/mfachallenge/MfaScreensTest.kt | 14 ++ .../feature/mfachallenge/MfaStepTest.kt | 23 +++ .../data/repository/MfaRepositoryImplTest.kt | 78 +++++++++- .../presentation/MfaEngineTest.kt | 92 +++++++++-- .../presentation/MfaReducerTest.kt | 145 +++++++++++++++--- .../presentation/MfaStepMapperTest.kt | 88 ++++++++++- .../presentation/MfaStepStreamMirrorTest.kt | 50 +++++- 28 files changed, 943 insertions(+), 150 deletions(-) create mode 100644 universal_components/src/main/res/drawable/ic_mfa_email.xml diff --git a/EXAMPLES.md b/EXAMPLES.md index 2ec5592..6ab199f 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -338,10 +338,10 @@ On code entry, Back returns to the email or phone entry it came from, and when t ### Multi-Factor Authentication -When the tenant's MFA policy asks for more than the emailed code, `EmbeddedLoginScreen` moves into MFA in place; nothing extra is needed in your app. Today it runs a push notification or a code sent to a phone, by text message or voice call. With several factors, the user picks one from a list first. Other factors aren't supported in this flow yet. +When the tenant's MFA policy asks for more than the emailed code, `EmbeddedLoginScreen` moves into MFA in place; nothing extra is needed in your app. Today it runs a push notification, or a code sent to a phone (by text message or voice call) or to an email address. With several factors, the user picks one from a list first. Other factors aren't supported in this flow yet. - **Approved, or the code is right:** the credentials go to `onAuthenticated`. If the policy asks for another factor, the list returns headed **One more step**, with no limit on the number of steps. -- **A wrong phone code** stays on the screen, inline, so the user can type another. **Resend** opens 60 seconds after each send. +- **A wrong phone or email code** stays on the screen, inline, so the user can type another. **Resend** opens 60 seconds after each send. - **Declined, expired, session ended, too many attempts or no connection:** a screen with its own message and **Start over**, which returns to email entry with the email kept. - **Verify another way** (several factors) returns to the list. **Back** does the same from a factor screen, and returns to email entry from the list, or from a factor screen in the first step when there is only one factor. While a code or a push is being sent again, or a typed code is being checked, it does nothing. - **No factor this flow can run:** a **Verification method not available** screen shows, unless your app takes over with `onMfaError`. @@ -376,7 +376,7 @@ If you return `Handled`, your app owns what happens next, including when what yo #### Change the words -Every word has a default string resource named `auth0_mfa__`, for example `auth0_mfa_push_challenge_title`. To change it for the whole app (and translate it), declare a string with the same name in your app. To change one place, or to build text from a value such as the device name, use `MfaMessages`; anything you leave unset keeps its default. The groups are `authenticatorList`, `loading`, `errors`, `pushChallenge` and `phoneChallenge`. +Every word has a default string resource named `auth0_mfa__`, for example `auth0_mfa_push_challenge_title`. To change it for the whole app (and translate it), declare a string with the same name in your app. To change one place, or to build text from a value such as the device name, use `MfaMessages`; anything you leave unset keeps its default. The groups are `authenticatorList`, `loading`, `errors`, `pushChallenge`, `phoneChallenge` and `emailChallenge`. ```kotlin // Built once: a new MfaMessages on every recomposition would make the words resolve again. @@ -390,6 +390,11 @@ val mfa = remember { phoneChallenge { resend { cooldown = { seconds -> "Send again in $seconds s" } } } + emailChallenge { + // The address as the server hides it, such as j***@example.com. When it gives none, the line + // under the title is `descriptionUnnamed`. + description = { address -> "We emailed a code to $address." } + } errors { rejected { title = "Sign-in denied" } } }, ) @@ -428,9 +433,9 @@ mfa = MfaConfiguration( ) ``` -`canResend` says whether the server still allows another send. `resendAvailableIn` is the seconds left before it may be sent, and 0 when it can be tapped now. The flow owns that countdown, so calling `resend()` early does nothing; a replacement only has to show it (the model is published again each second while it counts down). +`canResend` says whether the server still allows another send. `resendAvailableIn` is the seconds left before it may be sent, and 0 when it can be tapped now. The flow owns that countdown, so calling `resend()` early does nothing; a replacement only has to show it (the model is published again each second while it counts down). On a code screen (phone or email), `submit(code)` does nothing unless the code is exactly 6 digits, so a replacement may enable its button on any input. -The factor list (`authenticatorList`), the phone code screen (`phoneChallenge`) and the error screen (`error`) are replaced the same way. There is no screen to replace for loading. Models hold data only, and every action defaults to doing nothing, so they are easy to build for a preview or a test of your own screen. Build the words with `ResolvedMfaMessages.from(context)`, passing `MfaMessages { ... }` as the second argument to include your own. +The factor list (`authenticatorList`), the phone code screen (`phoneChallenge`), the email code screen (`emailChallenge`) and the error screen (`error`) are replaced the same way. There is no screen to replace for loading. Models hold data only, and every action defaults to doing nothing, so they are easy to build for a preview or a test of your own screen. Build the words with `ResolvedMfaMessages.from(context)`, passing `MfaMessages { ... }` as the second argument to include your own. #### Draw every MFA screen yourself (headless) @@ -453,6 +458,7 @@ Box { is MfaStep.Factor -> when (val model = current.step.model) { is PushChallengeModel -> MyPush(model) is PhoneChallengeModel -> MyPhone(model) + is EmailChallengeModel -> MyEmail(model) else -> Unit } is MfaStep.Error -> MyError(current.model) diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/embedded/data/repository/EmbeddedLoginRepositoryImpl.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/embedded/data/repository/EmbeddedLoginRepositoryImpl.kt index 242004c..0a094f9 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/embedded/data/repository/EmbeddedLoginRepositoryImpl.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/embedded/data/repository/EmbeddedLoginRepositoryImpl.kt @@ -158,7 +158,7 @@ internal class EmbeddedLoginRepositoryImpl( /** * What these screens can drive, told to the server so it only offers those: a code sent to an email or a phone, - * then a push or a code sent to a phone. The same as iOS, which sends only what is plugged in. + * then a push, or a code sent to a phone or an email. The same as iOS, which sends only what is plugged in. */ val CAPABILITIES = setOf( EmbeddedCapability.IDENTIFY_EMAIL, diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaMessages.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaMessages.kt index ed1ab82..d23a881 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaMessages.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaMessages.kt @@ -25,6 +25,7 @@ class MfaMessages private constructor( internal val errors: Errors, internal val pushChallenge: PushChallenge, internal val phoneChallenge: PhoneChallenge, + internal val emailChallenge: EmailChallenge, ) { /** A title and a description, either of which can be changed. */ @@ -155,6 +156,43 @@ class MfaMessages private constructor( } } + /** Typing the code that was sent to an email address. */ + class EmailChallenge internal constructor() { + internal val row = Message() + + var title: String? = null + + /** Takes the email address as the server hides it. */ + var description: ((address: String) -> String)? = null + + /** For a code sent when the server gave no address. */ + var descriptionUnnamed: String? = null + + /** The label above the code boxes. */ + var codeLabel: String? = null + var verifyButton: String? = null + + /** Under the code field after the server refused a code. */ + var wrongCode: String? = null + var verifyAnotherWay: String? = null + internal val resend = Resend() + + fun resend(configure: Resend.() -> Unit) = resend.configure() + + /** + * How the factor looks in the list. The title is the factor's name, with the address under it; the description + * sits there instead when the server gave no address. + */ + fun row(configure: Message.() -> Unit) = row.configure() + + class Resend internal constructor() { + var button: String? = null + + /** Takes the seconds left until Resend opens. */ + var cooldown: ((seconds: Int) -> String)? = null + } + } + companion object { /** @@ -173,6 +211,7 @@ class MfaMessages private constructor( private val errors = Errors() private val pushChallenge = PushChallenge() private val phoneChallenge = PhoneChallenge() + private val emailChallenge = EmailChallenge() fun authenticatorList(configure: AuthenticatorList.() -> Unit) = authenticatorList.configure() @@ -184,6 +223,9 @@ class MfaMessages private constructor( fun phoneChallenge(configure: PhoneChallenge.() -> Unit) = phoneChallenge.configure() - internal fun build() = MfaMessages(authenticatorList, loading, errors, pushChallenge, phoneChallenge) + fun emailChallenge(configure: EmailChallenge.() -> Unit) = emailChallenge.configure() + + internal fun build() = + MfaMessages(authenticatorList, loading, errors, pushChallenge, phoneChallenge, emailChallenge) } } diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaScreens.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaScreens.kt index 2245634..d49942f 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaScreens.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaScreens.kt @@ -65,6 +65,11 @@ class MfaScreens private constructor( replace(MfaFactorId.Phone) { model, messages -> if (model is PhoneChallengeModel) content(model, messages) } } + /** Typing the code that was sent to an email address. */ + fun emailChallenge(content: @Composable (EmailChallengeModel, ResolvedMfaMessages) -> Unit) { + replace(MfaFactorId.Email) { model, messages -> if (model is EmailChallengeModel) content(model, messages) } + } + internal fun build() = MfaScreens(authenticatorList, error, factors.toMap()) } } diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaStep.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaStep.kt index e82c6ed..178a083 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaStep.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaStep.kt @@ -71,9 +71,10 @@ data class AuthenticatorListModel @JvmOverloads constructor( /** * One way to verify. [title] is what the user named the factor, such as a device name, and may be blank: show the * factor's own name then (`messages.pushChallenge.row`); for a phone it is the number as the server hides it, and - * [delivery] says how its code is sent (the standard list words the row from both). [subtitle] is null from this - * library: the standard list words its second line from the messages too. [icon] is a drawable of this library; - * null draws none. + * [delivery] says how its code is sent (the standard list words the row from both); for an email it is the address + * as the server hides it, and blank when the server gave none (`messages.emailChallenge.row`). [subtitle] is null + * from this library: the standard list words its second line from the messages too. [icon] is a drawable of this + * library; null draws none. */ @Immutable data class Row @JvmOverloads constructor( @@ -88,7 +89,7 @@ data class AuthenticatorListModel @JvmOverloads constructor( /** * The screen of one factor. [model] is the factor's own model, picked by [factor] (a push gets a - * [PushChallengeModel], a phone a [PhoneChallengeModel]). + * [PushChallengeModel], a phone a [PhoneChallengeModel], an email an [EmailChallengeModel]). */ @Immutable data class MfaFactorStep @JvmOverloads constructor( @@ -149,6 +150,27 @@ enum class PhoneDelivery { Voice, } +/** + * A code was sent to [address] (as the server hides it, maybe blank) and waits to be typed. [submit] checks a typed + * code, unless [isVerifying]; [isWrongCode] is true after the server refused the last one. Resend is offered while + * [canResend] and opens once [resendAvailableIn] seconds have passed; while it goes out, [isResending] is true. + * [switchFactor] returns to the list when [canSwitch]. [cancel] leaves MFA. + */ +@Immutable +data class EmailChallengeModel @JvmOverloads constructor( + val address: String, + val isWrongCode: Boolean = false, + val isVerifying: Boolean = false, + val isResending: Boolean = false, + val canResend: Boolean = false, + val resendAvailableIn: Int = 0, + val canSwitch: Boolean = false, + val submit: (code: String) -> Unit = {}, + val resend: () -> Unit = {}, + val switchFactor: () -> Unit = {}, + val cancel: () -> Unit = {}, +) : MfaFactorModel + /** Why MFA ended is [error]; [exit] says where [leave] goes. */ @Immutable data class ErrorModel @JvmOverloads constructor( @@ -180,5 +202,8 @@ value class MfaFactorId(val value: String) { /** Type a code that was sent to a phone, by text or by a call. */ val Phone = MfaFactorId("phone") + + /** Type a code that was sent to an email address. */ + val Email = MfaFactorId("email") } } diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/ResolvedMfaMessages.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/ResolvedMfaMessages.kt index a72127c..bcfb069 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/ResolvedMfaMessages.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/ResolvedMfaMessages.kt @@ -14,6 +14,7 @@ class ResolvedMfaMessages internal constructor( val errors: Errors, val pushChallenge: PushChallenge, val phoneChallenge: PhoneChallenge, + val emailChallenge: EmailChallenge, ) { companion object { @@ -77,4 +78,19 @@ class ResolvedMfaMessages internal constructor( ) { class Resend internal constructor(val button: String, val cooldown: (seconds: Int) -> String) } + + @Suppress("LongParameterList") // one word per line of the screen; they belong together + class EmailChallenge internal constructor( + val row: Message, + val title: String, + val description: (address: String) -> String, + val descriptionUnnamed: String, + val codeLabel: String, + val verifyButton: String, + val wrongCode: String, + val resend: Resend, + val verifyAnotherWay: String, + ) { + class Resend internal constructor(val button: String, val cooldown: (seconds: Int) -> String) + } } diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/mapper/MfaFactorMapper.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/mapper/MfaFactorMapper.kt index 43a7fd9..d148efb 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/mapper/MfaFactorMapper.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/mapper/MfaFactorMapper.kt @@ -5,9 +5,11 @@ import com.auth0.android.embedded.authorize.PhoneDeliveryMethod import com.auth0.universalcomponents.feature.mfachallenge.domain.model.MfaFactor /** - * The second factors in a server menu that these screens support; the rest is left out. A push comes before a code sent - * to a phone, the more phishing-resistant first; within each kind the server's order is kept. A phone gives one factor - * for each way the server can reach it, and a text when it names none, as the SDK does. + * The second factors in a server menu that these screens support; the rest is left out. They are listed as iOS lists + * them: a push first (the more phishing-resistant), then a code texted to a phone, a code spoken by a call, and a code + * sent to an email; within each the server's order is kept. A phone gives one factor for each way the server can reach + * it, and a text when it names none, as the SDK does. An email address is listed even when the first factor used it: + * the server decides what is on offer. */ internal fun List.toMfaFactors(): List = flatMap { action -> when (action) { @@ -16,6 +18,15 @@ internal fun List.toMfaFactors(): List = flatMap { action action.deliveryMethods.ifEmpty { listOf(PhoneDeliveryMethod.TEXT) }.distinct().map { delivery -> MfaFactor.Phone(action.index, action.identifier, delivery) } + is NextAction.ChallengeEmail -> listOf(MfaFactor.Email(action.index, action.identifier)) else -> emptyList() } -}.sortedBy { it is MfaFactor.Phone } +}.sortedBy { it.listRank } + +/** Where a factor sits in the list: lower first. */ +private val MfaFactor.listRank: Int + get() = when (this) { + is MfaFactor.Push -> 0 + is MfaFactor.Phone -> if (delivery == PhoneDeliveryMethod.VOICE) 2 else 1 + is MfaFactor.Email -> 3 + } diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImpl.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImpl.kt index d0e4b06..0d4fd67 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImpl.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImpl.kt @@ -75,18 +75,32 @@ internal class MfaRepositoryImpl(private val steps: EmbeddedAuthSteps) : MfaRepo get() = (error as? EmbeddedAuthException)?.error == EmbeddedAuthError.AuthorizationRejected override suspend fun sendPhoneCode(factor: MfaFactor.Phone): Result = - when (val sent = steps.run { challengePhone(factor.index, factor.delivery) }) { - // The code is out once the server asks for it to be verified. - is StepAnswer.Next -> - if (sent.actions.any { it is NextAction.VerifyOtp }) { - Result.Success(sent.actions.any { it is NextAction.ChallengePhone }) - } else { - unexpected("The code was sent without a way to verify it") - } - is StepAnswer.Failed -> Result.Error(sent.mfaError) - is StepAnswer.Done -> unexpected("Sending a code signed in") + steps.run { challengePhone(factor.index, factor.delivery) }.toCodeSent { actions -> + actions.any { it is NextAction.ChallengePhone } + } + + override suspend fun sendEmailCode(factor: MfaFactor.Email): Result = + steps.run { challengeEmail(factor.index) }.toCodeSent { actions -> + actions.any { it is NextAction.ChallengeEmail && it.index == factor.index } } + /** + * The code is out once the server asks for it to be verified. [offersAgain] reads from the answer's menu whether the + * server still offers to send it again, which differs for a phone and an email address. + */ + private fun StepAnswer.toCodeSent( + offersAgain: (List) -> Boolean, + ): Result = when (this) { + is StepAnswer.Next -> + if (actions.any { it is NextAction.VerifyOtp }) { + Result.Success(offersAgain(actions)) + } else { + unexpected("The code was sent without a way to verify it") + } + is StepAnswer.Failed -> Result.Error(mfaError) + is StepAnswer.Done -> unexpected("Sending a code signed in") + } + override suspend fun verifyCode(code: String): Result = when (val verified = steps.run { verifyOtp(code, OtpType.OOB) }) { is StepAnswer.Done -> Result.Success(CodeStatus.Verified(verified.value)) diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/domain/model/MfaFactor.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/domain/model/MfaFactor.kt index c2484c9..bf9450a 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/domain/model/MfaFactor.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/domain/model/MfaFactor.kt @@ -5,18 +5,30 @@ import com.auth0.android.result.Credentials /** * A second way to prove who the user is, offered by the server once the first factor passed. - * Push and a code sent to a phone are supported for now; more factors are added here one at a time. + * Push and a code sent to a phone or an email are supported for now; more factors are added here one at a time. */ internal sealed interface MfaFactor { /** [index] is the server's handle for that device; [deviceName] is what the user called it, maybe blank. */ data class Push(val index: Int, val deviceName: String) : MfaFactor + /** + * A factor whose proof is a code the user types. They share one screen state, so the rules for a wrong code, Back, + * "Verify another way" and Resend are written once. + */ + sealed interface Code : MfaFactor + /** * A code sent to a phone, by [delivery]. [index] is the server's handle for the phone and [maskedNumber] is the number * as the server hides it. A phone that can be texted and called is two factors, one per way, so each is its own row. */ - data class Phone(val index: Int, val maskedNumber: String, val delivery: PhoneDeliveryMethod) : MfaFactor + data class Phone(val index: Int, val maskedNumber: String, val delivery: PhoneDeliveryMethod) : Code + + /** + * A code sent to an email address. [index] is the server's handle for the address and [address] is the address as the + * server hides it, maybe blank. + */ + data class Email(val index: Int, val address: String) : Code } /** Where a push stands after the server answered: sending it, or one check of it. */ diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/domain/repository/MfaRepository.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/domain/repository/MfaRepository.kt index b0882bd..0bb0192 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/domain/repository/MfaRepository.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/domain/repository/MfaRepository.kt @@ -21,6 +21,9 @@ internal interface MfaRepository { /** Sends the code to the phone; the answer is true while the server still offers to send it again. */ suspend fun sendPhoneCode(factor: MfaFactor.Phone): Result - /** Sends the code the user typed for the phone code that went out last. */ + /** Sends the code to the email address; the answer is true while the server still offers that address. */ + suspend fun sendEmailCode(factor: MfaFactor.Email): Result + + /** Sends the code the user typed for the code, phone or email, that went out last. */ suspend fun verifyCode(code: String): Result } diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaContract.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaContract.kt index 7402a43..e501a9d 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaContract.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaContract.kt @@ -64,14 +64,14 @@ internal sealed interface MfaState { ) : MfaState /** - * A code went to the phone and waits to be typed. [phase] says what is on its way; [isWrongCode] is true after the - * server refused the last code. [canResend] is the server's say; Resend opens once [resendSecondsLeft] also reaches - * zero. + * A code went to the phone or the email address of [factor] and waits to be typed. [phase] says what is on its way; + * [isWrongCode] is true after the server refused the last code. [canResend] is the server's say; Resend opens once + * [resendSecondsLeft] also reaches zero. */ - data class PhoneCode( + data class CodeEntry( val round: MfaRound, - val factor: MfaFactor.Phone, - val phase: PhoneCodePhase, + val factor: MfaFactor.Code, + val phase: CodePhase, val canResend: Boolean, val resendSecondsLeft: Int, val isWrongCode: Boolean = false, @@ -95,7 +95,7 @@ internal sealed interface PushPhase { } /** What is on its way for the code screen on show; only one at a time. */ -internal enum class PhoneCodePhase { +internal enum class CodePhase { /** Nothing: the user may type a code. */ Ready, @@ -124,7 +124,7 @@ internal sealed interface MfaIntent { /** Sends the code the user typed. */ data class SubmitCode(val code: String) : MfaIntent - /** Sends the push, or the code, to the same device or phone again. */ + /** Sends the push, or the code, to the same device, phone or address again. */ data object Resend : MfaIntent } @@ -149,7 +149,7 @@ internal sealed interface MfaOutcome { data class AnotherFactor(val factors: List) : MfaOutcome /** The code is out; [canResend] says whether the server still offers to send it again. */ - data class PhoneCodeSent(val canResend: Boolean) : MfaOutcome + data class CodeSent(val canResend: Boolean) : MfaOutcome /** The server did not accept the typed code. */ data object CodeRejected : MfaOutcome @@ -177,6 +177,8 @@ internal sealed interface MfaCommand { data class SendPhoneCode(val factor: MfaFactor.Phone) : MfaCommand + data class SendEmailCode(val factor: MfaFactor.Email) : MfaCommand + data class VerifyCode(val code: String) : MfaCommand /** Waits [afterMs], then says the next check is due; the reducer decides whether it still is. */ @@ -186,7 +188,7 @@ internal sealed interface MfaCommand { data object CheckPush : MfaCommand /** - * Cancels the wait before the next check, and a push or phone code still waiting for its turn at the SDK, so one + * Cancels the wait before the next check, and a push or code still waiting for its turn at the SDK, so one * the user has left behind is never sent. A call already on its way still finishes: the SDK can't stop it, and its * answer is dropped. */ diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaEngine.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaEngine.kt index 2784fc5..57814de 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaEngine.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaEngine.kt @@ -138,7 +138,12 @@ internal class MfaEngine( is MfaCommand.SendPhoneCode -> send { val sent = repository.sendPhoneCode(command.factor) - onOutcome(sent.toOutcome(MfaOutcome::SendFailed) { MfaOutcome.PhoneCodeSent(canResend = it) }) + onOutcome(sent.toOutcome(MfaOutcome::SendFailed) { MfaOutcome.CodeSent(canResend = it) }) + } + + is MfaCommand.SendEmailCode -> send { + val sent = repository.sendEmailCode(command.factor) + onOutcome(sent.toOutcome(MfaOutcome::SendFailed) { MfaOutcome.CodeSent(canResend = it) }) } is MfaCommand.VerifyCode -> scope.launch { diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaHost.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaHost.kt index 5911378..e4bcf8e 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaHost.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaHost.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.auth0.universalcomponents.feature.mfachallenge.EmailChallengeModel import com.auth0.universalcomponents.feature.mfachallenge.LoadingModel import com.auth0.universalcomponents.feature.mfachallenge.MfaConfiguration import com.auth0.universalcomponents.feature.mfachallenge.MfaPresentation @@ -16,6 +17,7 @@ import com.auth0.universalcomponents.feature.mfachallenge.PhoneChallengeModel import com.auth0.universalcomponents.feature.mfachallenge.PushChallengeModel import com.auth0.universalcomponents.feature.mfachallenge.ResolvedMfaMessages import com.auth0.universalcomponents.feature.mfachallenge.presentation.screens.MfaAuthenticatorListScreen +import com.auth0.universalcomponents.feature.mfachallenge.presentation.screens.MfaEmailChallengeScreen import com.auth0.universalcomponents.feature.mfachallenge.presentation.screens.MfaErrorScreen import com.auth0.universalcomponents.feature.mfachallenge.presentation.screens.MfaLoadingScreen import com.auth0.universalcomponents.feature.mfachallenge.presentation.screens.MfaPhoneChallengeScreen @@ -95,6 +97,7 @@ private fun MfaStepContent(step: MfaStep, configuration: MfaConfiguration, messa when (val model = step.step.model) { is PushChallengeModel -> MfaPushChallengeScreen(model, messages = messages) is PhoneChallengeModel -> MfaPhoneChallengeScreen(model, messages = messages) + is EmailChallengeModel -> MfaEmailChallengeScreen(model, messages = messages) } } } @@ -120,7 +123,7 @@ private val MfaState?.transitionIdentity: String is MfaState.ChooseFactor -> "list:${round.number}" is MfaState.SendingChallenge -> if (round.showsList) "list:${round.number}" else "sending:${round.number}" is MfaState.PushWaiting -> "push:${round.number}" - is MfaState.PhoneCode -> "phone:${round.number}" + is MfaState.CodeEntry -> "code:${round.number}" is MfaState.Failed -> "failed" } @@ -134,7 +137,7 @@ private val MfaState?.navigationDepth: Int is MfaState.ChooseFactor -> round.depth is MfaState.SendingChallenge -> round.depth is MfaState.PushWaiting -> round.depth + 1 - is MfaState.PhoneCode -> round.depth + 1 + is MfaState.CodeEntry -> round.depth + 1 is MfaState.Failed -> Int.MAX_VALUE } diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaMessagesResolver.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaMessagesResolver.kt index 04e5adb..2f4993e 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaMessagesResolver.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaMessagesResolver.kt @@ -116,6 +116,7 @@ internal fun MfaMessages.resolve(resources: Resources): ResolvedMfaMessages { verifyAnotherWay = word(push.verifyAnotherWay, R.string.auth0_mfa_push_challenge_verify_another_way), ), phoneChallenge = phoneChallenge.resolve(resources), + emailChallenge = emailChallenge.resolve(resources), ) } @@ -154,6 +155,33 @@ private fun MfaMessages.PhoneChallenge.resolve(resources: Resources): ResolvedMf ) } +/** The words of the email code screen and its list row, resolved the same way as the rest. */ +private fun MfaMessages.EmailChallenge.resolve(resources: Resources): ResolvedMfaMessages.EmailChallenge { + fun word(override: String?, @StringRes id: Int) = override ?: resources.getString(id) + + return ResolvedMfaMessages.EmailChallenge( + row = ResolvedMfaMessages.Message( + word(row.title, R.string.auth0_mfa_email_challenge_row_title), + word(row.description, R.string.auth0_mfa_email_challenge_row_description), + ), + title = word(title, R.string.auth0_mfa_email_challenge_title), + description = description ?: { address -> + resources.getString(R.string.auth0_mfa_email_challenge_description, address) + }, + descriptionUnnamed = word(descriptionUnnamed, R.string.auth0_mfa_email_challenge_description_unnamed), + codeLabel = word(codeLabel, R.string.auth0_mfa_email_challenge_code_label), + verifyButton = word(verifyButton, R.string.auth0_mfa_email_challenge_verify_button), + wrongCode = word(wrongCode, R.string.auth0_mfa_email_challenge_wrong_code), + resend = ResolvedMfaMessages.EmailChallenge.Resend( + button = word(resend.button, R.string.auth0_mfa_email_challenge_resend_button), + cooldown = resend.cooldown ?: { seconds -> + resources.getString(R.string.auth0_mfa_email_challenge_resend_cooldown, seconds) + }, + ), + verifyAnotherWay = word(verifyAnotherWay, R.string.auth0_mfa_email_challenge_verify_another_way), + ) +} + /** * The server hides most of a phone number (`XXXXXXXXX0128`, or with `*` or other look-alikes). The default words keep * the digits and the usual phone marks and draw every other character as one `*`, as the design does, so the hidden part diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaReducer.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaReducer.kt index b9454e3..253ef9a 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaReducer.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaReducer.kt @@ -31,7 +31,7 @@ internal class MfaReducer(private val factors: List) { MfaIntent.ChooseAnotherFactor -> when { state is MfaState.PushWaiting && state.round.canSwitch && state.phase != PushPhase.Resending -> showList(state.round) - state is MfaState.PhoneCode && state.round.canSwitch && state.phase == PhoneCodePhase.Ready -> + state is MfaState.CodeEntry && state.round.canSwitch && state.phase == CodePhase.Ready -> showList(state.round) else -> unchanged } @@ -43,15 +43,16 @@ internal class MfaReducer(private val factors: List) { MfaIntent.Resend -> when { state is MfaState.PushWaiting && state.canResendNow -> resend(state) - state is MfaState.PhoneCode && state.canResendNow -> resendCode(state) + state is MfaState.CodeEntry && state.canResendNow -> resendCode(state) else -> unchanged } - // Only a code that is ready for one, and not an empty one. + // Only a code that is ready for one, and only a whole one: the standard screen never sends less, and a + // replaced screen is held to the same rule, as on iOS. is MfaIntent.SubmitCode -> - if (state is MfaState.PhoneCode && state.phase == PhoneCodePhase.Ready && intent.code.isNotBlank()) { + if (state is MfaState.CodeEntry && state.phase == CodePhase.Ready && intent.code.isWholeCode()) { MfaUpdate( - state.copy(phase = PhoneCodePhase.Verifying, isWrongCode = false), + state.copy(phase = CodePhase.Verifying, isWrongCode = false), listOf(MfaCommand.VerifyCode(intent.code)), ) } else { @@ -63,7 +64,7 @@ internal class MfaReducer(private val factors: List) { fun handleOutcome(state: MfaState, outcome: MfaOutcome): MfaUpdate = when (outcome) { is MfaOutcome.PushSent -> pushSent(state, outcome.pending) is MfaOutcome.PushPending -> pushPending(state, outcome.pending) - is MfaOutcome.PhoneCodeSent -> phoneCodeSent(state, outcome.canResend) + is MfaOutcome.CodeSent -> codeSent(state, outcome.canResend) MfaOutcome.CodeRejected -> codeRejected(state) is MfaOutcome.CodeFailed -> codeFailed(state, outcome.error) // Even after the user moved on: they did approve, so they're signed in. The host shows the success screen. @@ -105,14 +106,14 @@ internal class MfaReducer(private val factors: List) { fun handleSecondPassed(state: MfaState, seconds: Int = 1): MfaUpdate { val secondsLeft = when (state) { is MfaState.PushWaiting -> state.resendSecondsLeft - is MfaState.PhoneCode -> state.resendSecondsLeft + is MfaState.CodeEntry -> state.resendSecondsLeft else -> 0 } if (secondsLeft == 0) return MfaUpdate(state) val left = (secondsLeft - maxOf(1, seconds)).coerceAtLeast(0) val counted = when (state) { is MfaState.PushWaiting -> state.copy(resendSecondsLeft = left) - is MfaState.PhoneCode -> state.copy(resendSecondsLeft = left) + is MfaState.CodeEntry -> state.copy(resendSecondsLeft = left) else -> state } return MfaUpdate(counted, if (left > 0) listOf(MfaCommand.CountDownResend) else emptyList()) @@ -132,8 +133,13 @@ internal class MfaReducer(private val factors: List) { /** The challenge goes out first; its screen shows once it's out. */ private fun select(round: MfaRound, factor: MfaFactor): MfaUpdate = when (factor) { is MfaFactor.Push -> MfaUpdate(MfaState.SendingChallenge(round, factor), listOf(MfaCommand.SendPush(factor))) - is MfaFactor.Phone -> - MfaUpdate(MfaState.SendingChallenge(round, factor), listOf(MfaCommand.SendPhoneCode(factor))) + is MfaFactor.Code -> MfaUpdate(MfaState.SendingChallenge(round, factor), listOf(sendCode(factor))) + } + + /** Each way a code travels has its own call; the code the user types back is checked the same way for all. */ + private fun sendCode(factor: MfaFactor.Code): MfaCommand = when (factor) { + is MfaFactor.Phone -> MfaCommand.SendPhoneCode(factor) + is MfaFactor.Email -> MfaCommand.SendEmailCode(factor) } /** A first push, or a resend, is out: the checks and the resend wait start. */ @@ -181,18 +187,18 @@ internal class MfaReducer(private val factors: List) { } /** A first code, or a resend, is out: the resend wait starts and the user may type. */ - private fun phoneCodeSent(state: MfaState, canResend: Boolean): MfaUpdate { + private fun codeSent(state: MfaState, canResend: Boolean): MfaUpdate { val ready = when { - state is MfaState.SendingChallenge && state.factor is MfaFactor.Phone -> MfaState.PhoneCode( + state is MfaState.SendingChallenge && state.factor is MfaFactor.Code -> MfaState.CodeEntry( round = state.round, factor = state.factor, - phase = PhoneCodePhase.Ready, + phase = CodePhase.Ready, canResend = canResend, resendSecondsLeft = RESEND_COOLDOWN_SECONDS, ) - state is MfaState.PhoneCode && state.phase == PhoneCodePhase.Resending -> state.copy( - phase = PhoneCodePhase.Ready, + state is MfaState.CodeEntry && state.phase == CodePhase.Resending -> state.copy( + phase = CodePhase.Ready, canResend = canResend, resendSecondsLeft = RESEND_COOLDOWN_SECONDS, isWrongCode = false, @@ -206,15 +212,15 @@ internal class MfaReducer(private val factors: List) { /** A wrong code is not the end: the user may type another, and the screen says so. */ private fun codeRejected(state: MfaState): MfaUpdate = - if (state is MfaState.PhoneCode && state.phase == PhoneCodePhase.Verifying) { - MfaUpdate(state.copy(phase = PhoneCodePhase.Ready, isWrongCode = true)) + if (state is MfaState.CodeEntry && state.phase == CodePhase.Verifying) { + MfaUpdate(state.copy(phase = CodePhase.Ready, isWrongCode = true)) } else { MfaUpdate(state) } /** Any other failure of the code check, such as an expired code, ends MFA, if the check is still the one on show. */ private fun codeFailed(state: MfaState, error: MfaError): MfaUpdate = - if (state is MfaState.PhoneCode && state.phase == PhoneCodePhase.Verifying) fail(error) else MfaUpdate(state) + if (state is MfaState.CodeEntry && state.phase == CodePhase.Verifying) fail(error) else MfaUpdate(state) /** * The user approved and the sign-in needs one more factor: the next round starts with the server's new menu, @@ -230,7 +236,7 @@ internal class MfaReducer(private val factors: List) { private fun sendFailed(state: MfaState, error: MfaError): MfaUpdate { val isOnShow = state is MfaState.SendingChallenge || (state is MfaState.PushWaiting && state.phase == PushPhase.Resending) || - (state is MfaState.PhoneCode && state.phase == PhoneCodePhase.Resending) + (state is MfaState.CodeEntry && state.phase == CodePhase.Resending) return if (isOnShow) fail(error) else MfaUpdate(state) } @@ -262,9 +268,9 @@ internal class MfaReducer(private val factors: List) { ) /** The code goes out again; until it's out the user can neither type nor leave the screen. */ - private fun resendCode(state: MfaState.PhoneCode): MfaUpdate = MfaUpdate( - state.copy(phase = PhoneCodePhase.Resending, canResend = false), - listOf(MfaCommand.SendPhoneCode(state.factor)), + private fun resendCode(state: MfaState.CodeEntry): MfaUpdate = MfaUpdate( + state.copy(phase = CodePhase.Resending, canResend = false), + listOf(sendCode(state.factor)), ) /** @@ -280,8 +286,8 @@ internal class MfaReducer(private val factors: List) { state.round.showsList -> showList(state.round) else -> restart(state) } - is MfaState.PhoneCode -> when { - state.phase != PhoneCodePhase.Ready -> MfaUpdate(state) + is MfaState.CodeEntry -> when { + state.phase != CodePhase.Ready -> MfaUpdate(state) state.round.showsList -> showList(state.round) else -> restart(state) } @@ -302,22 +308,28 @@ internal class MfaReducer(private val factors: List) { get() = phase != PushPhase.Resending && canResend && resendSecondsLeft == 0 /** The code is out, the server still offers another, and the resend wait is over. */ - private val MfaState.PhoneCode.canResendNow: Boolean - get() = phase == PhoneCodePhase.Ready && canResend && resendSecondsLeft == 0 + private val MfaState.CodeEntry.canResendNow: Boolean + get() = phase == CodePhase.Ready && canResend && resendSecondsLeft == 0 private val MfaState.roundOrNull: MfaRound? get() = when (this) { is MfaState.ChooseFactor -> round is MfaState.SendingChallenge -> round is MfaState.PushWaiting -> round - is MfaState.PhoneCode -> round + is MfaState.CodeEntry -> round is MfaState.Failed -> null } + /** Six digits, the length the code boxes take. */ + private fun String.isWholeCode(): Boolean = length == CODE_LENGTH && all(Char::isDigit) + companion object { /** The same wait as resending an emailed code. */ const val RESEND_COOLDOWN_SECONDS = 60 + /** How many digits a code has. */ + private const val CODE_LENGTH = 6 + /** Checks in a row that may fail on the network before MFA ends, as on iOS. */ const val QUIET_RETRIES = 3 diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaStepMapper.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaStepMapper.kt index 2359096..f587c63 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaStepMapper.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaStepMapper.kt @@ -4,11 +4,13 @@ import com.auth0.android.embedded.authorize.PhoneDeliveryMethod import com.auth0.universalcomponents.R import com.auth0.universalcomponents.feature.mfachallenge.AuthenticatorListHeader import com.auth0.universalcomponents.feature.mfachallenge.AuthenticatorListModel +import com.auth0.universalcomponents.feature.mfachallenge.EmailChallengeModel import com.auth0.universalcomponents.feature.mfachallenge.ErrorModel import com.auth0.universalcomponents.feature.mfachallenge.LoadingModel import com.auth0.universalcomponents.feature.mfachallenge.MfaError import com.auth0.universalcomponents.feature.mfachallenge.MfaExit import com.auth0.universalcomponents.feature.mfachallenge.MfaFactorId +import com.auth0.universalcomponents.feature.mfachallenge.MfaFactorModel import com.auth0.universalcomponents.feature.mfachallenge.MfaFactorStep import com.auth0.universalcomponents.feature.mfachallenge.MfaStep import com.auth0.universalcomponents.feature.mfachallenge.MfaStepStream @@ -53,24 +55,11 @@ internal fun MfaState.toMfaStep(onIntent: (MfaIntent) -> Unit, onLeave: () -> Un ), ) - is MfaState.PhoneCode -> MfaStep.Factor( + is MfaState.CodeEntry -> MfaStep.Factor( MfaFactorStep( - factor = MfaFactorId.Phone, + factor = state.factor.factorId, round = state.round.number, - model = PhoneChallengeModel( - phoneNumber = state.factor.maskedNumber, - delivery = state.factor.delivery.toDelivery(), - isWrongCode = state.isWrongCode, - isVerifying = state.phase == PhoneCodePhase.Verifying, - isResending = state.phase == PhoneCodePhase.Resending, - canResend = state.canResend, - resendAvailableIn = state.resendSecondsLeft, - canSwitch = state.round.canSwitch, - submit = { code -> onIntent(MfaIntent.SubmitCode(code)) }, - resend = { onIntent(MfaIntent.Resend) }, - switchFactor = { onIntent(MfaIntent.ChooseAnotherFactor) }, - cancel = cancel, - ), + model = state.toModel(onIntent, cancel), ), ) @@ -78,6 +67,43 @@ internal fun MfaState.toMfaStep(onIntent: (MfaIntent) -> Unit, onLeave: () -> Un } } +/** The code screen's model: the same actions for a code sent to a phone or to an email address. */ +private fun MfaState.CodeEntry.toModel(onIntent: (MfaIntent) -> Unit, cancel: () -> Unit): MfaFactorModel { + val submit = { code: String -> onIntent(MfaIntent.SubmitCode(code)) } + val resend = { onIntent(MfaIntent.Resend) } + val switchFactor = { onIntent(MfaIntent.ChooseAnotherFactor) } + return when (val factor = factor) { + is MfaFactor.Phone -> PhoneChallengeModel( + phoneNumber = factor.maskedNumber, + delivery = factor.delivery.toDelivery(), + isWrongCode = isWrongCode, + isVerifying = phase == CodePhase.Verifying, + isResending = phase == CodePhase.Resending, + canResend = canResend, + resendAvailableIn = resendSecondsLeft, + canSwitch = round.canSwitch, + submit = submit, + resend = resend, + switchFactor = switchFactor, + cancel = cancel, + ) + + is MfaFactor.Email -> EmailChallengeModel( + address = factor.address, + isWrongCode = isWrongCode, + isVerifying = phase == CodePhase.Verifying, + isResending = phase == CodePhase.Resending, + canResend = canResend, + resendAvailableIn = resendSecondsLeft, + canSwitch = round.canSwitch, + submit = submit, + resend = resend, + switchFactor = switchFactor, + cancel = cancel, + ) + } +} + private fun listModel( round: MfaRound, pending: MfaFactor?, @@ -100,12 +126,14 @@ private fun MfaFactor.row() = AuthenticatorListModel.Row( title = when (this) { is MfaFactor.Push -> deviceName is MfaFactor.Phone -> maskedNumber + is MfaFactor.Email -> address }, subtitle = null, icon = when (this) { is MfaFactor.Push -> R.drawable.ic_device is MfaFactor.Phone -> if (delivery == PhoneDeliveryMethod.VOICE) R.drawable.ic_mfa_voice else R.drawable.ic_mfa_sms + is MfaFactor.Email -> R.drawable.ic_mfa_email }, delivery = (this as? MfaFactor.Phone)?.delivery?.toDelivery(), ) @@ -114,16 +142,18 @@ private val MfaFactor.factorId: MfaFactorId get() = when (this) { is MfaFactor.Push -> MfaFactorId.Push is MfaFactor.Phone -> MfaFactorId.Phone + is MfaFactor.Email -> MfaFactorId.Email } /** - * One row per device, and per way a phone is reached: the server's handle for the device tells two phones with push - * apart, and a phone that can be texted and called is two rows. + * One row per device, per way a phone is reached, and per email address: the server's handle for the device tells two + * phones with push apart, and a phone that can be texted and called is two rows. */ private val MfaFactor.rowId: String get() = when (this) { is MfaFactor.Push -> "${MfaFactorId.Push.value}:$index" is MfaFactor.Phone -> "${MfaFactorId.Phone.value}:$index:${delivery.name}" + is MfaFactor.Email -> "${MfaFactorId.Email.value}:$index" } private fun PhoneDeliveryMethod.toDelivery() = when (this) { diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/screens/MfaStandardScreens.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/screens/MfaStandardScreens.kt index e11063b..fe8cc88 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/screens/MfaStandardScreens.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/screens/MfaStandardScreens.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.semantics.liveRegion import androidx.compose.ui.semantics.semantics import com.auth0.universalcomponents.feature.mfachallenge.AuthenticatorListHeader import com.auth0.universalcomponents.feature.mfachallenge.AuthenticatorListModel +import com.auth0.universalcomponents.feature.mfachallenge.EmailChallengeModel import com.auth0.universalcomponents.feature.mfachallenge.ErrorModel import com.auth0.universalcomponents.feature.mfachallenge.LoadingModel import com.auth0.universalcomponents.feature.mfachallenge.MfaError @@ -181,16 +182,118 @@ internal fun MfaPhoneChallengeScreen( messages: ResolvedMfaMessages, modifier: Modifier = Modifier, ) { - val dimensions = Auth0Theme.dimensions val words = messages.phoneChallenge + MfaCodeEntry( + words = CodeEntryWords( + title = words.title, + subtitle = when (model.delivery) { + PhoneDelivery.Text -> words.description(model.phoneNumber) + PhoneDelivery.Voice -> words.descriptionVoice(model.phoneNumber) + }, + codeLabel = words.codeLabel, + verifyButton = words.verifyButton, + wrongCode = words.wrongCode, + resendButton = words.resend.button, + resendCooldown = words.resend.cooldown, + verifyAnotherWay = words.verifyAnotherWay, + ), + controls = CodeEntryControls( + isWrongCode = model.isWrongCode, + isVerifying = model.isVerifying, + isResending = model.isResending, + canResend = model.canResend, + resendAvailableIn = model.resendAvailableIn, + canSwitch = model.canSwitch, + submit = model.submit, + resend = model.resend, + switchFactor = model.switchFactor, + ), + modifier = modifier, + ) +} + +/** + * Typing the code that went to [EmailChallengeModel.address]. It follows the phone code screen's rules; when the server + * gave no address, the line under the title says the code went by email without naming it. + */ +@Composable +internal fun MfaEmailChallengeScreen( + model: EmailChallengeModel, + messages: ResolvedMfaMessages, + modifier: Modifier = Modifier, +) { + val words = messages.emailChallenge + MfaCodeEntry( + words = CodeEntryWords( + title = words.title, + subtitle = if (model.address.isBlank()) words.descriptionUnnamed else words.description(model.address), + codeLabel = words.codeLabel, + verifyButton = words.verifyButton, + wrongCode = words.wrongCode, + resendButton = words.resend.button, + resendCooldown = words.resend.cooldown, + verifyAnotherWay = words.verifyAnotherWay, + ), + controls = CodeEntryControls( + isWrongCode = model.isWrongCode, + isVerifying = model.isVerifying, + isResending = model.isResending, + canResend = model.canResend, + resendAvailableIn = model.resendAvailableIn, + canSwitch = model.canSwitch, + submit = model.submit, + resend = model.resend, + switchFactor = model.switchFactor, + ), + modifier = modifier, + ) +} + +/** The words of a code screen; each factor's screen fills them from its own messages. */ +private class CodeEntryWords( + val title: String, + val subtitle: String, + val codeLabel: String, + val verifyButton: String, + val wrongCode: String, + val resendButton: String, + val resendCooldown: (seconds: Int) -> String, + val verifyAnotherWay: String, +) + +/** What a code screen shows and does, whichever factor's code it takes. */ +private class CodeEntryControls( + val isWrongCode: Boolean, + val isVerifying: Boolean, + val isResending: Boolean, + val canResend: Boolean, + val resendAvailableIn: Int, + val canSwitch: Boolean, + val submit: (code: String) -> Unit, + val resend: () -> Unit, + val switchFactor: () -> Unit, +) + +/** + * The body every code screen shares: the six boxes, a wrong-code line, Verify, and Resend and "Verify another way" when + * [CodeEntryControls.canResend] and [CodeEntryControls.canSwitch] allow them. The title's second line turns off the + * font's contextual alternates, since it may show a hidden number or address (`*********0128`, `s***@example.com`). + */ +@Composable +private fun MfaCodeEntry( + words: CodeEntryWords, + controls: CodeEntryControls, + modifier: Modifier = Modifier, +) { + val dimensions = Auth0Theme.dimensions var code by rememberSaveable { mutableStateOf("") } // Hidden once the user types after a refused code, shown again by the next Verify. Kept across a rotation, so a // line the user already dismissed does not come back. var hideError by rememberSaveable { mutableStateOf(false) } val focusRequester = remember { FocusRequester() } LaunchedEffect(Unit) { focusRequester.requestFocus() } - val isBusy = model.isVerifying || model.isResending - val showsError = model.isWrongCode && !hideError + val isBusy = controls.isVerifying || controls.isResending + val showsError = controls.isWrongCode && !hideError val maxWidth = Auth0Theme.sizes.otpFieldWidth * CODE_LENGTH + dimensions.spacingXs * (CODE_LENGTH - 1) FlowStepLayout( @@ -198,10 +301,7 @@ internal fun MfaPhoneChallengeScreen( pageDescription = { FlowScreenHeader( title = words.title, - subtitle = when (model.delivery) { - PhoneDelivery.Text -> words.description(model.phoneNumber) - PhoneDelivery.Voice -> words.descriptionVoice(model.phoneNumber) - }, + subtitle = words.subtitle, subtitleFontFeatures = NO_CONTEXTUAL_ALTERNATES, ) }, @@ -240,31 +340,31 @@ internal fun MfaPhoneChallengeScreen( title = words.verifyButton, onClick = { hideError = false - model.submit(code) + controls.submit(code) }, - isLoading = model.isVerifying, + isLoading = controls.isVerifying, enabled = code.length == CODE_LENGTH && !isBusy, ) }, - secondaryAction = if (model.canResend || model.canSwitch) { + secondaryAction = if (controls.canResend || controls.canSwitch) { { Column(verticalArrangement = Arrangement.spacedBy(dimensions.spacingMd)) { - if (model.canResend) { + if (controls.canResend) { FlowTextButton( - title = if (model.resendAvailableIn > 0) { - words.resend.cooldown(model.resendAvailableIn) + title = if (controls.resendAvailableIn > 0) { + words.resendCooldown(controls.resendAvailableIn) } else { - words.resend.button + words.resendButton }, - onClick = { model.resend() }, - enabled = model.resendAvailableIn == 0 && !isBusy, + onClick = { controls.resend() }, + enabled = controls.resendAvailableIn == 0 && !isBusy, style = Auth0Theme.typography.body, ) } - if (model.canSwitch) { + if (controls.canSwitch) { FlowTextButton( title = words.verifyAnotherWay, - onClick = { model.switchFactor() }, + onClick = { controls.switchFactor() }, enabled = !isBusy, ) } @@ -349,7 +449,8 @@ private fun MfaWaitingForApproval(caption: String, device: String) { /** * What a row says. A named device leads, with the factor's name under it; a device with no name shows the factor's name * with its description under it. A phone leads with how its code is sent, with its number under it (left out when the - * server hid every digit). A factor this version has no words for shows just its own title. + * server hid every digit); an email leads with its name, with its address under it. A factor this version has no words + * for shows just its own title. */ private fun AuthenticatorListModel.Row.words(messages: ResolvedMfaMessages): ResolvedMfaMessages.Message { if (factor == MfaFactorId.Phone && delivery != null) { @@ -357,6 +458,11 @@ private fun AuthenticatorListModel.Row.words(messages: ResolvedMfaMessages): Res val label = if (delivery == PhoneDelivery.Voice) phone.rowVoice(title) else phone.rowText(title) return ResolvedMfaMessages.Message(label, if (title.hasDigits()) title.forDisplay() else "") } + if (factor == MfaFactorId.Email) { + // The factor's name leads, with the address under it; with no address, its description takes that place. + val kind = messages.emailChallenge.row + return if (title.isBlank()) kind else ResolvedMfaMessages.Message(kind.title, title) + } if (factor != MfaFactorId.Push) return ResolvedMfaMessages.Message(title, subtitle.orEmpty()) val kind = messages.pushChallenge.row return if (title.isBlank()) kind else ResolvedMfaMessages.Message(title, kind.title) diff --git a/universal_components/src/main/res/drawable/ic_mfa_email.xml b/universal_components/src/main/res/drawable/ic_mfa_email.xml new file mode 100644 index 0000000..108d48a --- /dev/null +++ b/universal_components/src/main/res/drawable/ic_mfa_email.xml @@ -0,0 +1,18 @@ + + + + diff --git a/universal_components/src/main/res/values/strings.xml b/universal_components/src/main/res/values/strings.xml index 8c7e187..a6e2a2d 100644 --- a/universal_components/src/main/res/values/strings.xml +++ b/universal_components/src/main/res/values/strings.xml @@ -174,6 +174,17 @@ Verify another way SMS OTP Voice OTP + Email OTP + Get a code by email. + Enter the 6-digit code + We’ve sent a code to %1$s + We’ve sent you a code by email. + One-Time Passcode + Verify + Invalid code. Please check the code and try again. + Resend code + Resend in %1$d sec… + Verify another way Start over Close Verification cancelled. diff --git a/universal_components/src/test/java/com/auth0/universalcomponents/feature/embedded/data/repository/EmbeddedLoginRepositoryImplTest.kt b/universal_components/src/test/java/com/auth0/universalcomponents/feature/embedded/data/repository/EmbeddedLoginRepositoryImplTest.kt index 8ad0fd7..87d08fd 100644 --- a/universal_components/src/test/java/com/auth0/universalcomponents/feature/embedded/data/repository/EmbeddedLoginRepositoryImplTest.kt +++ b/universal_components/src/test/java/com/auth0/universalcomponents/feature/embedded/data/repository/EmbeddedLoginRepositoryImplTest.kt @@ -327,7 +327,7 @@ class EmbeddedLoginRepositoryImplTest { } @Test - fun `verifyOtp - second factors offered - fails as MFA required, with only the factors these screens drive, push first and each kind in the server's order`() = runTest { + fun `verifyOtp - second factors offered - fails as MFA required, with only the factors these screens drive, listed push, text, voice, email`() = runTest { every { client.verifyOtp(CODE, OtpType.OOB) } returns step( challengeEmail(at = 0), sdkInstance( @@ -350,6 +350,7 @@ class EmbeddedLoginRepositoryImplTest { MfaFactor.Push(3, OTHER_DEVICE), MfaFactor.Phone(1, MASKED_PHONE, PhoneDeliveryMethod.TEXT), MfaFactor.Phone(1, MASKED_PHONE, PhoneDeliveryMethod.VOICE), + MfaFactor.Email(0, EMAIL), ), ), ) @@ -360,10 +361,9 @@ class EmbeddedLoginRepositoryImplTest { listOf( verifyOtp(OtpChannel.EMAIL, MASKED_EMAIL), verifyOtp(OtpChannel.SMS, null), - // Second factors other than push and a phone code aren't supported yet. + // Second factors other than push, a phone code and an email code aren't supported yet. verifyOtp(OtpChannel.TOTP, null), NextAction.VerifyRecoveryCode, - challengeEmail(at = 0), ).forEach { nextAction -> every { client.verifyOtp(CODE, OtpType.OOB) } returns step(nextAction) diff --git a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/MfaMessagesTest.kt b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/MfaMessagesTest.kt index 386298f..425ade0 100644 --- a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/MfaMessagesTest.kt +++ b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/MfaMessagesTest.kt @@ -177,6 +177,44 @@ class MfaMessagesTest { assertThat(words.verifyAnotherWay).isEqualTo(default(R.string.auth0_mfa_phone_challenge_verify_another_way)) } + @Test + fun `email challenge - nothing set - every word is the string resource's, with the address filled in`() { + val words = MfaMessages().resolve(resources).emailChallenge + + assertThat(words.title).isEqualTo(default(R.string.auth0_mfa_email_challenge_title)) + assertThat(words.description("j***@example.com")) + .isEqualTo(default(R.string.auth0_mfa_email_challenge_description, "j***@example.com")) + assertThat(words.descriptionUnnamed) + .isEqualTo(default(R.string.auth0_mfa_email_challenge_description_unnamed)) + assertThat(words.codeLabel).isEqualTo(default(R.string.auth0_mfa_email_challenge_code_label)) + assertThat(words.verifyButton).isEqualTo(default(R.string.auth0_mfa_email_challenge_verify_button)) + assertThat(words.wrongCode).isEqualTo(default(R.string.auth0_mfa_email_challenge_wrong_code)) + assertThat(words.row.title).isEqualTo(default(R.string.auth0_mfa_email_challenge_row_title)) + assertThat(words.row.description).isEqualTo(default(R.string.auth0_mfa_email_challenge_row_description)) + assertThat(words.resend.button).isEqualTo(default(R.string.auth0_mfa_email_challenge_resend_button)) + assertThat(words.resend.cooldown(30)).isEqualTo(default(R.string.auth0_mfa_email_challenge_resend_cooldown, 30)) + } + + @Test + fun `email challenge - some set - replaces those, the others keep their defaults`() { + val words = MfaMessages { + emailChallenge { + title = "Check your inbox" + description = { address -> "Mailed to $address" } + row { title = "Email" } + resend { cooldown = { seconds -> "Wait $seconds" } } + } + }.resolve(resources).emailChallenge + + assertThat(words.title).isEqualTo("Check your inbox") + assertThat(words.description("j***@example.com")).isEqualTo("Mailed to j***@example.com") + assertThat(words.row.title).isEqualTo("Email") + assertThat(words.row.description).isEqualTo(default(R.string.auth0_mfa_email_challenge_row_description)) + assertThat(words.resend.cooldown(5)).isEqualTo("Wait 5") + assertThat(words.resend.button).isEqualTo(default(R.string.auth0_mfa_email_challenge_resend_button)) + assertThat(words.verifyAnotherWay).isEqualTo(default(R.string.auth0_mfa_email_challenge_verify_another_way)) + } + @Test fun `a word with a percent sign - is read as it is, never formatted`() { val sure = mockk() diff --git a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/MfaScreensTest.kt b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/MfaScreensTest.kt index 6394600..fd89d35 100644 --- a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/MfaScreensTest.kt +++ b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/MfaScreensTest.kt @@ -31,6 +31,20 @@ class MfaScreensTest { assertThat(byId.factors.keys).isEqualTo(byShortcut.factors.keys) } + @Test + fun `typed shortcuts - phone and email - are kept under their own ids`() { + val phone = MfaScreens { phoneChallenge { _, _ -> } } + val email = MfaScreens { emailChallenge { _, _ -> } } + val both = MfaScreens { + phoneChallenge { _, _ -> } + emailChallenge { _, _ -> } + } + + assertThat(phone.factors.keys).containsExactly(MfaFactorId.Phone) + assertThat(email.factors.keys).containsExactly(MfaFactorId.Email) + assertThat(both.factors.keys).containsExactly(MfaFactorId.Phone, MfaFactorId.Email) + } + @Test fun `replace - several screens - are all kept`() { val screens = MfaScreens { diff --git a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/MfaStepTest.kt b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/MfaStepTest.kt index b1634d7..c6ae201 100644 --- a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/MfaStepTest.kt +++ b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/MfaStepTest.kt @@ -31,6 +31,28 @@ class MfaStepTest { assertThat(MfaFactorId("push")).isEqualTo(MfaFactorId.Push) } + @Test + fun `factor id - email - is named email, and compares by its value`() { + assertThat(MfaFactorId.Email.value).isEqualTo("email") + assertThat(MfaFactorId("email")).isEqualTo(MfaFactorId.Email) + assertThat(MfaFactorId.Email).isNotEqualTo(MfaFactorId.Phone) + } + + @Test + fun `email model - built for a preview or a test - needs only the address, and its actions are safe to call`() { + val email = EmailChallengeModel(address = "j***@example.com") + + assertThat(email.isWrongCode).isFalse() + assertThat(email.canResend).isFalse() + assertThat(email.canSwitch).isFalse() + assertThat(email.resendAvailableIn).isEqualTo(0) + assertThat(MfaFactorStep(MfaFactorId.Email, email).round).isEqualTo(1) + email.submit("123456") + email.resend() + email.switchFactor() + email.cancel() + } + @Test fun `stream - a result - is kept until the next flow clears it`() { val stream = MfaStepStream() @@ -46,6 +68,7 @@ class MfaStepTest { fun `constructors - the shortest form of each model - stays, so a field added later does not break a compiled app`() { // Only the models without a value-class parameter: Kotlin mangles the constructors of the others. PushChallengeModel::class.java.getConstructor(String::class.java) + EmailChallengeModel::class.java.getConstructor(String::class.java) AuthenticatorListModel::class.java.getConstructor(AuthenticatorListHeader::class.java, List::class.java) ErrorModel::class.java.getConstructor(MfaError::class.java) LoadingModel::class.java.getConstructor() diff --git a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImplTest.kt b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImplTest.kt index e401e0d..47bf1d0 100644 --- a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImplTest.kt +++ b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImplTest.kt @@ -316,8 +316,81 @@ class MfaRepositoryImplTest { // endregion - private fun verifyOtp(channel: OtpChannel): NextAction.VerifyOtp = - sdkInstance(OtpChannel::class.java to channel, String::class.java to SMS.maskedNumber) + // region email code + + @Test + fun `sendEmailCode - sent - emails that address and says the server still offers it`() = runTest { + every { client.challengeEmail(EMAIL.index) } returns + step(verifyOtp(OtpChannel.EMAIL, EMAIL.address), challengeEmail()) + + assertThat(repository.sendEmailCode(EMAIL)).isEqualTo(Result.Success(true)) + + verify(exactly = 1) { client.challengeEmail(EMAIL.index) } + } + + @Test + fun `sendEmailCode - this address is no longer offered - says resend isn't allowed`() = runTest { + every { client.challengeEmail(EMAIL.index) } returns step(verifyOtp(OtpChannel.EMAIL, EMAIL.address)) + assertThat(repository.sendEmailCode(EMAIL)).isEqualTo(Result.Success(false)) + + // Another address still on offer is not this one. + every { client.challengeEmail(EMAIL.index) } returns + step(verifyOtp(OtpChannel.EMAIL, EMAIL.address), challengeEmail(index = EMAIL.index + 1)) + assertThat(repository.sendEmailCode(EMAIL)).isEqualTo(Result.Success(false)) + } + + @Test + fun `sendEmailCode - nothing to verify it with, or it signed in - fails as unexpected`() = runTest { + every { client.challengeEmail(any()) } returns step(NextAction.VerifyRecoveryCode) + assertThat(repository.sendEmailCode(EMAIL).mfaError()).isInstanceOf(MfaError.Unexpected::class.java) + + every { client.challengeEmail(any()) } returns FakeRequestImpl(response = null) + assertThat(repository.sendEmailCode(EMAIL).mfaError()).isInstanceOf(MfaError.Unexpected::class.java) + } + + @Test + fun `sendEmailCode - the server refuses - fails in MFA's terms, with the SDK error as cause`() = runTest { + every { client.challengeEmail(any()) } returns failingWith(EmbeddedAuthError.TooManyAttempts) + + val error = repository.sendEmailCode(EMAIL).mfaError() + + assertThat(error).isInstanceOf(MfaError.TooManyAttempts::class.java) + assertThat(error.cause).isNotNull() + } + + @Test + fun `verifyCode - an email code, right - signs in the same way as a phone code`() = runTest { + val credentials = mockk() + every { client.verifyOtp("654321", OtpType.OOB) } returns FakeRequestImpl(response = credentials) + + assertThat(repository.verifyCode("654321")).isEqualTo(Result.Success(CodeStatus.Verified(credentials))) + } + + @Test + fun `an email in the menu - is listed after the phone ways, one factor per address`() = runTest { + val other = MfaFactor.Email(8, "w***@example.com") + every { client.verifyOtp(any(), any()) } returns step( + challengeEmail(), + challengePush(), + challengePhone(), + challengeEmail(other.index, other.address), + reason = Reason.NONE, + ) + + val status = repository.verifyCode("123456") + + assertThat(status).isEqualTo( + Result.Success(CodeStatus.AnotherFactor(listOf(PHONE, SMS, CALL, EMAIL, other))), + ) + } + + // endregion + + private fun verifyOtp(channel: OtpChannel, destination: String = SMS.maskedNumber): NextAction.VerifyOtp = + sdkInstance(OtpChannel::class.java to channel, String::class.java to destination) + + private fun challengeEmail(index: Int = EMAIL.index, address: String = EMAIL.address): NextAction.ChallengeEmail = + sdkInstance(Int::class.java to index, String::class.java to address) /** The phone [SMS] and [CALL] are made from, which the server offers both ways. */ private fun challengePhone(): NextAction.ChallengePhone = sdkInstance( @@ -340,6 +413,7 @@ class MfaRepositoryImplTest { val PHONE = MfaFactor.Push(2, "Jane's phone") val SMS = MfaFactor.Phone(4, "***9239", PhoneDeliveryMethod.TEXT) val CALL = SMS.copy(delivery = PhoneDeliveryMethod.VOICE) + val EMAIL = MfaFactor.Email(6, "j***@example.com") const val POLL_MS = 3_000 const val SLOWER_MS = 5_000 } diff --git a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaEngineTest.kt b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaEngineTest.kt index 447744f..cb3cd1e 100644 --- a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaEngineTest.kt +++ b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaEngineTest.kt @@ -46,6 +46,7 @@ class MfaEngineTest { coEvery { repository.sendPush(any()) } returns Result.Success(PENDING) coEvery { repository.checkPushStatus() } returns Result.Success(PENDING) coEvery { repository.sendPhoneCode(any()) } returns Result.Success(true) + coEvery { repository.sendEmailCode(any()) } returns Result.Success(true) } @After @@ -433,24 +434,24 @@ class MfaEngineTest { @Test fun `one phone - the code goes out first, then its screen shows with the resend wait running`() = runTest { - val engine = startedWithPhone() + val engine = startedWithCode() assertThat(steps.first()).isEqualTo(MfaState.SendingChallenge(BY_SMS, SMS)) - assertThat((engine.state.value as MfaState.PhoneCode).phase).isEqualTo(PhoneCodePhase.Ready) + assertThat((engine.state.value as MfaState.CodeEntry).phase).isEqualTo(CodePhase.Ready) coVerify(exactly = 1) { repository.sendPhoneCode(SMS) } waitFor(ONE_SECOND) - assertThat((engine.state.value as MfaState.PhoneCode).resendSecondsLeft).isEqualTo(COOLDOWN - 1) + assertThat((engine.state.value as MfaState.CodeEntry).resendSecondsLeft).isEqualTo(COOLDOWN - 1) } @Test fun `a wrong code - stays on the screen, and the user can type another`() = runTest { coEvery { repository.verifyCode("111111") } returns Result.Success(CodeStatus.WrongCode) - val engine = startedWithPhone() + val engine = startedWithCode() engine.onIntent(MfaIntent.SubmitCode("111111")) runCurrent() - assertThat((engine.state.value as MfaState.PhoneCode).isWrongCode).isTrue() + assertThat((engine.state.value as MfaState.CodeEntry).isWrongCode).isTrue() assertThat(effects).isEmpty() } @@ -458,7 +459,7 @@ class MfaEngineTest { fun `the right code - tells the host the user is signed in`() = runTest { val credentials = mockk() coEvery { repository.verifyCode("123456") } returns Result.Success(CodeStatus.Verified(credentials)) - val engine = startedWithPhone() + val engine = startedWithCode() engine.onIntent(MfaIntent.SubmitCode("123456")) runCurrent() @@ -470,7 +471,7 @@ class MfaEngineTest { fun `an expired code - ends MFA with the error`() = runTest { val expired = MfaError.TokenExpired() coEvery { repository.verifyCode(any()) } returns Result.Error(expired) - val engine = startedWithPhone() + val engine = startedWithCode() engine.onIntent(MfaIntent.SubmitCode("123456")) runCurrent() @@ -481,14 +482,79 @@ class MfaEngineTest { @Test fun `resend - after the wait - sends the code again and starts a new wait`() = runTest { - val engine = startedWithPhone() + val engine = startedWithCode() waitFor(COOLDOWN * ONE_SECOND) engine.onIntent(MfaIntent.Resend) runCurrent() coVerify(exactly = 2) { repository.sendPhoneCode(SMS) } - assertThat((engine.state.value as MfaState.PhoneCode).resendSecondsLeft).isEqualTo(COOLDOWN) + assertThat((engine.state.value as MfaState.CodeEntry).resendSecondsLeft).isEqualTo(COOLDOWN) + } + + // endregion + + // region email code + + @Test + fun `one email - the code goes out first, then its screen shows with the resend wait running`() = runTest { + val engine = startedWithCode(EMAIL) + + assertThat(steps.first()).isEqualTo(MfaState.SendingChallenge(BY_EMAIL, EMAIL)) + assertThat((engine.state.value as MfaState.CodeEntry).phase).isEqualTo(CodePhase.Ready) + coVerify(exactly = 1) { repository.sendEmailCode(EMAIL) } + coVerify(exactly = 0) { repository.sendPhoneCode(any()) } + waitFor(ONE_SECOND) + assertThat((engine.state.value as MfaState.CodeEntry).resendSecondsLeft).isEqualTo(COOLDOWN - 1) + } + + @Test + fun `an email code - the right one - tells the host the user is signed in`() = runTest { + val credentials = mockk() + coEvery { repository.verifyCode("123456") } returns Result.Success(CodeStatus.Verified(credentials)) + val engine = startedWithCode(EMAIL) + + engine.onIntent(MfaIntent.SubmitCode("123456")) + runCurrent() + + assertThat(effects).containsExactly(MfaEffect.Authenticated(credentials)) + } + + @Test + fun `an email code - a wrong one - stays on the screen, and the user can type another`() = runTest { + coEvery { repository.verifyCode("111111") } returns Result.Success(CodeStatus.WrongCode) + val engine = startedWithCode(EMAIL) + + engine.onIntent(MfaIntent.SubmitCode("111111")) + runCurrent() + + assertThat((engine.state.value as MfaState.CodeEntry).isWrongCode).isTrue() + assertThat(effects).isEmpty() + } + + @Test + fun `an email code - sending it fails - ends MFA with the error, and checks nothing`() = runTest { + val error = MfaError.Network() + coEvery { repository.sendEmailCode(any()) } returns Result.Error(error) + + val engine = startedWithCode(EMAIL) + + assertThat(effects).containsExactly(MfaEffect.Failed(error)) + assertThat(engine.isRunning).isFalse() + coVerify(exactly = 0) { repository.verifyCode(any()) } + } + + @Test + fun `an email code - resend after the wait - sends the code to the same address again`() = runTest { + val engine = startedWithCode(EMAIL) + waitFor(COOLDOWN * ONE_SECOND) + + engine.onIntent(MfaIntent.Resend) + runCurrent() + + coVerify(exactly = 2) { repository.sendEmailCode(EMAIL) } + coVerify(exactly = 0) { repository.sendPhoneCode(any()) } + assertThat((engine.state.value as MfaState.CodeEntry).resendSecondsLeft).isEqualTo(COOLDOWN) } // endregion @@ -516,10 +582,10 @@ class MfaEngineTest { return engine } - /** Starts MFA with a single phone, so its code goes out at once. */ - private fun TestScope.startedWithPhone(): MfaEngine { + /** Starts MFA with a single phone (or [factor]), so its code goes out at once. */ + private fun TestScope.startedWithCode(factor: MfaFactor.Code = SMS): MfaEngine { val engine = MfaEngine( - reducer = MfaReducer(listOf(SMS)), + reducer = MfaReducer(listOf(factor)), repository = repository, hostScope = backgroundScope, onEffect = { effects += it }, @@ -554,6 +620,8 @@ class MfaEngineTest { val TWO = MfaRound(1, listOf(PHONE, TABLET)) val SMS = MfaFactor.Phone(3, "***9239", PhoneDeliveryMethod.TEXT) val BY_SMS = MfaRound(1, listOf(SMS)) + val EMAIL = MfaFactor.Email(5, "j***@example.com") + val BY_EMAIL = MfaRound(1, listOf(EMAIL)) const val POLL = 3_000L const val SLOWER = 5_000L val PENDING = PushStatus.Pending(POLL, canResend = true) diff --git a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaReducerTest.kt b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaReducerTest.kt index 88fb904..6a6aade 100644 --- a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaReducerTest.kt +++ b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaReducerTest.kt @@ -15,6 +15,8 @@ class MfaReducerTest { private val twoDevices = MfaReducer(listOf(PHONE, TABLET)) private val smsOnly = MfaReducer(listOf(SMS)) private val smsOrPush = MfaReducer(listOf(SMS, PHONE)) + private val emailOnly = MfaReducer(listOf(EMAIL)) + private val emailOrPush = MfaReducer(listOf(EMAIL, PHONE)) // region start @@ -537,7 +539,7 @@ class MfaReducerTest { @Test fun `phone code sent - shows the code screen and starts the resend wait`() { - val update = smsOnly.handleOutcome(MfaState.SendingChallenge(BY_SMS, SMS), MfaOutcome.PhoneCodeSent(true)) + val update = smsOnly.handleOutcome(MfaState.SendingChallenge(BY_SMS, SMS), MfaOutcome.CodeSent(true)) assertThat(update.state).isEqualTo(codeReady(BY_SMS)) assertThat(update.commands).containsExactly(MfaCommand.CountDownResend) @@ -547,14 +549,14 @@ class MfaReducerTest { fun `phone code sent - after the user left that screen - is dropped`() { val list = MfaState.ChooseFactor(SMS_OR_PUSH) - assertThat(smsOrPush.handleOutcome(list, MfaOutcome.PhoneCodeSent(true))).isEqualTo(MfaUpdate(list)) + assertThat(smsOrPush.handleOutcome(list, MfaOutcome.CodeSent(true))).isEqualTo(MfaUpdate(list)) } @Test fun `phone code send fails - the first one, or a resend - ends MFA, and a stale one is dropped`() { val sending = smsOnly.handleOutcome(MfaState.SendingChallenge(BY_SMS, SMS), MfaOutcome.SendFailed(NETWORK)) val resending = smsOnly.handleOutcome( - codeReady(BY_SMS).copy(phase = PhoneCodePhase.Resending), + codeReady(BY_SMS).copy(phase = CodePhase.Resending), MfaOutcome.SendFailed(NETWORK), ) val stale = smsOnly.handleOutcome(codeReady(BY_SMS), MfaOutcome.SendFailed(NETWORK)) @@ -568,14 +570,25 @@ class MfaReducerTest { fun `submit - a typed code - is checked, and the screen waits`() { val update = smsOnly.handleIntent(codeReady(BY_SMS), MfaIntent.SubmitCode("123456")) - assertThat(update.state).isEqualTo(codeReady(BY_SMS).copy(phase = PhoneCodePhase.Verifying)) + assertThat(update.state).isEqualTo(codeReady(BY_SMS).copy(phase = CodePhase.Verifying)) assertThat(update.commands).containsExactly(MfaCommand.VerifyCode("123456")) } + @Test + fun `submit - a code that is not whole - is never sent, for a phone or an email`() { + val phone = codeReady(BY_SMS) + val email = emailReady(BY_EMAIL) + + listOf("", " ", "12345", "1234567", "12345a", "12 456").forEach { code -> + assertThat(smsOnly.handleIntent(phone, MfaIntent.SubmitCode(code))).isEqualTo(MfaUpdate(phone)) + assertThat(emailOnly.handleIntent(email, MfaIntent.SubmitCode(code))).isEqualTo(MfaUpdate(email)) + } + } + @Test fun `submit - nothing typed, a check already on its way, or off the code screen - does nothing`() { - val verifying = codeReady(BY_SMS).copy(phase = PhoneCodePhase.Verifying) - val resending = codeReady(BY_SMS).copy(phase = PhoneCodePhase.Resending) + val verifying = codeReady(BY_SMS).copy(phase = CodePhase.Verifying) + val resending = codeReady(BY_SMS).copy(phase = CodePhase.Resending) val list = MfaState.ChooseFactor(SMS_OR_PUSH) val ready = codeReady(BY_SMS) @@ -588,19 +601,19 @@ class MfaReducerTest { @Test fun `wrong code - stays on the screen with the line shown, and typing again clears it`() { - val verifying = codeReady(BY_SMS).copy(phase = PhoneCodePhase.Verifying) + val verifying = codeReady(BY_SMS).copy(phase = CodePhase.Verifying) val rejected = smsOnly.handleOutcome(verifying, MfaOutcome.CodeRejected) val again = smsOnly.handleIntent(rejected.state, MfaIntent.SubmitCode("654321")) assertThat(rejected.state).isEqualTo(codeReady(BY_SMS).copy(isWrongCode = true)) assertThat(rejected.effect).isNull() - assertThat((again.state as MfaState.PhoneCode).isWrongCode).isFalse() + assertThat((again.state as MfaState.CodeEntry).isWrongCode).isFalse() } @Test fun `code check fails - expired, too many tries, network - ends MFA with that error`() { - val verifying = codeReady(BY_SMS).copy(phase = PhoneCodePhase.Verifying) + val verifying = codeReady(BY_SMS).copy(phase = CodePhase.Verifying) val expired = MfaError.TokenExpired() val update = smsOnly.handleOutcome(verifying, MfaOutcome.CodeFailed(expired)) @@ -622,7 +635,7 @@ class MfaReducerTest { @Test fun `right code - signs in, from the code screen`() { val credentials = mockk() - val verifying = codeReady(BY_SMS).copy(phase = PhoneCodePhase.Verifying) + val verifying = codeReady(BY_SMS).copy(phase = CodePhase.Verifying) val update = smsOnly.handleOutcome(verifying, MfaOutcome.SignedIn(credentials)) @@ -631,7 +644,7 @@ class MfaReducerTest { @Test fun `right code - and the server asks for one more factor - starts the next round`() { - val verifying = codeReady(BY_SMS).copy(phase = PhoneCodePhase.Verifying) + val verifying = codeReady(BY_SMS).copy(phase = CodePhase.Verifying) val update = smsOnly.handleOutcome(verifying, MfaOutcome.AnotherFactor(listOf(PHONE))) @@ -643,7 +656,7 @@ class MfaReducerTest { val update = smsOnly.handleIntent(codeReady(BY_SMS, secondsLeft = 0), MfaIntent.Resend) assertThat(update.state).isEqualTo( - codeReady(BY_SMS, secondsLeft = 0).copy(phase = PhoneCodePhase.Resending, canResend = false), + codeReady(BY_SMS, secondsLeft = 0).copy(phase = CodePhase.Resending, canResend = false), ) assertThat(update.commands).containsExactly(MfaCommand.SendPhoneCode(SMS)) } @@ -652,7 +665,7 @@ class MfaReducerTest { fun `resend code - wait not over, not offered, or a check on its way - does nothing`() { val waitingOut = codeReady(BY_SMS) val notOffered = codeReady(BY_SMS, secondsLeft = 0, canResend = false) - val verifying = codeReady(BY_SMS, secondsLeft = 0).copy(phase = PhoneCodePhase.Verifying) + val verifying = codeReady(BY_SMS, secondsLeft = 0).copy(phase = CodePhase.Verifying) listOf(waitingOut, notOffered, verifying).forEach { state -> assertThat(smsOnly.handleIntent(state, MfaIntent.Resend)).isEqualTo(MfaUpdate(state)) @@ -661,9 +674,9 @@ class MfaReducerTest { @Test fun `resend code sent - ready again with a new wait, and the wrong-code line gone`() { - val resending = codeReady(BY_SMS, secondsLeft = 0).copy(phase = PhoneCodePhase.Resending, isWrongCode = true) + val resending = codeReady(BY_SMS, secondsLeft = 0).copy(phase = CodePhase.Resending, isWrongCode = true) - val update = smsOnly.handleOutcome(resending, MfaOutcome.PhoneCodeSent(canResend = false)) + val update = smsOnly.handleOutcome(resending, MfaOutcome.CodeSent(canResend = false)) assertThat(update.state).isEqualTo(codeReady(BY_SMS, canResend = false)) assertThat(update.commands).containsExactly(MfaCommand.CountDownResend) @@ -675,9 +688,9 @@ class MfaReducerTest { val last = smsOnly.handleSecondPassed(one.state) val done = smsOnly.handleSecondPassed(last.state) - assertThat((one.state as MfaState.PhoneCode).resendSecondsLeft).isEqualTo(1) + assertThat((one.state as MfaState.CodeEntry).resendSecondsLeft).isEqualTo(1) assertThat(one.commands).containsExactly(MfaCommand.CountDownResend) - assertThat((last.state as MfaState.PhoneCode).resendSecondsLeft).isEqualTo(0) + assertThat((last.state as MfaState.CodeEntry).resendSecondsLeft).isEqualTo(0) assertThat(last.commands).isEmpty() assertThat(done).isEqualTo(MfaUpdate(last.state)) } @@ -692,8 +705,8 @@ class MfaReducerTest { @Test fun `verify another way - with one factor, or while a code is being checked or sent - does nothing`() { val alone = codeReady(BY_SMS) - val verifying = codeReady(SMS_OR_PUSH).copy(phase = PhoneCodePhase.Verifying) - val resending = codeReady(SMS_OR_PUSH).copy(phase = PhoneCodePhase.Resending) + val verifying = codeReady(SMS_OR_PUSH).copy(phase = CodePhase.Verifying) + val resending = codeReady(SMS_OR_PUSH).copy(phase = CodePhase.Resending) listOf(alone, verifying, resending).forEach { state -> assertThat(smsOrPush.handleIntent(state, MfaIntent.ChooseAnotherFactor)).isEqualTo(MfaUpdate(state)) @@ -711,8 +724,8 @@ class MfaReducerTest { @Test fun `back - while a code is being checked or sent - is ignored, but Cancel still leaves`() { - val verifying = codeReady(BY_SMS).copy(phase = PhoneCodePhase.Verifying) - val resending = codeReady(BY_SMS).copy(phase = PhoneCodePhase.Resending) + val verifying = codeReady(BY_SMS).copy(phase = CodePhase.Verifying) + val resending = codeReady(BY_SMS).copy(phase = CodePhase.Resending) listOf(verifying, resending).forEach { state -> assertThat(smsOnly.handleIntent(state, MfaIntent.Back)).isEqualTo(MfaUpdate(state)) @@ -722,6 +735,85 @@ class MfaReducerTest { // endregion + // region email code + + @Test + fun `email - the only factor - sends its code first, with no screen of its own until it's out`() { + val update = emailOnly.start() + + assertThat(update.state).isEqualTo(MfaState.SendingChallenge(BY_EMAIL, EMAIL)) + assertThat(update.commands).containsExactly(MfaCommand.SendEmailCode(EMAIL)) + } + + @Test + fun `email - picked from the list - sends its code, with the list kept`() { + val update = emailOrPush.handleIntent(MfaState.ChooseFactor(EMAIL_OR_PUSH), MfaIntent.SelectFactor(EMAIL)) + + assertThat(update.state).isEqualTo(MfaState.SendingChallenge(EMAIL_OR_PUSH, EMAIL)) + assertThat(update.commands).containsExactly(MfaCommand.SendEmailCode(EMAIL)) + } + + @Test + fun `email code sent - shows the code screen for that address and starts the resend wait`() { + val update = emailOnly.handleOutcome(MfaState.SendingChallenge(BY_EMAIL, EMAIL), MfaOutcome.CodeSent(true)) + + assertThat(update.state).isEqualTo(emailReady(BY_EMAIL)) + assertThat(update.commands).containsExactly(MfaCommand.CountDownResend) + } + + @Test + fun `email code - typed, wrong, then right - follows the same rules as a phone code`() { + val submitted = emailOnly.handleIntent(emailReady(BY_EMAIL), MfaIntent.SubmitCode("123456")) + val rejected = emailOnly.handleOutcome(submitted.state, MfaOutcome.CodeRejected) + val credentials = mockk() + val signedIn = emailOnly.handleOutcome(submitted.state, MfaOutcome.SignedIn(credentials)) + + assertThat(submitted.state).isEqualTo(emailReady(BY_EMAIL).copy(phase = CodePhase.Verifying)) + assertThat(submitted.commands).containsExactly(MfaCommand.VerifyCode("123456")) + assertThat(rejected.state).isEqualTo(emailReady(BY_EMAIL).copy(isWrongCode = true)) + assertThat(signedIn.effect).isEqualTo(MfaEffect.Authenticated(credentials)) + } + + @Test + fun `email code - resend - sends the code to the same address again`() { + val update = emailOnly.handleIntent(emailReady(BY_EMAIL, secondsLeft = 0), MfaIntent.Resend) + + assertThat(update.state).isEqualTo( + emailReady(BY_EMAIL, secondsLeft = 0).copy(phase = CodePhase.Resending, canResend = false), + ) + assertThat(update.commands).containsExactly(MfaCommand.SendEmailCode(EMAIL)) + } + + @Test + fun `email code - verify another way and back - go to the list with a choice, else start the sign-in over`() { + val switched = emailOrPush.handleIntent(emailReady(EMAIL_OR_PUSH), MfaIntent.ChooseAnotherFactor) + val back = emailOrPush.handleIntent(emailReady(EMAIL_OR_PUSH), MfaIntent.Back) + val alone = emailOnly.handleIntent(emailReady(BY_EMAIL), MfaIntent.Back) + + assertThat(switched.state).isEqualTo(MfaState.ChooseFactor(EMAIL_OR_PUSH)) + assertThat(back.state).isEqualTo(MfaState.ChooseFactor(EMAIL_OR_PUSH)) + assertThat(alone.effect).isEqualTo(MfaEffect.Restart) + } + + @Test + fun `email code send fails - the first one, or a resend - ends MFA, and a stale one is dropped`() { + val sending = emailOnly.handleOutcome( + MfaState.SendingChallenge(BY_EMAIL, EMAIL), + MfaOutcome.SendFailed(NETWORK), + ) + val resending = emailOnly.handleOutcome( + emailReady(BY_EMAIL).copy(phase = CodePhase.Resending), + MfaOutcome.SendFailed(NETWORK), + ) + val stale = emailOnly.handleOutcome(emailReady(BY_EMAIL), MfaOutcome.SendFailed(NETWORK)) + + assertThat(sending.state).isEqualTo(MfaState.Failed(NETWORK)) + assertThat(resending.state).isEqualTo(MfaState.Failed(NETWORK)) + assertThat(stale).isEqualTo(MfaUpdate(emailReady(BY_EMAIL))) + } + + // endregion + private fun sent(pollMs: Long, canResend: Boolean = true) = MfaOutcome.PushSent(PushStatus.Pending(pollMs, canResend)) @@ -751,14 +843,18 @@ class MfaReducerTest { round: MfaRound, secondsLeft: Int = MfaReducer.RESEND_COOLDOWN_SECONDS, canResend: Boolean = true, - ) = MfaState.PhoneCode( + ) = MfaState.CodeEntry( round = round, factor = SMS, - phase = PhoneCodePhase.Ready, + phase = CodePhase.Ready, canResend = canResend, resendSecondsLeft = secondsLeft, ) + /** On the code screen for an email address right after the code went out. */ + private fun emailReady(round: MfaRound, secondsLeft: Int = MfaReducer.RESEND_COOLDOWN_SECONDS) = + codeReady(round, secondsLeft).copy(factor = EMAIL) + private fun checking(round: MfaRound, factor: MfaFactor.Push) = waiting(round, factor).copy(phase = PushPhase.Checking) @@ -770,6 +866,9 @@ class MfaReducerTest { val SMS = MfaFactor.Phone(3, "***9239", PhoneDeliveryMethod.TEXT) val BY_SMS = MfaRound(1, listOf(SMS)) val SMS_OR_PUSH = MfaRound(1, listOf(SMS, PHONE)) + val EMAIL = MfaFactor.Email(5, "j***@example.com") + val BY_EMAIL = MfaRound(1, listOf(EMAIL)) + val EMAIL_OR_PUSH = MfaRound(1, listOf(EMAIL, PHONE)) val NETWORK = MfaError.Network(null) val DENIED_CAUSE = IllegalStateException("denied") const val POLL = 3_000L diff --git a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaStepMapperTest.kt b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaStepMapperTest.kt index 6747354..8a674be 100644 --- a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaStepMapperTest.kt +++ b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaStepMapperTest.kt @@ -4,6 +4,7 @@ import com.auth0.android.embedded.authorize.PhoneDeliveryMethod import com.auth0.universalcomponents.R import com.auth0.universalcomponents.feature.mfachallenge.AuthenticatorListHeader import com.auth0.universalcomponents.feature.mfachallenge.AuthenticatorListModel +import com.auth0.universalcomponents.feature.mfachallenge.EmailChallengeModel import com.auth0.universalcomponents.feature.mfachallenge.MfaError import com.auth0.universalcomponents.feature.mfachallenge.MfaExit import com.auth0.universalcomponents.feature.mfachallenge.MfaFactorId @@ -120,8 +121,8 @@ class MfaStepMapperTest { @Test fun `phone code - being checked, or sent again - says so`() { - val verifying = codeReady(TWO_WITH_SMS).copy(phase = PhoneCodePhase.Verifying) - val resending = codeReady(TWO_WITH_SMS).copy(phase = PhoneCodePhase.Resending, canResend = false) + val verifying = codeReady(TWO_WITH_SMS).copy(phase = CodePhase.Verifying) + val resending = codeReady(TWO_WITH_SMS).copy(phase = CodePhase.Resending, canResend = false) val checked = (toStep(verifying) as MfaStep.Factor).step.model as PhoneChallengeModel val sent = (toStep(resending) as MfaStep.Factor).step.model as PhoneChallengeModel @@ -177,6 +178,80 @@ class MfaStepMapperTest { ).inOrder() } + @Test + fun `email code - the model carries the address, and the wait states`() { + val state = emailReady(TWO_WITH_EMAIL).copy(resendSecondsLeft = 42, isWrongCode = true) + + val factor = (toStep(state) as MfaStep.Factor).step + val model = factor.model as EmailChallengeModel + + assertThat(factor.factor).isEqualTo(MfaFactorId.Email) + assertThat(factor.round).isEqualTo(1) + assertThat(model.address).isEqualTo("j***@example.com") + assertThat(model.isWrongCode).isTrue() + assertThat(model.canResend).isTrue() + assertThat(model.resendAvailableIn).isEqualTo(42) + assertThat(model.canSwitch).isTrue() + assertThat(model.isVerifying).isFalse() + assertThat(model.isResending).isFalse() + } + + @Test + fun `email code - being checked, or sent again - says so`() { + val verifying = emailReady(TWO_WITH_EMAIL).copy(phase = CodePhase.Verifying) + val resending = emailReady(TWO_WITH_EMAIL).copy(phase = CodePhase.Resending, canResend = false) + + val checked = (toStep(verifying) as MfaStep.Factor).step.model as EmailChallengeModel + val sent = (toStep(resending) as MfaStep.Factor).step.model as EmailChallengeModel + + assertThat(checked.isVerifying).isTrue() + assertThat(sent.isResending).isTrue() + assertThat(sent.canResend).isFalse() + } + + @Test + fun `email code - the only factor - cannot switch`() { + val model = (toStep(emailReady(MfaRound(1, listOf(EMAIL)))) as MfaStep.Factor).step.model as EmailChallengeModel + + assertThat(model.canSwitch).isFalse() + } + + @Test + fun `email code - the taps - come back as intents`() { + val model = (toStep(emailReady(TWO_WITH_EMAIL)) as MfaStep.Factor).step.model as EmailChallengeModel + + model.submit("123456") + model.resend() + model.switchFactor() + model.cancel() + + assertThat(intents).containsExactly( + MfaIntent.SubmitCode("123456"), + MfaIntent.Resend, + MfaIntent.ChooseAnotherFactor, + MfaIntent.Cancel, + ).inOrder() + } + + @Test + fun `list - email addresses - are one row each, named by the address, with the mail icon`() { + val step = toStep(MfaState.ChooseFactor(MfaRound(1, listOf(EMAIL, OTHER_EMAIL)))) as MfaStep.AuthenticatorList + + assertThat(step.model.rows).containsExactly( + AuthenticatorListModel.Row("email:5", MfaFactorId.Email, "j***@example.com", null, R.drawable.ic_mfa_email), + AuthenticatorListModel.Row("email:6", MfaFactorId.Email, "w***@example.com", null, R.drawable.ic_mfa_email), + ).inOrder() + } + + @Test + fun `select - an email row - picks that address`() { + val step = toStep(MfaState.ChooseFactor(MfaRound(1, listOf(EMAIL, OTHER_EMAIL)))) as MfaStep.AuthenticatorList + + step.model.select("email:6") + + assertThat(intents).containsExactly(MfaIntent.SelectFactor(OTHER_EMAIL)) + } + @Test fun `failed - any error, including no usable factor - is the error step with Start over`() { val errors = listOf(MfaError.Rejected(), MfaError.NoEligibleFactors(), MfaError.Network()) @@ -246,14 +321,16 @@ class MfaStepMapperTest { // endregion - private fun codeReady(round: MfaRound) = MfaState.PhoneCode( + private fun codeReady(round: MfaRound) = MfaState.CodeEntry( round = round, factor = SMS, - phase = PhoneCodePhase.Ready, + phase = CodePhase.Ready, canResend = true, resendSecondsLeft = 0, ) + private fun emailReady(round: MfaRound) = codeReady(round).copy(factor = EMAIL) + private fun waiting(round: MfaRound, factor: MfaFactor.Push) = MfaState.PushWaiting( round = round, factor = factor, @@ -271,6 +348,9 @@ class MfaStepMapperTest { val SMS = MfaFactor.Phone(3, "***9239", PhoneDeliveryMethod.TEXT) val CALL = SMS.copy(delivery = PhoneDeliveryMethod.VOICE) val TWO_WITH_SMS = MfaRound(1, listOf(SMS, PHONE)) + val EMAIL = MfaFactor.Email(5, "j***@example.com") + val OTHER_EMAIL = MfaFactor.Email(6, "w***@example.com") + val TWO_WITH_EMAIL = MfaRound(1, listOf(EMAIL, PHONE)) const val POLL = 3_000L } } diff --git a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaStepStreamMirrorTest.kt b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaStepStreamMirrorTest.kt index 94c09c7..43bec31 100644 --- a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaStepStreamMirrorTest.kt +++ b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/MfaStepStreamMirrorTest.kt @@ -1,11 +1,16 @@ package com.auth0.universalcomponents.feature.mfachallenge.presentation +import com.auth0.android.embedded.authorize.PhoneDeliveryMethod import com.auth0.android.result.Credentials import com.auth0.universalcomponents.domain.network.Result +import com.auth0.universalcomponents.feature.mfachallenge.EmailChallengeModel import com.auth0.universalcomponents.feature.mfachallenge.MfaError +import com.auth0.universalcomponents.feature.mfachallenge.MfaFactorId import com.auth0.universalcomponents.feature.mfachallenge.MfaStep import com.auth0.universalcomponents.feature.mfachallenge.MfaStepStream +import com.auth0.universalcomponents.feature.mfachallenge.PhoneChallengeModel import com.auth0.universalcomponents.feature.mfachallenge.PushChallengeModel +import com.auth0.universalcomponents.feature.mfachallenge.domain.model.CodeStatus import com.auth0.universalcomponents.feature.mfachallenge.domain.model.MfaFactor import com.auth0.universalcomponents.feature.mfachallenge.domain.model.PushStatus import com.auth0.universalcomponents.feature.mfachallenge.domain.repository.MfaRepository @@ -130,7 +135,48 @@ class MfaStepStreamMirrorTest { assertThat(effects).containsExactly(MfaEffect.Authenticated(credentials)) } - private fun TestScope.started(vararg factors: MfaFactor.Push, select: MfaFactor.Push? = null): MfaEngine { + @Test + fun `phone code - the stream shows its model, and a code typed in the app's own screen signs in`() = runTest { + val credentials = mockk() + coEvery { repository.sendPhoneCode(SMS) } returns Result.Success(true) + coEvery { repository.verifyCode("123456") } returns Result.Success(CodeStatus.Verified(credentials)) + started(SMS) + + val step = (stream.step.value as MfaStep.Factor).step + val model = step.model as PhoneChallengeModel + assertThat(step.factor).isEqualTo(MfaFactorId.Phone) + assertThat(model.phoneNumber).isEqualTo("***9239") + assertThat(model.resendAvailableIn).isEqualTo(RESEND_SECONDS) + + model.submit("123456") + runCurrent() + + assertThat(effects).containsExactly(MfaEffect.Authenticated(credentials)) + } + + @Test + fun `email code - the stream shows its model, a wrong code and the countdown reach it`() = runTest { + coEvery { repository.sendEmailCode(EMAIL) } returns Result.Success(true) + coEvery { repository.verifyCode("111111") } returns Result.Success(CodeStatus.WrongCode) + started(EMAIL) + + val step = (stream.step.value as MfaStep.Factor).step + val model = step.model as EmailChallengeModel + assertThat(step.factor).isEqualTo(MfaFactorId.Email) + assertThat(model.address).isEqualTo("j***@example.com") + assertThat(model.isWrongCode).isFalse() + + model.submit("111111") + runCurrent() + advanceTimeBy(2_000) + runCurrent() + + val after = (stream.step.value as MfaStep.Factor).step.model as EmailChallengeModel + assertThat(after.isWrongCode).isTrue() + assertThat(after.resendAvailableIn).isEqualTo(RESEND_SECONDS - 2) + } + + private fun TestScope.started(vararg factors: MfaFactor, select: MfaFactor? = null): MfaEngine { val engine = MfaEngine( reducer = MfaReducer(factors.toList()), repository = repository, @@ -149,6 +195,8 @@ class MfaStepStreamMirrorTest { private companion object { val PHONE = MfaFactor.Push(0, "Jane's phone") val TABLET = MfaFactor.Push(1, "Jane's tablet") + val SMS = MfaFactor.Phone(3, "***9239", PhoneDeliveryMethod.TEXT) + val EMAIL = MfaFactor.Email(5, "j***@example.com") const val POLL = 3_000L const val RESEND_SECONDS = 60 } From 9ee67d4fe7760955452a75e6323eae4bf66c983a Mon Sep 17 00:00:00 2001 From: Manmohan Gupta Date: Fri, 13 Nov 2026 13:49:30 +0530 Subject: [PATCH 2/2] Harden MFA code entry: channel and resend checks, clear on Resend, lock while verifying Co-Authored-By: Claude --- .../feature/mfachallenge/MfaMessages.kt | 2 - .../data/mapper/MfaFactorMapper.kt | 11 ++-- .../data/repository/MfaRepositoryImpl.kt | 24 +++++-- .../screens/MfaStandardScreens.kt | 21 ++++-- .../data/repository/MfaRepositoryImplTest.kt | 55 ++++++++++++++-- .../presentation/screens/MfaEmailWordsTest.kt | 65 +++++++++++++++++++ 6 files changed, 157 insertions(+), 21 deletions(-) create mode 100644 universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/screens/MfaEmailWordsTest.kt diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaMessages.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaMessages.kt index d23a881..fcf1cd5 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaMessages.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/MfaMessages.kt @@ -16,8 +16,6 @@ package com.auth0.universalcomponents.feature.mfachallenge * errors { rejected { title = "Denied" } } * } * ``` - * - * The key paths are the same as on iOS. */ class MfaMessages private constructor( internal val authenticatorList: AuthenticatorList, diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/mapper/MfaFactorMapper.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/mapper/MfaFactorMapper.kt index d148efb..30d36a8 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/mapper/MfaFactorMapper.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/mapper/MfaFactorMapper.kt @@ -14,15 +14,18 @@ import com.auth0.universalcomponents.feature.mfachallenge.domain.model.MfaFactor internal fun List.toMfaFactors(): List = flatMap { action -> when (action) { is NextAction.ChallengePush -> listOf(MfaFactor.Push(action.index, action.name)) - is NextAction.ChallengePhone -> - action.deliveryMethods.ifEmpty { listOf(PhoneDeliveryMethod.TEXT) }.distinct().map { delivery -> - MfaFactor.Phone(action.index, action.identifier, delivery) - } + is NextAction.ChallengePhone -> action.ways.map { delivery -> + MfaFactor.Phone(action.index, action.identifier, delivery) + } is NextAction.ChallengeEmail -> listOf(MfaFactor.Email(action.index, action.identifier)) else -> emptyList() } }.sortedBy { it.listRank } +/** The ways the server can reach this phone: a text when it names none, as the SDK does. */ +internal val NextAction.ChallengePhone.ways: List + get() = deliveryMethods.ifEmpty { listOf(PhoneDeliveryMethod.TEXT) }.distinct() + /** Where a factor sits in the list: lower first. */ private val MfaFactor.listRank: Int get() = when (this) { diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImpl.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImpl.kt index 0d4fd67..d037a45 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImpl.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImpl.kt @@ -4,6 +4,7 @@ import com.auth0.android.embedded.EmbeddedAuthError import com.auth0.android.embedded.EmbeddedAuthError.InsufficientAuthorization.Reason import com.auth0.android.embedded.EmbeddedAuthException import com.auth0.android.embedded.authorize.NextAction +import com.auth0.android.embedded.authorize.OtpChannel import com.auth0.android.embedded.authorize.OtpType import com.auth0.universalcomponents.data.embeddedauth.EmbeddedAuthSteps import com.auth0.universalcomponents.data.embeddedauth.INVALID_CODE_REASONS @@ -12,6 +13,7 @@ import com.auth0.universalcomponents.domain.network.Result import com.auth0.universalcomponents.feature.mfachallenge.MfaError import com.auth0.universalcomponents.feature.mfachallenge.data.mapper.mfaError import com.auth0.universalcomponents.feature.mfachallenge.data.mapper.toMfaFactors +import com.auth0.universalcomponents.feature.mfachallenge.data.mapper.ways import com.auth0.universalcomponents.feature.mfachallenge.domain.model.CodeStatus import com.auth0.universalcomponents.feature.mfachallenge.domain.model.MfaFactor import com.auth0.universalcomponents.feature.mfachallenge.domain.model.PushStatus @@ -75,24 +77,28 @@ internal class MfaRepositoryImpl(private val steps: EmbeddedAuthSteps) : MfaRepo get() = (error as? EmbeddedAuthException)?.error == EmbeddedAuthError.AuthorizationRejected override suspend fun sendPhoneCode(factor: MfaFactor.Phone): Result = - steps.run { challengePhone(factor.index, factor.delivery) }.toCodeSent { actions -> - actions.any { it is NextAction.ChallengePhone } + steps.run { challengePhone(factor.index, factor.delivery) }.toCodeSent(PHONE_CHANNELS) { actions -> + // This phone, this way: another phone, or this one by another way, is not the code just sent. + actions.any { it is NextAction.ChallengePhone && it.index == factor.index && factor.delivery in it.ways } } override suspend fun sendEmailCode(factor: MfaFactor.Email): Result = - steps.run { challengeEmail(factor.index) }.toCodeSent { actions -> + steps.run { challengeEmail(factor.index) }.toCodeSent(EMAIL_CHANNELS) { actions -> actions.any { it is NextAction.ChallengeEmail && it.index == factor.index } } /** - * The code is out once the server asks for it to be verified. [offersAgain] reads from the answer's menu whether the - * server still offers to send it again, which differs for a phone and an email address. + * The code is out once the server asks for it to be verified on one of [channels]. A user with an authenticator app + * is asked for its code in every answer, so a request to verify on any other channel says nothing about this code. + * [offersAgain] reads from the answer's menu whether the server still offers to send it again, which differs for a + * phone and an email address. */ private fun StepAnswer.toCodeSent( + channels: Set, offersAgain: (List) -> Boolean, ): Result = when (this) { is StepAnswer.Next -> - if (actions.any { it is NextAction.VerifyOtp }) { + if (actions.any { it is NextAction.VerifyOtp && it.channel in channels }) { Result.Success(offersAgain(actions)) } else { unexpected("The code was sent without a way to verify it") @@ -139,5 +145,11 @@ internal class MfaRepositoryImpl(private val steps: EmbeddedAuthSteps) : MfaRepo private companion object { private const val TAG = "MfaRepository" + + /** Where the server asks for the code it sent to a phone, whether it was texted or spoken. */ + val PHONE_CHANNELS = setOf(OtpChannel.SMS, OtpChannel.VOICE) + + /** Where the server asks for the code it sent to an email address. */ + val EMAIL_CHANNELS = setOf(OtpChannel.EMAIL) } } diff --git a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/screens/MfaStandardScreens.kt b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/screens/MfaStandardScreens.kt index fe8cc88..c3ae284 100644 --- a/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/screens/MfaStandardScreens.kt +++ b/universal_components/src/main/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/screens/MfaStandardScreens.kt @@ -226,7 +226,7 @@ internal fun MfaEmailChallengeScreen( MfaCodeEntry( words = CodeEntryWords( title = words.title, - subtitle = if (model.address.isBlank()) words.descriptionUnnamed else words.description(model.address), + subtitle = words.subtitleFor(model.address), codeLabel = words.codeLabel, verifyButton = words.verifyButton, wrongCode = words.wrongCode, @@ -249,6 +249,10 @@ internal fun MfaEmailChallengeScreen( ) } +/** The line under the title: the address the code went to, or, when the server gave none, that it went by email. */ +internal fun ResolvedMfaMessages.EmailChallenge.subtitleFor(address: String): String = + if (address.isBlank()) descriptionUnnamed else description(address) + /** The words of a code screen; each factor's screen fills them from its own messages. */ private class CodeEntryWords( val title: String, @@ -314,9 +318,12 @@ private fun MfaCodeEntry( value = code, length = CODE_LENGTH, isError = showsError, + // The digits on show are the ones being checked, and a wrong-code answer should still be seen. onValueChange = { - code = it.filter(Char::isDigit).take(CODE_LENGTH) - hideError = true + if (!isBusy) { + code = it.filter(Char::isDigit).take(CODE_LENGTH) + hideError = true + } }, focusRequester = focusRequester, // The visible label is a separate node, so TalkBack would land on an unnamed edit box. @@ -356,7 +363,11 @@ private fun MfaCodeEntry( } else { words.resendButton }, - onClick = { controls.resend() }, + onClick = { + // The old code is no use once a new one is on its way. + code = "" + controls.resend() + }, enabled = controls.resendAvailableIn == 0 && !isBusy, style = Auth0Theme.typography.body, ) @@ -452,7 +463,7 @@ private fun MfaWaitingForApproval(caption: String, device: String) { * server hid every digit); an email leads with its name, with its address under it. A factor this version has no words * for shows just its own title. */ -private fun AuthenticatorListModel.Row.words(messages: ResolvedMfaMessages): ResolvedMfaMessages.Message { +internal fun AuthenticatorListModel.Row.words(messages: ResolvedMfaMessages): ResolvedMfaMessages.Message { if (factor == MfaFactorId.Phone && delivery != null) { val phone = messages.phoneChallenge val label = if (delivery == PhoneDelivery.Voice) phone.rowVoice(title) else phone.rowText(title) diff --git a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImplTest.kt b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImplTest.kt index 47bf1d0..94f14e9 100644 --- a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImplTest.kt +++ b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/data/repository/MfaRepositoryImplTest.kt @@ -242,6 +242,30 @@ class MfaRepositoryImplTest { assertThat(repository.sendPhoneCode(CALL)).isEqualTo(Result.Success(false)) } + @Test + fun `sendPhoneCode - this phone is no longer offered this way - says resend isn't allowed`() = runTest { + // Another phone still on offer is not this one. + every { client.challengePhone(SMS.index, PhoneDeliveryMethod.TEXT) } returns + step(verifyOtp(OtpChannel.SMS), challengePhone(index = SMS.index + 1)) + assertThat(repository.sendPhoneCode(SMS)).isEqualTo(Result.Success(false)) + + // Nor is this phone by another way: it can no longer be texted, though it can still be called. + every { client.challengePhone(SMS.index, PhoneDeliveryMethod.TEXT) } returns + step(verifyOtp(OtpChannel.SMS), challengePhone(ways = listOf(PhoneDeliveryMethod.VOICE))) + assertThat(repository.sendPhoneCode(SMS)).isEqualTo(Result.Success(false)) + } + + @Test + fun `sendPhoneCode - the server names no way - reads it as a text`() = runTest { + every { client.challengePhone(SMS.index, PhoneDeliveryMethod.TEXT) } returns + step(verifyOtp(OtpChannel.SMS), challengePhone(ways = emptyList())) + assertThat(repository.sendPhoneCode(SMS)).isEqualTo(Result.Success(true)) + + every { client.challengePhone(SMS.index, PhoneDeliveryMethod.VOICE) } returns + step(verifyOtp(OtpChannel.VOICE), challengePhone(ways = emptyList())) + assertThat(repository.sendPhoneCode(CALL)).isEqualTo(Result.Success(false)) + } + @Test fun `sendPhoneCode - nothing to verify it with, or it signed in - fails as unexpected`() = runTest { every { client.challengePhone(any(), any()) } returns step(NextAction.VerifyRecoveryCode) @@ -251,6 +275,16 @@ class MfaRepositoryImplTest { assertThat(repository.sendPhoneCode(SMS).mfaError()).isInstanceOf(MfaError.Unexpected::class.java) } + @Test + fun `sendPhoneCode - only another way of verifying is asked for - fails as unexpected`() = runTest { + // A user with an authenticator app is asked for its code in every answer: it doesn't show a code went out. + every { client.challengePhone(any(), any()) } returns step(verifyOtp(OtpChannel.TOTP), challengePhone()) + assertThat(repository.sendPhoneCode(SMS).mfaError()).isInstanceOf(MfaError.Unexpected::class.java) + + every { client.challengePhone(any(), any()) } returns step(verifyOtp(OtpChannel.EMAIL), challengePhone()) + assertThat(repository.sendPhoneCode(SMS).mfaError()).isInstanceOf(MfaError.Unexpected::class.java) + } + @Test fun `sendPhoneCode - the server refuses - fails in MFA's terms, with the SDK error as cause`() = runTest { every { client.challengePhone(any(), any()) } returns failingWith(EmbeddedAuthError.TooManyAttempts) @@ -348,6 +382,16 @@ class MfaRepositoryImplTest { assertThat(repository.sendEmailCode(EMAIL).mfaError()).isInstanceOf(MfaError.Unexpected::class.java) } + @Test + fun `sendEmailCode - only another way of verifying is asked for - fails as unexpected`() = runTest { + // A user with an authenticator app is asked for its code in every answer: it doesn't show a code went out. + every { client.challengeEmail(any()) } returns step(verifyOtp(OtpChannel.TOTP), challengeEmail()) + assertThat(repository.sendEmailCode(EMAIL).mfaError()).isInstanceOf(MfaError.Unexpected::class.java) + + every { client.challengeEmail(any()) } returns step(verifyOtp(OtpChannel.SMS), challengeEmail()) + assertThat(repository.sendEmailCode(EMAIL).mfaError()).isInstanceOf(MfaError.Unexpected::class.java) + } + @Test fun `sendEmailCode - the server refuses - fails in MFA's terms, with the SDK error as cause`() = runTest { every { client.challengeEmail(any()) } returns failingWith(EmbeddedAuthError.TooManyAttempts) @@ -392,11 +436,14 @@ class MfaRepositoryImplTest { private fun challengeEmail(index: Int = EMAIL.index, address: String = EMAIL.address): NextAction.ChallengeEmail = sdkInstance(Int::class.java to index, String::class.java to address) - /** The phone [SMS] and [CALL] are made from, which the server offers both ways. */ - private fun challengePhone(): NextAction.ChallengePhone = sdkInstance( - Int::class.java to SMS.index, + /** The phone [SMS] and [CALL] are made from, which the server by default offers both ways. */ + private fun challengePhone( + index: Int = SMS.index, + ways: List = listOf(PhoneDeliveryMethod.TEXT, PhoneDeliveryMethod.VOICE), + ): NextAction.ChallengePhone = sdkInstance( + Int::class.java to index, String::class.java to SMS.maskedNumber, - List::class.java to listOf(PhoneDeliveryMethod.TEXT, PhoneDeliveryMethod.VOICE), + List::class.java to ways, ) private fun verifyOob(pollInMs: Int): NextAction.VerifyOob = sdkInstance(Int::class.java to pollInMs) diff --git a/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/screens/MfaEmailWordsTest.kt b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/screens/MfaEmailWordsTest.kt new file mode 100644 index 0000000..2ce2458 --- /dev/null +++ b/universal_components/src/test/java/com/auth0/universalcomponents/feature/mfachallenge/presentation/screens/MfaEmailWordsTest.kt @@ -0,0 +1,65 @@ +package com.auth0.universalcomponents.feature.mfachallenge.presentation.screens + +import android.content.res.Resources +import com.auth0.universalcomponents.feature.mfachallenge.AuthenticatorListModel +import com.auth0.universalcomponents.feature.mfachallenge.MfaFactorId +import com.auth0.universalcomponents.feature.mfachallenge.MfaMessages +import com.auth0.universalcomponents.feature.mfachallenge.presentation.resolve +import com.google.common.truth.Truth.assertThat +import io.mockk.every +import io.mockk.mockk +import org.junit.Test + +class MfaEmailWordsTest { + + /** Stands in for the string resources: each default is its resource id and its argument. */ + private val resources = mockk { + every { getString(any()) } answers { "default(${firstArg()})" } + every { getString(any(), any()) } answers { + "default(${firstArg()},${secondArg>().joinToString(",")})" + } + } + + private val words = MfaMessages().resolve(resources) + + private fun emailRow(address: String) = + AuthenticatorListModel.Row(id = "email-6", factor = MfaFactorId.Email, title = address) + + @Test + fun `email row - an address - leads with the factor's name and names the address under it`() { + val shown = emailRow("j***@example.com").words(words) + + assertThat(shown.title).isEqualTo(words.emailChallenge.row.title) + assertThat(shown.description).isEqualTo("j***@example.com") + } + + @Test + fun `email row - no address - leads with the factor's name and its own description under it`() { + val shown = emailRow("").words(words) + + assertThat(shown.title).isEqualTo(words.emailChallenge.row.title) + assertThat(shown.description).isEqualTo(words.emailChallenge.row.description) + assertThat(shown.description).isNotEmpty() + } + + @Test + fun `email row - a blank address - is read as no address`() { + val shown = emailRow(" ").words(words) + + assertThat(shown.description).isEqualTo(words.emailChallenge.row.description) + } + + @Test + fun `code screen subtitle - an address - names where the code went`() { + val subtitle = words.emailChallenge.subtitleFor("j***@example.com") + + assertThat(subtitle).isEqualTo(words.emailChallenge.description("j***@example.com")) + assertThat(subtitle).contains("j***@example.com") + } + + @Test + fun `code screen subtitle - no address, or a blank one - says it went by email without naming it`() { + assertThat(words.emailChallenge.subtitleFor("")).isEqualTo(words.emailChallenge.descriptionUnnamed) + assertThat(words.emailChallenge.subtitleFor(" ")).isEqualTo(words.emailChallenge.descriptionUnnamed) + } +}