Skip to content
Merged
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
6 changes: 5 additions & 1 deletion EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ Each topic lives in its own file under [`examples/`](examples).

## Embedded Authentication (Beta)

- [Authorize (email OTP flow)](examples/embedded-auth/authorize.md)
- [Email OTP — first-factor & MFA second-factor](examples/embedded-auth/authorize.md)
- [Phone OTP (SMS/Voice) — first-factor & MFA second-factor](examples/embedded-auth/phone-authorize.md)
- [Push Notification Polling (OOB)](examples/embedded-auth/push-oob-polling.md)
- [Recovery Code](examples/embedded-auth/recovery-code.md)
- [TOTP (Authenticator App)](examples/embedded-auth/totp.md)

## Other APIs and features

Expand Down
124 changes: 121 additions & 3 deletions auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import com.auth0.android.embedded.authorize.EmbeddedAuthState
import com.auth0.android.embedded.authorize.EmbeddedCapability
import com.auth0.android.embedded.authorize.FailedRequest
import com.auth0.android.embedded.authorize.IdentifierType
import com.auth0.android.embedded.authorize.NextAction
import com.auth0.android.embedded.authorize.OtpType
import com.auth0.android.embedded.authorize.PhoneDeliveryMethod
import com.auth0.android.embedded.authorize.StepRequest
import com.auth0.android.embedded.authorize.authorizeCodeAdapter
import com.auth0.android.embedded.authorize.embeddedAuthErrorAdapter
Expand Down Expand Up @@ -89,6 +91,9 @@ public class EmbeddedAuthClient(private val auth0: Auth0) {
): Request<Void?, EmbeddedAuthException> = when (type) {
IdentifierType.EMAIL ->
continueStep(EmbeddedCapability.IDENTIFY_EMAIL) { addParameter(EMAIL_KEY, identifier) }

IdentifierType.PHONE ->
continueStep(EmbeddedCapability.IDENTIFY_PHONE) { addParameter(PHONE_KEY, identifier) }
}

/**
Expand Down Expand Up @@ -129,6 +134,114 @@ public class EmbeddedAuthClient(private val auth0: Auth0) {
return advancing(request)
}

/**
* Continues the flow by requesting a phone challenge for the authenticator at [index].
*
* [index] defaults to the first enrolled authenticator (`0`) — the right value when phone is
* the first factor. As an MFA step-up, echo the [NextAction.ChallengePhone.index] the server
* offered, since a user may have several phone authenticators enrolled.
*
* This call never resolves successfully; it completes through [EmbeddedAuthException] whose
* [EmbeddedAuthError.InsufficientAuthorization.nextActions] carry the next step to call.
*/
@JvmOverloads
public fun challengePhone(
index: Int = 0,
deliveryMethod: PhoneDeliveryMethod = PhoneDeliveryMethod.TEXT
): Request<Void?, EmbeddedAuthException> =
continueStep(EmbeddedCapability.CHALLENGE_PHONE) {
addParameter(INDEX_KEY, index)
addParameter(DELIVERY_METHOD_KEY, deliveryMethod.value)
}

/**
* Continues the flow by requesting a push notification challenge for the authenticator at [index].
*
* This call never resolves successfully; it completes through [EmbeddedAuthException] whose
* [EmbeddedAuthError.InsufficientAuthorization.nextActions] will contain a [NextAction.VerifyOob]
* entry. After receiving that continuation, call [verifyOob] repeatedly, waiting
* [NextAction.VerifyOob.pollInMs] milliseconds between each call, until the push is approved,
* rejected, or expires.
*
* [index] must be echoed from the [NextAction.ChallengePush.index] the server offered — a user
* may have several push authenticators enrolled.
*/
public fun challengePush(index: Int): Request<Void?, EmbeddedAuthException> =
continueStep(EmbeddedCapability.CHALLENGE_PUSH) {
addParameter(INDEX_KEY, index)
}

/**
* Performs a single poll to check whether the push notification sent by [challengePush] has
* been approved.
*
* This method performs **one poll per call** — the SDK does not poll automatically. After
* calling [challengePush], wait [NextAction.VerifyOob.pollInMs] milliseconds, then call this
* method. On each [EmbeddedAuthError.InsufficientAuthorization] continuation with reason
* [EmbeddedAuthError.InsufficientAuthorization.Reason.AUTHORIZATION_PENDING] or
* [EmbeddedAuthError.InsufficientAuthorization.Reason.SLOW_DOWN], read the updated
* [NextAction.VerifyOob.pollInMs] from [EmbeddedAuthError.InsufficientAuthorization.nextActions],
* wait that interval, and call this method again.
*
* The flow is sequential. Stop polling before calling any other step or starting a new flow
* with [authorize], and do so between polls — while waiting out the interval — not while a call
* to this method is in flight. Let an in-flight poll complete first; a poll that completes after
* another step has started can overwrite that step's session.
*
* On approval the server returns an authorization code and this call yields [Credentials].
* On rejection or expiry the request completes with a terminal [EmbeddedAuthError] such as
* [EmbeddedAuthError.AuthorizationRejected] or [EmbeddedAuthError.ChallengeExpired].
*/
public fun verifyOob(): Request<Credentials, EmbeddedAuthException> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With polling there's now a realistic race. If the user taps "start over" or switches factor while a verifyOob() is still in flight, the late authorization_pending response goes through updateSessionFromFailure and writes the old auth_session over the new flow's session. That function doesn't check which flow a response belongs to. Could we track that and ignore stale responses? If that's too much for now, at least note in this KDoc that the app must cancel its polling before calling anything else.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agree this is a valid scenario but there is not much we can do on the SDK side for the same. The client needs to handle this . I have updated the doc to highlight the same

val session = transactionState?.authSession ?: return noActiveSession()
val request = factory.post(authorizeUrl, authorizeCodeAdapter(gson))
.addParameters(
mapOf(
AUTH_SESSION_KEY to session,
ACTION_KEY to EmbeddedCapability.VERIFY_OOB.value,
CLIENT_ID_KEY to clientId
)
)
return advancing(request)
}

/**
* Verifies a recovery [code].
*
* This call **never** yields [Credentials] directly. The server always responds with a
* continuation whose [EmbeddedAuthError.InsufficientAuthorization.nextActions] contains a
* [NextAction.ConfirmRecoveryCode] entry carrying the newly rotated code to display to the
* user. Call [confirmRecoveryCode] next to acknowledge the new code and complete the flow.
*/
public fun verifyRecoveryCode(code: String): Request<Void?, EmbeddedAuthException> =
continueStep(
EmbeddedCapability.VERIFY_RECOVERY_CODE,
addPayload = { addParameter(CODE_KEY, code) }
)

/**
* Acknowledges the rotated recovery code returned by [verifyRecoveryCode] and completes
* the flow.
*
* Must only be called after [verifyRecoveryCode] returns a continuation with a
* [NextAction.ConfirmRecoveryCode] next action. Display [NextAction.ConfirmRecoveryCode.newCode]
* to the user before calling this method so they can record their new recovery code.
*
* On success, it yields the [Credentials].
*/
public fun confirmRecoveryCode(): Request<Credentials, EmbeddedAuthException> {
val session = transactionState?.authSession ?: return noActiveSession()
val request = factory.post(authorizeUrl, authorizeCodeAdapter(gson))
.addParameters(
mapOf(
AUTH_SESSION_KEY to session,
ACTION_KEY to EmbeddedCapability.CONFIRM_RECOVERY_CODE.value,
CLIENT_ID_KEY to clientId
)
)
return advancing(request)
}

private fun continueStep(
action: EmbeddedCapability,
addPayload: Request<Void?, EmbeddedAuthException>.() -> Unit = {}
Expand Down Expand Up @@ -216,9 +329,8 @@ public class EmbeddedAuthClient(private val auth0: Auth0) {
private const val AUTH_SESSION_KEY = "auth_session"
private const val ACTION_KEY = "action"
private const val EMAIL_KEY = "email"

@Suppress("unused")
private const val PHONE_KEY = "phone"
private const val DELIVERY_METHOD_KEY = "delivery_method"
private const val OTP_KEY = "otp"
private const val INDEX_KEY = "index"
private const val TYPE_KEY = "type"
Expand All @@ -231,8 +343,14 @@ public class EmbeddedAuthClient(private val auth0: Auth0) {

private val DEFAULT_CAPABILITIES: Set<EmbeddedCapability> = setOf(
EmbeddedCapability.IDENTIFY_EMAIL,
EmbeddedCapability.IDENTIFY_PHONE,
EmbeddedCapability.CHALLENGE_EMAIL,
EmbeddedCapability.VERIFY_OTP
EmbeddedCapability.CHALLENGE_PHONE,
EmbeddedCapability.CHALLENGE_PUSH,
EmbeddedCapability.VERIFY_OTP,
EmbeddedCapability.VERIFY_OOB,
EmbeddedCapability.VERIFY_RECOVERY_CODE,
EmbeddedCapability.CONFIRM_RECOVERY_CODE
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ public sealed interface EmbeddedAuthError {
INVALID_CODE,

/** The identifier or the code was invalid; the server does not say which. */
INVALID_IDENTIFIER_OR_CODE
INVALID_IDENTIFIER_OR_CODE,

/** Authorization is pending (e.g., push notification awaiting user approval). Retry after a delay. */
AUTHORIZATION_PENDING,

/** The app polled too fast. Retry after the interval the server returns. */
SLOW_DOWN
}
}

Expand All @@ -48,6 +54,9 @@ public sealed interface EmbeddedAuthError {
/** Terminal: rate-limited by same-user-login protection. */
public data object TooManyLogins : EmbeddedAuthError

/** Terminal: rate-limited by suspicious-IP throttling for the device's IP address. */
public data object IpBlocked : EmbeddedAuthError

/**
* Terminal: the grant being redeemed is no longer valid. Start a new flow with
* [EmbeddedAuthClient.authorize].
Expand All @@ -60,6 +69,15 @@ public sealed interface EmbeddedAuthError {
*/
public data object InvalidRequest : EmbeddedAuthError

/** Terminal: the user rejected or canceled the authorization (e.g., denied a push notification). */
public data object AuthorizationRejected : EmbeddedAuthError

/** Terminal: no eligible factors are available for the user. Enroll factors and retry the flow. */
public data object NoEligibleFactors : EmbeddedAuthError

/** Terminal: additional consent is required before the flow can proceed. */
public data object ConsentRequired : EmbeddedAuthError

/** The request never reached the server (`cause` is a network error). Retry the step. */
public data object Network : EmbeddedAuthError

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,17 @@ private const val CHALLENGE_EXPIRED = "challenge_expired"
private const val TOO_MANY_REQUESTS = "too_many_requests"
private const val TOO_MANY_ATTEMPTS = "too_many_attempts"
private const val TOO_MANY_LOGINS = "too_many_logins"
private const val IP_BLOCKED = "ip_blocked"
private const val INVALID_CODE = "invalid_code"
private const val INVALID_IDENTIFIER_OR_CODE = "invalid_identifier_or_code"
private const val INVALID_GRANT = "invalid_grant"
private const val INVALID_REQUEST = "invalid_request"
private const val TOO_MANY_REQUESTS_STATUS = 429
private const val AUTHORIZATION_REJECTED = "authorization_rejected"
private const val NO_ELIGIBLE_FACTORS = "no_eligible_factors"
private const val CONSENT_REQUIRED = "consent_required"
private const val AUTHORIZATION_PENDING = "authorization_pending"
private const val SLOW_DOWN = "slow_down"

/** Parses the `200` body of `/e/authorize` into the code to exchange for tokens. */
internal fun authorizeCodeAdapter(gson: Gson): JsonAdapter<AuthorizeCode> =
Expand Down Expand Up @@ -85,12 +91,20 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter<EmbeddedAuthException> {
EmbeddedAuthError.TooManyWrongOtpAttempts
code == ACCESS_DENIED && description == CHALLENGE_EXPIRED ->
EmbeddedAuthError.ChallengeExpired
code == ACCESS_DENIED && description == AUTHORIZATION_REJECTED ->
EmbeddedAuthError.AuthorizationRejected
code == ACCESS_DENIED && description == NO_ELIGIBLE_FACTORS ->
EmbeddedAuthError.NoEligibleFactors
code == ACCESS_DENIED && description == CONSENT_REQUIRED ->
EmbeddedAuthError.ConsentRequired
code == ACCESS_DENIED ->
EmbeddedAuthError.AccessDenied
statusCode == TOO_MANY_REQUESTS_STATUS && code == TOO_MANY_REQUESTS && description == TOO_MANY_ATTEMPTS ->
EmbeddedAuthError.TooManyAttempts
statusCode == TOO_MANY_REQUESTS_STATUS && code == TOO_MANY_REQUESTS && description == TOO_MANY_LOGINS ->
EmbeddedAuthError.TooManyLogins
statusCode == TOO_MANY_REQUESTS_STATUS && code == TOO_MANY_REQUESTS && description == IP_BLOCKED ->
EmbeddedAuthError.IpBlocked
code == INVALID_GRANT ->
EmbeddedAuthError.SessionExpired
code == INVALID_REQUEST ->
Expand All @@ -113,6 +127,9 @@ internal fun embeddedAuthErrorAdapter(): ErrorAdapter<EmbeddedAuthException> {
INVALID_CODE -> EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_CODE
INVALID_IDENTIFIER_OR_CODE ->
EmbeddedAuthError.InsufficientAuthorization.Reason.INVALID_IDENTIFIER_OR_CODE
AUTHORIZATION_PENDING ->
EmbeddedAuthError.InsufficientAuthorization.Reason.AUTHORIZATION_PENDING
SLOW_DOWN -> EmbeddedAuthError.InsufficientAuthorization.Reason.SLOW_DOWN
else -> EmbeddedAuthError.InsufficientAuthorization.Reason.NONE
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ public enum class EmbeddedCapability(public val value: String) {
IDENTIFY_EMAIL("action:identify:email:v1"),
IDENTIFY_PHONE("action:identify:phone:v1"),
CHALLENGE_EMAIL("action:challenge:email:v1"),
CHALLENGE_PHONE("action:challenge:phone:v1"),
CHALLENGE_PUSH("action:challenge:push:v1"),
VERIFY_OTP("action:verify:otp:v1"),
VERIFY_OOB("action:verify:oob:v1"),
VERIFY_RECOVERY_CODE("action:verify:recovery-code:v1"),
CONFIRM_RECOVERY_CODE("action:confirm:recovery-code:v1"),
UNKNOWN("Unknown");

internal companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ import com.auth0.android.embedded.EmbeddedAuthClient
public enum class IdentifierType {
/** An email address. */
EMAIL,
/** A phone number. */
PHONE,
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,48 @@ public sealed interface NextAction {
override val capability: EmbeddedCapability = EmbeddedCapability.VERIFY_OTP
}

/** Continue by challenging a phone factor. Act on it with [EmbeddedAuthClient.challengePhone]. */
@ConsistentCopyVisibility
public data class ChallengePhone internal constructor(
public val index: Int,
public val identifier: String,
public val deliveryMethods: List<PhoneDeliveryMethod>,
) : NextAction {
override val capability: EmbeddedCapability = EmbeddedCapability.CHALLENGE_PHONE
}

/** Continue by challenging a push notification factor. Act on it with [EmbeddedAuthClient.challengePush]. */
@ConsistentCopyVisibility
public data class ChallengePush internal constructor(
public val index: Int,
public val name: String,
) : NextAction {
override val capability: EmbeddedCapability = EmbeddedCapability.CHALLENGE_PUSH
}

/** Continue by verifying an out-of-band (push) notification. Act on it with [EmbeddedAuthClient.verifyOob]. */
@ConsistentCopyVisibility
public data class VerifyOob internal constructor(
public val pollInMs: Int,
) : NextAction {
override val capability: EmbeddedCapability = EmbeddedCapability.VERIFY_OOB
}

/** Continue by verifying a recovery code. Act on it with [EmbeddedAuthClient.verifyRecoveryCode]. */
public data object VerifyRecoveryCode : NextAction {
override val capability: EmbeddedCapability = EmbeddedCapability.VERIFY_RECOVERY_CODE
}

/** Continue by confirming a recovery code after verification. Act on it with [EmbeddedAuthClient.confirmRecoveryCode]. */
@ConsistentCopyVisibility
public data class ConfirmRecoveryCode internal constructor(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Since this is a data class, toString() prints the new recovery code in plain text. InsufficientAuthorization is a data class too, so something as simple as Log.d(TAG, "${e.error}") or a crash-reporter breadcrumb would leak it. Can we override toString() to mask newCode, and add a quick test for it?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Valid point. Done

public val newCode: String,
Comment thread
pmathew92 marked this conversation as resolved.
) : NextAction {
override val capability: EmbeddedCapability = EmbeddedCapability.CONFIRM_RECOVERY_CODE

override fun toString(): String = "ConfirmRecoveryCode(newCode='xxxxx')"
}

/** An action the server offered that this version of the SDK does not model. */
@ConsistentCopyVisibility
public data class Unknown internal constructor(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ private const val ACTION_KEY = "action"
private const val CHANNEL_KEY = "channel"
private const val IDENTIFIER_KEY = "identifier"
private const val INDEX_KEY = "index"
private const val DELIVERY_METHODS_KEY = "delivery_methods"
private const val POLL_IN_MS_KEY = "poll_in_ms"
private const val NAME_KEY = "name"
private const val NEW_CODE_KEY = "new_code"

internal fun List<Map<String, Any>>.toNextActions(): List<NextAction> = mapNotNull { it.toNextAction() }

Expand All @@ -29,12 +33,36 @@ private fun Map<String, Any>.toNextAction(): NextAction? {
identifier = this[IDENTIFIER_KEY] as? String
)
}
EmbeddedCapability.CHALLENGE_PHONE -> {
val index = (this[INDEX_KEY] as? Number)?.toInt() ?: return dropped(raw, INDEX_KEY)
val identifier = this[IDENTIFIER_KEY] as? String ?: return dropped(raw, IDENTIFIER_KEY)
val deliveryMethods = (this[DELIVERY_METHODS_KEY] as? List<*>)
?.filterIsInstance<String>()
?.mapNotNull(PhoneDeliveryMethod::fromValue)
?: return dropped(raw, DELIVERY_METHODS_KEY)
NextAction.ChallengePhone(index, identifier, deliveryMethods)
}
EmbeddedCapability.CHALLENGE_PUSH -> {
val index = (this[INDEX_KEY] as? Number)?.toInt() ?: return dropped(raw, INDEX_KEY)
val name = this[NAME_KEY] as? String ?: return dropped(raw, NAME_KEY)
NextAction.ChallengePush(index, name)
}
EmbeddedCapability.VERIFY_OOB -> {
val pollInMs = (this[POLL_IN_MS_KEY] as? Number)?.toInt()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two things here. Every other required field goes through dropped(), but this one returns null without logging. More importantly, if poll_in_ms is ever missing on a pending response, VerifyOob vanishes from the menu, and the app treats a still-pending push as terminal and gives up. How about falling back to a default interval (say 5s) instead of dropping it?

@pmathew92 pmathew92 Oct 16, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

poll_in_ms is a required property and server is bound to send it. Added logging for the same similar to other actions

?: return dropped(raw, POLL_IN_MS_KEY)
NextAction.VerifyOob(pollInMs)
}
EmbeddedCapability.VERIFY_RECOVERY_CODE -> NextAction.VerifyRecoveryCode
EmbeddedCapability.CONFIRM_RECOVERY_CODE -> {
val newCode = this[NEW_CODE_KEY] as? String ?: return dropped(raw, NEW_CODE_KEY)
NextAction.ConfirmRecoveryCode(newCode)
}
EmbeddedCapability.UNKNOWN -> NextAction.Unknown(raw)
}
}

/**
* Logs and drops a recognised next action whose required [field] the server omitted or sent
* Logs and drops a recognized next action whose required [field] the server omitted or sent
* malformed. Returns `null` so the entry is filtered out by [toNextActions].
*/
private fun dropped(action: String, field: String): NextAction? {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.auth0.android.embedded.authorize

/** How a phone one-time code is delivered. */
internal enum class PhoneDeliveryMethod(val value: String) {
public enum class PhoneDeliveryMethod(public val value: String) {
/** Deliver the code as an SMS text message. */
TEXT("text"),

Expand Down
Loading
Loading