Skip to content

feat : Add support for MFA in embedded authorize flow - #1086

Open
pmathew92 wants to merge 6 commits into
v5_developmentfrom
feat/embedded_mfa
Open

pmathew92 wants to merge 6 commits into
v5_developmentfrom
feat/embedded_mfa

Conversation

@pmathew92

@pmathew92 pmathew92 commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Changes

This PR adds MFA flow support to the embedded /e/authorize flow, building on the email-OTP first-factor work.

Supported factors

  • Email OTP — first factor & MFA second factor
  • Phone OTP (SMS / voice) — first factor & MFA second factor
  • TOTP (authenticator app)
  • Authenticator Push (OOB, app-driven polling)
  • Recovery Code (verify + confirm rotated code)

New public types

  • NextAction sealed interface — IdentifyPhone, ChallengePhone, ChallengePush, VerifyOob, VerifyRecoveryCode, ConfirmRecoveryCode — surfaced via InsufficientAuthorization.nextActions (a menu of what the server will accept next)
  • EmbeddedAuthError — added the MFA continuation/terminal cases and InsufficientAuthorization.Reason (AUTHORIZATION_PENDING, SLOW_DOWN) so callers can tell why an input was rejected
  • IdentifierType ( PHONE), PhoneDeliveryMethod (TEXT, VOICE).

Push note: the SDK never polls. verifyOob() performs exactly one poll per call and the app owns the loop — a pending push comes back as InsufficientAuthorization carrying a fresh VerifyOob(pollInMs) delay to wait before the next call.

Testing

Please describe how this can be tested by reviewers. Be specific about anything not tested and reasons why. Since this library has unit testing, tests should be added for new functionality and existing tests should complete without errors.

  • This change adds unit test coverage

  • This change has been tested on the latest version of the platform/language or why not

Checklist

@coderabbitai

coderabbitai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Team
  • Run ID: 2a04e510-391a-4a13-b946-cb3d2c542ddb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.


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

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

* 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 index = (this[INDEX_KEY] as? Number)?.toInt() ?: return dropped(raw, INDEX_KEY)
val identifier = this[IDENTIFIER_KEY] as? String ?: return dropped(raw, IDENTIFIER_KEY)
@Suppress("UNCHECKED_CAST")
val rawDeliveryMethods = this[DELIVERY_METHODS_KEY] as? List<String>

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.

PhoneDeliveryMethod.fromValue() already exists and ignores case, so this hand-written when duplicates it (and is case-sensitive). Also, as? List is an unchecked cast, so a non-string value in the list would throw a ClassCastException while parsing the error. Something like (this[DELIVERY_METHODS_KEY] as? List<*>)?.filterIsInstance()?.mapNotNull(PhoneDeliveryMethod::fromValue) handles both.

@@ -141,41 +154,6 @@ when (e.error) {
}

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.

On the terminal-errors when just above: the new errors (AuthorizationRejected, NoEligibleFactors, ConsentRequired) aren't listed, so they fall into the "Unknown" else. NoEligibleFactors especially deserves its own line, since the app needs to send the user off to enroll a factor.

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.

Added

val embedded = EmbeddedAuthClient(Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN"))

// Next poll delay from a pending-push continuation, or null when the push is terminal.
// The server always includes poll_in_ms on a pending VerifyOob.

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.

This assumes the server always sends poll_in_ms, but the SDK doesn't guarantee that today (see my comment on the mapper). Can we update this once the fallback is settled?

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.

Server will always send a poll_in_ms

* [EmbeddedAuthError.InsufficientAuthorization.Reason.AUTHORIZATION_PENDING] or
* [EmbeddedAuthError.InsufficientAuthorization.Reason.SLOW_DOWN], read the updated
* [NextAction.VerifyOob.pollInMs] from [EmbeddedAuthError.InsufficientAuthorization.nextActions],
* wait that interval (back off on SLOW_DOWN), and call this method again.

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.

The KDoc says to back off on SLOW_DOWN, but the push example just waits whatever pollInMs the server returns. Which one is it? Should the app add its own back-off, or is the server's pollInMs already the slowed-down value?

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.

Servers pollInMs is already the slowed down value. App shouldn't add anything on its own

// which is correct for phone as a first factor; as an MFA step-up, pass the
// NextAction.ChallengePhone.index the server offered. Continues with VerifyOtp(channel = SMS).
try {
embedded.challengePhone(deliveryMethod = PhoneDeliveryMethod.TEXT).await()

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.

The comment above says to pass the server's index for MFA, but the code hard-codes the default index and TEXT. Can we show reading ChallengePhone.index and deliveryMethods? Otherwise people will copy this and challenge the wrong authenticator, or ask for voice when the server didn't offer it.

}
});

embedded.verifyOtp("123456", OtpType.OOB)

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.

The Java sample jumps from identify straight to verifyOtp and skips challengePhone. Since it's the new method here, it'd be good to show its Java usage too.

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.

Added

mockAPI.willReturnInsufficientAuthorization()

try {
client.challengePhone(index = 0, deliveryMethod = PhoneDeliveryMethod.VOICE).execute()

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.

index = 0 is also the default, so this would still pass if index were ignored. Could you use a non-zero index here, and add a quick check that the default delivery method goes out as "text"?

}

@Test
public fun `verifyOob should exchange the code and return credentials on 200`() {

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.

ould we also check that the session is cleared after a successful exchange, i.e. the next call fails with NoActiveSession?

}

@Test
public fun `confirmRecoveryCode should exchange the code and return credentials on 200`() {

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.

Same as on the verifyOob test: could we check that the session is cleared after the exchange succeeds?

@pmathew92
pmathew92 marked this pull request as ready for review October 19, 2026 04:24
@pmathew92
pmathew92 requested a review from a team as a code owner October 19, 2026 04:24
* which the SDK surfaces as an [EmbeddedAuthException]. A plain success here is unexpected.
*/
private fun runStep(step: suspend () -> Unit) {
pollJob?.cancel()

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.

pollJob?.cancel() doesn't stop an in-flight poll. await() wraps a blocking execute(), so a late response still overwrites the new step's session. That's the race the KDoc warns about. Can the sample wait for the in-flight poll before starting the next step?

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.

It's a sample app change.Not a blocker

// 3. Send the code using the index and a delivery method the server offered. Pick VOICE for a
// voice call; the first offered method is used here. Continues with VerifyOtp.
try {
val method = challenge.deliveryMethods.firstOrNull() ?: PhoneDeliveryMethod.TEXT

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.

This won't compile because challenge is never defined. Step 2 reads the ChallengePhone but drops it. Also, there's a stray space in challenge.index ,

val credentials = client.verifyOtp(
code = "123456",
type = OtpType.OOB // OOB for emailed codes; TOTP for authenticator-app codes
type = OtpType.OOB // OOB for email and SMS/voice codes; TOTP for authenticator-app codes

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.

The when (error.reason) below (around line 126) has no else, so with the new AUTHORIZATION_PENDING and SLOW_DOWN it won't compile. Can you add those cases or an else -> null?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants