Skip to content

Conversation

@utkrishtsahu
Copy link
Contributor

@utkrishtsahu utkrishtsahu commented Jan 15, 2026

Summary:

Adds comprehensive Multi-Factor Authentication (MFA) support to the Android SDK, enabling TOTP, SMS, Email, and recovery code authentication flows.

Changes

New Public APIs:

  1. AuthenticationAPIClient.mfa(mfaToken): MfaApiClient - Factory method for MFA operations

  2. MfaApiClient - Complete MFA client with methods for:

  • getAvailableAuthenticators() - List available MFA factors
  • enrollTotp() / enroll() - Enroll in TOTP or OOB authenticators
  • challenge() - Request MFA challenges
  • verifyWithOtp() / verifyWithOob() / verifyWithRecoveryCode() - Complete authentication

New Error Types:

MfaException sealed class hierarchy for type-safe MFA error handling
Specific exceptions: MfaListAuthenticatorsException, MfaEnrollmentException, MfaChallengeException, MfaVerifyException, MfaRequiredException
Includes fallback error codes aligned

New Data Models:
Authenticator, Challenge, EnrollmentChallenge, TotpEnrollmentChallenge, OobEnrollmentChallenge

Endpoints:

  • GET /mfa/authenticators - List factors
  • POST /mfa/associate - Enroll in MFA
  • POST /mfa/challenge - Request challenge
  • POST /oauth/token - Verify with grant types: mfa-otp, mfa-oob, mfa-recovery-code.

Testing

Tested on physical device with sample app.
Unit Test yet to write.
Sample app includes comprehensive MFA workflow examples

Reference Ticket

https://auth0team.atlassian.net/jira/software/c/projects/SDK/boards/2607?assignee=712020%3A746f4583-dada-45dc-8f76-07f273104490&selectedIssue=SDK-6405

@utkrishtsahu utkrishtsahu requested a review from a team as a code owner January 15, 2026 05:33
factorsAllowed: List<String>? = null
): Request<List<Authenticator>, MfaListAuthenticatorsException> {
// SDK validation: factorsAllowed cannot be empty
if (factorsAllowed != null && factorsAllowed.isEmpty()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

This is not the correct way. Use the addValidator method of the Request class to handle the validation logic

) : MfaException("MFA authenticator listing failed: $code") {

internal constructor(values: Map<String, Any>, statusCode: Int) : this(
code = (values["error"] as? String) ?: FALLBACK_ERROR_CODE,
Copy link
Contributor

Choose a reason for hiding this comment

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

default error code needs to be removed as discussed

* @param mfaToken The token received in the 'mfa_required' error from a login attempt.
* @return A new [MfaApiClient] instance configured for the transaction.
*/
public fun mfa(mfaToken: String): MfaApiClient {
Copy link
Contributor

Choose a reason for hiding this comment

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

Rename it to mfaClient

* The MFA token returned when multi-factor authentication is required.
* This token should be used to create an [MfaApiClient] to continue the MFA flow.
*/
public val mfaToken: String?
Copy link
Contributor

Choose a reason for hiding this comment

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

Keep this as part of the MfaRequirements error body and not separate.

* The MFA requirements returned when multi-factor authentication is required.
* Contains information about the required challenge types.
*/
public val mfaRequirements: MfaRequirements?
Copy link
Contributor

Choose a reason for hiding this comment

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

Hope the name is consistent with Auth0.Swift implementation

private const val JWKS_FILE_PATH = "jwks.json"
private const val TAG = "AuthenticationAPIClient"
private fun createErrorAdapter(): ErrorAdapter<AuthenticationException> {
internal fun createErrorAdapter(): ErrorAdapter<AuthenticationException> {
Copy link
Contributor

Choose a reason for hiding this comment

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

Why make them internal ?

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds comprehensive Multi-Factor Authentication (MFA) support to the Android SDK, enabling developers to implement TOTP, SMS, Email, and recovery code authentication flows.

Changes:

  • Introduced new MfaApiClient with methods for listing authenticators, enrollment, challenge, and verification operations
  • Added MfaException sealed class hierarchy for type-safe MFA error handling with specific exceptions for different operations
  • Extended AuthenticationException and CredentialsManagerException to expose MFA tokens and requirements for continuing MFA flows

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
MfaRequirements.kt New data model representing MFA requirements returned by Auth0 (challenge/enroll types)
Authenticator.kt New data model representing enrolled MFA authenticator details
MfaException.kt New sealed class hierarchy for MFA-specific exceptions with fallback error codes
MfaApiClient.kt New API client providing methods for MFA operations (list, enroll, challenge, verify)
AuthenticationAPIClient.kt Added factory method mfa() to create MfaApiClient instances; exposed internal error adapter
AuthenticationException.kt Added mfaToken and mfaRequirements properties to support MFA flow continuation
CredentialsManagerException.kt Added MFA_REQUIRED error code and properties for MFA token/requirements
CredentialsManager.kt Added MFA error detection and exception wrapping in credential renewal flows
SecureCredentialsManager.kt Added MFA error detection and exception wrapping in credential renewal flows

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +218 to +230
/**
* The MFA token required to continue the multi-factor authentication flow.
* This is only available when the error code is [Code.MFA_REQUIRED].
*/
public val mfaToken: String?
get() = mfaTokenValue

/**
* The MFA requirements when multi-factor authentication is required.
* This is only available when the error code is [Code.MFA_REQUIRED].
*/
public val mfaRequirements: MfaRequirements?
get() = mfaRequirementsValue
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The new mfaToken and mfaRequirements properties in CredentialsManagerException are missing the @JvmName annotation. If Java callers want to access these properties, they would need to use getMfaToken() and getMfaRequirements(), but since these are custom getters, the Java method names might not be ideal. Consider adding @JvmName annotations for consistency with Java interoperability.

Copilot uses AI. Check for mistakes.
Comment on lines +258 to +259
public val mfaRequirements: Map<String, Any>?
get() = getValue("mfa_requirements") as? Map<String, Any>
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The MfaRequiredException.mfaRequirements property returns Map<String, Any>? instead of the more type-safe MfaRequirements class that exists in the codebase. This forces consumers to work with untyped maps when a proper data class is available, reducing type safety and developer experience.

Suggested change
public val mfaRequirements: Map<String, Any>?
get() = getValue("mfa_requirements") as? Map<String, Any>
public data class MfaRequirements(
val enroll: List<String>?,
val challenge: List<String>?
)
public val mfaRequirements: MfaRequirements?
get() {
val raw = getValue("mfa_requirements") as? Map<*, *> ?: return null
val enroll = (raw["enroll"] as? List<*>)?.filterIsInstance<String>()
val challenge = (raw["challenge"] as? List<*>)?.filterIsInstance<String>()
return MfaRequirements(enroll = enroll, challenge = challenge)
}

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Choose a reason for hiding this comment

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

Agree. Here instead of the Map we can return the defined MfaRequirements instance

Comment on lines +217 to +287
public fun enroll(
factorType: String,
phoneNumber: String? = null,
email: String? = null,
authenticatorType: String? = null
): Request<EnrollmentChallenge, MfaEnrollmentException> {
// Auth0 API expects authenticator_types as an array and oob_channels for OOB types
// Map the factorType to the correct Auth0 API format
val authenticatorTypesArray: List<String>
val oobChannelsArray: List<String>?

when (factorType.lowercase()) {
"phone" -> {
// SMS enrollment: authenticator_types=["oob"], oob_channels=["sms"]
authenticatorTypesArray = listOf("oob")
oobChannelsArray = listOf("sms")
}
"email" -> {
// Email enrollment: authenticator_types=["oob"], oob_channels=["email"]
authenticatorTypesArray = listOf("oob")
oobChannelsArray = listOf("email")
}
"totp" -> {
// TOTP enrollment: authenticator_types=["otp"]
authenticatorTypesArray = listOf("otp")
oobChannelsArray = null
}
"push" -> {
// Push enrollment: authenticator_types=["push-notification"]
authenticatorTypesArray = listOf("push-notification")
oobChannelsArray = null
}
else -> {
// Use authenticatorType if provided, otherwise use factorType as-is
authenticatorTypesArray = if (authenticatorType != null) {
listOf(authenticatorType)
} else {
listOf(factorType)
}
oobChannelsArray = null
}
}

val parameters = ParameterBuilder.newBuilder()
.setClientId(clientId)
.set(MFA_TOKEN_KEY, mfaToken)
.set(PHONE_NUMBER_KEY, phoneNumber)
.set(EMAIL_KEY, email)
.asDictionary()

val url = baseURL.toHttpUrl().newBuilder()
.addPathSegment(MFA_PATH)
.addPathSegment(ASSOCIATE_PATH)
.build()

val enrollmentAdapter: JsonAdapter<EnrollmentChallenge> = GsonAdapter(
EnrollmentChallenge::class.java, gson
)

val request = enrollmentFactory.post(url.toString(), enrollmentAdapter)
.addParameters(parameters)

// Add array parameters using addParameter(name, Any) which handles serialization
request.addParameter(AUTHENTICATOR_TYPES_KEY, authenticatorTypesArray)

if (oobChannelsArray != null) {
request.addParameter(OOB_CHANNELS_KEY, oobChannelsArray)
}

return request
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The enroll method has complex parameter validation logic based on factorType, but there's no input validation for required parameters. For example, when factorType is "phone", phoneNumber should be required but there's no check to ensure it's provided. This could lead to API errors that would be better caught earlier with proper validation.

Copilot uses AI. Check for mistakes.
val authenticatorTypesArray: List<String>
val oobChannelsArray: List<String>?

when (factorType.lowercase()) {
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The enroll method uses locale-sensitive lowercase() for factorType comparison. This could lead to unexpected behavior with Turkish or other locales where lowercase transformations differ. Use factorType.lowercase(Locale.ROOT) or factorType.lowercase(Locale.ENGLISH) for consistent behavior across all locales.

Copilot uses AI. Check for mistakes.
* mfaClient.challenge("oob", "{authenticator_id}")
* .start(object : Callback<Challenge, MfaChallengeException> {
* override fun onSuccess(result: Challenge) {
* // Code sent, now prompt user for the OTP they received
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The documentation example has extra whitespace before the comment text on line 160. This formatting inconsistency should be corrected to maintain code quality standards.

Suggested change
* // Code sent, now prompt user for the OTP they received
* // Code sent, now prompt user for the OTP they received

Copilot uses AI. Check for mistakes.
Comment on lines +121 to +130
public fun getAvailableAuthenticators(
factorsAllowed: List<String>? = null
): Request<List<Authenticator>, MfaListAuthenticatorsException> {
// SDK validation: factorsAllowed cannot be empty
if (factorsAllowed != null && factorsAllowed.isEmpty()) {
throw MfaListAuthenticatorsException.invalidRequest(
"challengeType is required and must contain at least one challenge type. " +
"Pass null to retrieve all authenticators, or provide at least one factor type (e.g., \"otp\", \"oob\", \"recovery-code\")."
)
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The getAvailableAuthenticators method throws MfaListAuthenticatorsException for validation errors, which is inconsistent with standard Kotlin/Java practices. SDK validation errors should typically throw IllegalArgumentException or similar, while MfaListAuthenticatorsException should be reserved for API/network errors that occur during request execution. This creates confusion as the exception can be thrown synchronously (validation) or asynchronously (API error).

Copilot uses AI. Check for mistakes.
Comment on lines +547 to +558
if (error.isMultifactorRequired) {
callback.onFailure(
CredentialsManagerException(
CredentialsManagerException.Code.MFA_REQUIRED,
error.message ?: "Multi-factor authentication is required to complete the credential renewal.",
error,
error.mfaToken,
error.mfaRequirements
)
)
return@execute
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

There is code duplication in the MFA error handling across CredentialsManager and SecureCredentialsManager. The same MFA detection and exception creation logic appears in multiple places (lines 547-558, 674-685, 915-926, 1074-1085). Consider extracting this into a shared helper method to reduce duplication and improve maintainability.

Copilot uses AI. Check for mistakes.
private const val JWKS_FILE_PATH = "jwks.json"
private const val TAG = "AuthenticationAPIClient"
private fun createErrorAdapter(): ErrorAdapter<AuthenticationException> {
internal fun createErrorAdapter(): ErrorAdapter<AuthenticationException> {
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The companion object visibility is changed from private to internal, which expands the API surface. Unless there's a specific requirement for internal access to createErrorAdapter, this change increases coupling between internal components and may make future refactoring more difficult.

Suggested change
internal fun createErrorAdapter(): ErrorAdapter<AuthenticationException> {
private fun createErrorAdapter(): ErrorAdapter<AuthenticationException> {

Copilot uses AI. Check for mistakes.
* Example usage:
* ```
* try {
* val credentials = mfaClient.verifyOtp("123456").await()
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The documentation example at line 176 shows calling "verifyOtp" but the actual method name is "verifyWithOtp". This inconsistency in the documentation will confuse developers trying to use the API.

Suggested change
* val credentials = mfaClient.verifyOtp("123456").await()
* val credentials = mfaClient.verifyWithOtp("123456").await()

Copilot uses AI. Check for mistakes.
Comment on lines +163 to +170
public val mfaRequirements: MfaRequirements?
get() = (getValue("mfa_requirements") as? Map<*, *>)?.let {
@Suppress("UNCHECKED_CAST")
GsonProvider.gson.fromJson(
GsonProvider.gson.toJson(it),
MfaRequirements::class.java
)
}
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

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

The AuthenticationException.mfaRequirements property performs JSON serialization/deserialization on every access, which is inefficient if the property is accessed multiple times. Consider caching the deserialized MfaRequirements object in a lazy-initialized backing field to avoid repeated conversions.

Copilot uses AI. Check for mistakes.
mfaToken,
RequestFactory<AuthenticationException>(
auth0.networkingClient,
AuthenticationAPIClient.createErrorAdapter()
Copy link
Contributor

Choose a reason for hiding this comment

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

Create a new Adapter for this class instead of reusing the AuthenticationException one. All errors from this set of APIs should return the new error type

* @throws MfaListAuthenticatorsException if factorsAllowed is an empty list (SDK validation error)
*/
public fun getAvailableAuthenticators(
factorsAllowed: List<String>? = null
Copy link
Contributor

Choose a reason for hiding this comment

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

factorsAllowed is not an optional parameter.

// Apply filtering if factorsAllowed is provided and not empty
if (factorsAllowed != null) {
urlBuilder.addQueryParameter("factorsAllowed", factorsAllowed.joinToString(","))
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Filtering should be done by the SDK

phoneNumber: String? = null,
email: String? = null,
authenticatorType: String? = null
): Request<EnrollmentChallenge, MfaEnrollmentException> {
Copy link
Contributor

@pmathew92 pmathew92 Jan 20, 2026

Choose a reason for hiding this comment

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

Would suggest your make this a private method and expose public methods for individual factors like what you have written for enrollTotp. This method doesn't look DX friendly. I think web and Swift is also exposing individual public APIs

* @param otp the one-time password provided by the user
* @return an authentication request to configure and start that will yield [Credentials]
*/
public fun verifyWithOtp(otp: String): AuthenticationRequest {
Copy link
Contributor

Choose a reason for hiding this comment

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

AuthenticationRequest is bound to an AuthenticationException . Since we will have a completely new error class here , I would suggest we use the Request interface. This will also be consistent with other methods in this class

/**
* Creates error adapter for getAuthenticators() operations.
* Returns MfaListAuthenticatorsException with fallback error code if API doesn't provide one.
*/
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we would need separate error classes for different APIs. A single MfaException class should suffice IMO. Check with @NandanPrabhu and decide on a single class

* Base class for MFA-related exceptions.
* All MFA-specific errors inherit from this class for easier error handling.
*/
public sealed class MfaException(
Copy link
Contributor

Choose a reason for hiding this comment

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

Lets create a single Exception class

* }
* ```
*/
public class MfaRequiredException internal constructor(
Copy link
Contributor

Choose a reason for hiding this comment

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

where is this MfaRequiredException being used . Doesn't the existing login APIs return AuthenticationException. So users would catch it and check for MfaRequired error and proceed with Mfa flow

CredentialsManagerException.Code.MFA_REQUIRED,
error.message ?: "Multi-factor authentication is required to complete the credential renewal.",
error,
error.mfaToken,
Copy link
Contributor

Choose a reason for hiding this comment

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

lets return token as part of the MfaRequirements

}

private var code: Code?
private var mfaTokenValue: String? = null
Copy link
Contributor

Choose a reason for hiding this comment

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

Keep mfaToken as part of MfaRequirements

*/
public data class MfaRequirements(
@SerializedName("challenge") val challenge: List<MfaChallengeRequirement>?,
@SerializedName("enroll") val enroll: List<MfaChallengeRequirement>?
Copy link
Contributor

Choose a reason for hiding this comment

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

Lets add mfaToken also as part of this

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.

4 participants