Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Pbly just adding a list of supported factors will be easier to update


- **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`.
Expand Down Expand Up @@ -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_<group>_<key>`, 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_<group>_<key>`, 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.
Expand All @@ -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" } }
},
)
Expand Down Expand Up @@ -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)

Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,14 @@ 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,
internal val loading: Loading,
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. */
Expand Down Expand Up @@ -155,6 +154,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 {

/**
Expand All @@ -173,6 +209,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()

Expand All @@ -184,6 +221,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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class ResolvedMfaMessages internal constructor(
val errors: Errors,
val pushChallenge: PushChallenge,
val phoneChallenge: PhoneChallenge,
val emailChallenge: EmailChallenge,
) {

companion object {
Expand Down Expand Up @@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,31 @@ 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<NextAction>.toMfaFactors(): List<MfaFactor> = 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 is MfaFactor.Phone }
}.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<PhoneDeliveryMethod>
get() = deliveryMethods.ifEmpty { listOf(PhoneDeliveryMethod.TEXT) }.distinct()

/** 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
}
Loading
Loading