diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt index b11dc187..8668b0da 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -84,6 +84,31 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe return this } + /** + * Creates a new [MfaApiClient] to handle a multi-factor authentication transaction. + * + * Example usage: + * ``` + * try { + * val credentials = authClient.login("user@example.com", "password").await() + * } catch (error: AuthenticationException) { + * if (error.isMultifactorRequired) { + * val mfaToken = error.mfaToken + * if (mfaToken != null) { + * val mfaClient = authClient.mfa(mfaToken) + * // Use mfaClient to handle MFA flow + * } + * } + * } + * ``` + * + * @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 { + return MfaApiClient(this.auth0, mfaToken) + } + /** * Log in a user with email/username and password for a connection/realm. * It will use the password-realm grant type for the `/oauth/token` endpoint @@ -1081,7 +1106,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe return factory.get(url.toString(), userProfileAdapter, dPoP) } - private companion object { + internal companion object { private const val SMS_CONNECTION = "sms" private const val EMAIL_CONNECTION = "email" private const val USERNAME_KEY = "username" @@ -1122,7 +1147,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private const val WELL_KNOWN_PATH = ".well-known" private const val JWKS_FILE_PATH = "jwks.json" private const val TAG = "AuthenticationAPIClient" - private fun createErrorAdapter(): ErrorAdapter { + internal fun createErrorAdapter(): ErrorAdapter { val mapAdapter = forMap(GsonProvider.gson) return object : ErrorAdapter { override fun fromRawResponse( diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt index b5627c0b..0c7c9227 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt @@ -5,6 +5,8 @@ import android.util.Log import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.provider.TokenValidationException +import com.auth0.android.request.internal.GsonProvider +import com.auth0.android.result.MfaRequirements public class AuthenticationException : Auth0Exception { private var code: String? = null @@ -147,6 +149,26 @@ public class AuthenticationException : Auth0Exception { public val isMultifactorEnrollRequired: Boolean get() = "a0.mfa_registration_required" == code || "unsupported_challenge_type" == code + /** + * 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? + get() = getValue("mfa_token") as? String + + /** + * The MFA requirements returned when multi-factor authentication is required. + * Contains information about the required challenge types. + */ + public val mfaRequirements: MfaRequirements? + get() = (getValue("mfa_requirements") as? Map<*, *>)?.let { + @Suppress("UNCHECKED_CAST") + GsonProvider.gson.fromJson( + GsonProvider.gson.toJson(it), + MfaRequirements::class.java + ) + } + /// When Bot Protection flags the request as suspicious public val isVerificationRequired: Boolean get() = "requires_verification" == code diff --git a/auth0/src/main/java/com/auth0/android/authentication/MfaApiClient.kt b/auth0/src/main/java/com/auth0/android/authentication/MfaApiClient.kt new file mode 100644 index 00000000..aa1b2ff4 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/MfaApiClient.kt @@ -0,0 +1,601 @@ +package com.auth0.android.authentication + +import androidx.annotation.VisibleForTesting +import com.auth0.android.Auth0 +import com.auth0.android.Auth0Exception +import com.auth0.android.NetworkErrorException +import com.auth0.android.authentication.MfaException.* +import com.auth0.android.dpop.DPoPException +import com.auth0.android.request.AuthenticationRequest +import com.auth0.android.request.ErrorAdapter +import com.auth0.android.request.JsonAdapter +import com.auth0.android.request.Request +import com.auth0.android.request.internal.BaseAuthenticationRequest +import com.auth0.android.request.internal.GsonAdapter +import com.auth0.android.request.internal.GsonProvider +import com.auth0.android.request.internal.RequestFactory +import com.auth0.android.request.internal.ResponseUtils.isNetworkError +import com.auth0.android.result.Authenticator +import com.auth0.android.result.Challenge +import com.auth0.android.result.Credentials +import com.auth0.android.result.EnrollmentChallenge +import com.google.gson.Gson +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.io.IOException +import java.io.Reader + +/** + * API client for handling Multi-Factor Authentication (MFA) flows. + * This client is created via [AuthenticationAPIClient.mfa] and provides methods + * to handle MFA challenges and enrollments. + * + * Example usage: + * ``` + * val authClient = AuthenticationAPIClient(auth0) + * try { + * val credentials = authClient.login("user@example.com", "password").await() + * } catch (error: AuthenticationException) { + * if (error.isMultifactorRequired) { + * val mfaToken = error.mfaToken + * if (mfaToken != null) { + * val mfaClient = authClient.mfa(mfaToken) + * // Use mfaClient to handle MFA flow + * } + * } + * } + * ``` + */ +public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor( + private val auth0: Auth0, + private val mfaToken: String, + private val factory: RequestFactory, + private val gson: Gson +) { + + // Specialized factories for MFA-specific errors + private val listAuthenticatorsFactory: RequestFactory by lazy { + RequestFactory(auth0.networkingClient, createListAuthenticatorsErrorAdapter()) + } + + private val enrollmentFactory: RequestFactory by lazy { + RequestFactory(auth0.networkingClient, createEnrollmentErrorAdapter()) + } + + private val challengeFactory: RequestFactory by lazy { + RequestFactory(auth0.networkingClient, createChallengeErrorAdapter()) + } + + private val verifyFactory: RequestFactory by lazy { + RequestFactory(auth0.networkingClient, createVerifyErrorAdapter()) + } + + /** + * Creates a new MfaApiClient instance. + * + * @param auth0 the Auth0 account information + * @param mfaToken the MFA token received from the mfa_required error + */ + public constructor(auth0: Auth0, mfaToken: String) : this( + auth0, + mfaToken, + RequestFactory( + auth0.networkingClient, + AuthenticationAPIClient.createErrorAdapter() + ), + GsonProvider.gson + ) + + private val clientId: String + get() = auth0.clientId + private val baseURL: String + get() = auth0.getDomainUrl() + + /** + * Get the list of available authenticators (MFA factors) enrolled for the user. + * + * Example usage: + * ``` + * mfaClient.getAvailableAuthenticators() + * .start(object : Callback, MfaListAuthenticatorsException> { + * override fun onSuccess(result: List) { } + * override fun onFailure(error: MfaListAuthenticatorsException) { } + * }) + * ``` + * + * Example with filtering: + * ``` + * mfaClient.getAvailableAuthenticators(listOf("otp", "oob")) + * .start(object : Callback, MfaListAuthenticatorsException> { + * override fun onSuccess(result: List) { + * // Only OTP and OOB authenticators returned + * } + * override fun onFailure(error: MfaListAuthenticatorsException) { } + * }) + * ``` + * + * @param factorsAllowed optional list of factor types to filter by (e.g., "otp", "oob", "recovery-code"). + * Pass null to retrieve all authenticators. Empty list is not allowed. + * @return a request to configure and start that will yield a list of [Authenticator] + * @throws MfaListAuthenticatorsException if factorsAllowed is an empty list (SDK validation error) + */ + public fun getAvailableAuthenticators( + factorsAllowed: List? = null + ): Request, 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\")." + ) + } + + val urlBuilder = baseURL.toHttpUrl().newBuilder() + .addPathSegment(MFA_PATH) + .addPathSegment(AUTHENTICATORS_PATH) + + // Apply filtering if factorsAllowed is provided and not empty + if (factorsAllowed != null) { + urlBuilder.addQueryParameter("factorsAllowed", factorsAllowed.joinToString(",")) + } + + val url = urlBuilder.build() + + val authenticatorsAdapter: JsonAdapter> = GsonAdapter.forListOf( + Authenticator::class.java, gson + ) + + return listAuthenticatorsFactory.get(url.toString(), authenticatorsAdapter) + .addHeader(HEADER_AUTHORIZATION, "Bearer $mfaToken") + } + + /** + * Send a challenge for an out-of-band (OOB) MFA authenticator (e.g., SMS, Push). + * This will trigger the system to send the code to the user. + * + * Example usage: + * ``` + * mfaClient.challenge("oob", "{authenticator_id}") + * .start(object : Callback { + * override fun onSuccess(result: Challenge) { + * // Code sent, now prompt user for the OTP they received + * } + * override fun onFailure(error: MfaChallengeException) { } + * }) + * ``` + * + * @param challengeType the type of challenge (e.g., "oob") + * @param authenticatorId the ID of the authenticator to challenge + * @return a request to configure and start that will yield [Challenge] + */ + public fun challenge( + challengeType: String, + authenticatorId: String + ): Request { + val parameters = ParameterBuilder.newBuilder() + .setClientId(clientId) + .set(MFA_TOKEN_KEY, mfaToken) + .set(CHALLENGE_TYPE_KEY, challengeType) + .set(AUTHENTICATOR_ID_KEY, authenticatorId) + .asDictionary() + + val url = baseURL.toHttpUrl().newBuilder() + .addPathSegment(MFA_PATH) + .addPathSegment(CHALLENGE_PATH) + .build() + + val challengeAdapter: JsonAdapter = GsonAdapter( + Challenge::class.java, gson + ) + + return challengeFactory.post(url.toString(), challengeAdapter) + .addParameters(parameters) + } + + /** + * Enroll a new MFA factor for the user. This is a generic enrollment method + * that supports different factor types. + * + * Example usage for TOTP: + * ``` + * mfaClient.enroll("totp") + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * if (result is TotpEnrollmentChallenge) { + * // Show QR code to user: result.barcodeUri + * } + * } + * override fun onFailure(error: MfaEnrollmentException) { } + * }) + * ``` + * + * @param factorType the type of factor to enroll (e.g., "totp", "phone", "email") + * @param phoneNumber the phone number (required for SMS enrollment) + * @param email the email address (required for email OTP enrollment) + * @param authenticatorType optional authenticator type specification + * @return a request to configure and start that will yield [EnrollmentChallenge] + */ + public fun enroll( + factorType: String, + phoneNumber: String? = null, + email: String? = null, + authenticatorType: String? = null + ): Request { + // 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 + val oobChannelsArray: List? + + 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 = 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 + } + + /** + * Convenience method to enroll a TOTP authenticator. + * + * Example usage: + * ``` + * mfaClient.enrollTotp() + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * if (result is TotpEnrollmentChallenge) { + * showQrCode(result.barcodeUri) + * } + * } + * override fun onFailure(error: MfaEnrollmentException) { } + * }) + * ``` + * + * @return a request to configure and start that will yield [EnrollmentChallenge] + */ + public fun enrollTotp(): Request { + return enroll("totp") + } + + /** + * Verify the MFA challenge with a one-time password (OTP). + * This completes the MFA flow and returns the credentials. + * + * Example usage: + * ``` + * mfaClient.verifyWithOtp("{otp_code}") + * .validateClaims() //mandatory + * .start(object : Callback { + * override fun onSuccess(result: Credentials) { + * // MFA completed successfully + * } + * override fun onFailure(error: AuthenticationException) { } + * }) + * ``` + * + * @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 { + val parameters = ParameterBuilder.newAuthenticationBuilder() + .setGrantType(GRANT_TYPE_MFA_OTP) + .set(MFA_TOKEN_KEY, mfaToken) + .set(ONE_TIME_PASSWORD_KEY, otp) + .asDictionary() + + return loginWithToken(parameters) + } + + /** + * Verify the MFA challenge with an out-of-band (OOB) code. + * This is used for SMS or Push notification based MFA. + * + * Example usage: + * ``` + * mfaClient.verifyWithOob("{oob_code}", "{binding_code}") + * .validateClaims() //mandatory + * .start(object : Callback { + * override fun onSuccess(result: Credentials) { + * // MFA completed successfully + * } + * override fun onFailure(error: AuthenticationException) { } + * }) + * ``` + * + * @param oobCode the out-of-band code from the challenge response + * @param bindingCode the binding code (OTP) entered by the user + * @return an authentication request to configure and start that will yield [Credentials] + */ + public fun verifyWithOob(oobCode: String, bindingCode: String): AuthenticationRequest { + val parameters = ParameterBuilder.newAuthenticationBuilder() + .setGrantType(GRANT_TYPE_MFA_OOB) + .set(MFA_TOKEN_KEY, mfaToken) + .set(OUT_OF_BAND_CODE_KEY, oobCode) + .set(BINDING_CODE_KEY, bindingCode) + .asDictionary() + + return loginWithToken(parameters) + } + + /** + * Verify the MFA challenge with a recovery code. + * Recovery codes are backup codes that can be used when other MFA methods are unavailable. + * + * Example usage: + * ``` + * mfaClient.verifyWithRecoveryCode("{recovery_code}") + * .validateClaims() //mandatory + * .start(object : Callback { + * override fun onSuccess(result: Credentials) { + * // MFA completed successfully + * // result.recoveryCode contains a NEW recovery code to replace the used one + * } + * override fun onFailure(error: AuthenticationException) { } + * }) + * ``` + * + * @param recoveryCode the recovery code to verify + * @return an authentication request to configure and start that will yield [Credentials] + */ + public fun verifyWithRecoveryCode(recoveryCode: String): AuthenticationRequest { + val parameters = ParameterBuilder.newAuthenticationBuilder() + .setGrantType(GRANT_TYPE_MFA_RECOVERY_CODE) + .set(MFA_TOKEN_KEY, mfaToken) + .set(RECOVERY_CODE_KEY, recoveryCode) + .asDictionary() + + return loginWithToken(parameters) + } + + /** + * Helper function to make a request to the /oauth/token endpoint. + */ + private fun loginWithToken(parameters: Map): AuthenticationRequest { + val url = baseURL.toHttpUrl().newBuilder() + .addPathSegment(OAUTH_PATH) + .addPathSegment(TOKEN_PATH) + .build() + + val requestParameters = ParameterBuilder.newBuilder() + .setClientId(clientId) + .addAll(parameters) + .asDictionary() + + val credentialsAdapter: JsonAdapter = GsonAdapter( + Credentials::class.java, gson + ) + + val request = BaseAuthenticationRequest( + factory.post(url.toString(), credentialsAdapter), clientId, baseURL + ) + request.addParameters(requestParameters) + return request + } + + /** + * Creates error adapter for getAuthenticators() operations. + * Returns MfaListAuthenticatorsException with fallback error code if API doesn't provide one. + */ + private fun createListAuthenticatorsErrorAdapter(): ErrorAdapter { + val mapAdapter = GsonAdapter.forMap(gson) + return object : ErrorAdapter { + override fun fromRawResponse( + statusCode: Int, bodyText: String, headers: Map> + ): MfaListAuthenticatorsException { + val values = mapOf("error_description" to bodyText) + return MfaListAuthenticatorsException(values, statusCode) + } + + @Throws(IOException::class) + override fun fromJsonResponse( + statusCode: Int, reader: Reader + ): MfaListAuthenticatorsException { + val values = mapAdapter.fromJson(reader) + return MfaListAuthenticatorsException(values, statusCode) + } + + override fun fromException(cause: Throwable): MfaListAuthenticatorsException { + return if (isNetworkError(cause)) { + MfaListAuthenticatorsException( + code = "network_error", + description = "Failed to execute the network request" + ) + } else { + MfaListAuthenticatorsException( + code = MfaListAuthenticatorsException.FALLBACK_ERROR_CODE, + description = cause.message ?: "Something went wrong" + ) + } + } + } + } + + /** + * Creates error adapter for enroll() operations. + * Returns MfaEnrollmentException with fallback error code if API doesn't provide one. + */ + private fun createEnrollmentErrorAdapter(): ErrorAdapter { + val mapAdapter = GsonAdapter.forMap(gson) + return object : ErrorAdapter { + override fun fromRawResponse( + statusCode: Int, bodyText: String, headers: Map> + ): MfaEnrollmentException { + val values = mapOf("error_description" to bodyText) + return MfaEnrollmentException(values, statusCode) + } + + @Throws(IOException::class) + override fun fromJsonResponse( + statusCode: Int, reader: Reader + ): MfaEnrollmentException { + val values = mapAdapter.fromJson(reader) + return MfaEnrollmentException(values, statusCode) + } + + override fun fromException(cause: Throwable): MfaEnrollmentException { + return if (isNetworkError(cause)) { + MfaEnrollmentException( + code = "network_error", + description = "Failed to execute the network request" + ) + } else { + MfaEnrollmentException( + code = MfaEnrollmentException.FALLBACK_ERROR_CODE, + description = cause.message ?: "Something went wrong" + ) + } + } + } + } + + /** + * Creates error adapter for challenge() operations. + * Returns MfaChallengeException with fallback error code if API doesn't provide one. + */ + private fun createChallengeErrorAdapter(): ErrorAdapter { + val mapAdapter = GsonAdapter.forMap(gson) + return object : ErrorAdapter { + override fun fromRawResponse( + statusCode: Int, bodyText: String, headers: Map> + ): MfaChallengeException { + val values = mapOf("error_description" to bodyText) + return MfaChallengeException(values, statusCode) + } + + @Throws(IOException::class) + override fun fromJsonResponse( + statusCode: Int, reader: Reader + ): MfaChallengeException { + val values = mapAdapter.fromJson(reader) + return MfaChallengeException(values, statusCode) + } + + override fun fromException(cause: Throwable): MfaChallengeException { + return if (isNetworkError(cause)) { + MfaChallengeException( + code = "network_error", + description = "Failed to execute the network request" + ) + } else { + MfaChallengeException( + code = MfaChallengeException.FALLBACK_ERROR_CODE, + description = cause.message ?: "Something went wrong" + ) + } + } + } + } + + /** + * Creates error adapter for verify() operations. + * Returns MfaVerifyException with fallback error code if API doesn't provide one. + */ + private fun createVerifyErrorAdapter(): ErrorAdapter { + val mapAdapter = GsonAdapter.forMap(gson) + return object : ErrorAdapter { + override fun fromRawResponse( + statusCode: Int, bodyText: String, headers: Map> + ): MfaVerifyException { + val values = mapOf("error_description" to bodyText) + return MfaVerifyException(values, statusCode) + } + + @Throws(IOException::class) + override fun fromJsonResponse( + statusCode: Int, reader: Reader + ): MfaVerifyException { + val values = mapAdapter.fromJson(reader) + return MfaVerifyException(values, statusCode) + } + + override fun fromException(cause: Throwable): MfaVerifyException { + return if (isNetworkError(cause)) { + MfaVerifyException( + code = "network_error", + description = "Failed to execute the network request" + ) + } else { + MfaVerifyException( + code = MfaVerifyException.FALLBACK_ERROR_CODE, + description = cause.message ?: "Something went wrong" + ) + } + } + } + } + + private companion object { + private const val MFA_PATH = "mfa" + private const val AUTHENTICATORS_PATH = "authenticators" + private const val CHALLENGE_PATH = "challenge" + private const val ASSOCIATE_PATH = "associate" + private const val OAUTH_PATH = "oauth" + private const val TOKEN_PATH = "token" + private const val HEADER_AUTHORIZATION = "Authorization" + private const val MFA_TOKEN_KEY = "mfa_token" + private const val CHALLENGE_TYPE_KEY = "challenge_type" + private const val AUTHENTICATOR_ID_KEY = "authenticator_id" + private const val AUTHENTICATOR_TYPES_KEY = "authenticator_types" + private const val OOB_CHANNELS_KEY = "oob_channels" + private const val PHONE_NUMBER_KEY = "phone_number" + private const val EMAIL_KEY = "email" + private const val ONE_TIME_PASSWORD_KEY = "otp" + private const val OUT_OF_BAND_CODE_KEY = "oob_code" + private const val BINDING_CODE_KEY = "binding_code" + private const val RECOVERY_CODE_KEY = "recovery_code" + private const val GRANT_TYPE_MFA_OTP = "http://auth0.com/oauth/grant-type/mfa-otp" + private const val GRANT_TYPE_MFA_OOB = "http://auth0.com/oauth/grant-type/mfa-oob" + private const val GRANT_TYPE_MFA_RECOVERY_CODE = "http://auth0.com/oauth/grant-type/mfa-recovery-code" + } +} diff --git a/auth0/src/main/java/com/auth0/android/authentication/MfaException.kt b/auth0/src/main/java/com/auth0/android/authentication/MfaException.kt new file mode 100644 index 00000000..b945a109 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/MfaException.kt @@ -0,0 +1,261 @@ +package com.auth0.android.authentication + +import com.auth0.android.Auth0Exception + +/** + * Base class for MFA-related exceptions. + * All MFA-specific errors inherit from this class for easier error handling. + */ +public sealed class MfaException( + message: String = "An error occurred during MFA operation", + cause: Throwable? = null +) : Auth0Exception(message, cause) { + + /** + * The error code from the API response or SDK validation + */ + public abstract fun getCode(): String + + /** + * The error description providing details about what went wrong + */ + public abstract fun getDescription(): String + + /** + * Http Response status code. Can have value of 0 if not set. + */ + public abstract val statusCode: Int + + /** + * Returns a value from the error map, if any. + * + * @param key key of the value to return + * @return the value if found or null + */ + public abstract fun getValue(key: String): Any? + + /** + * Exception thrown when listing authenticators fails. + * + * SDK-thrown errors: + * - `invalid_request`: challengeType is required and must contain at least one challenge type + * + * Additional errors may be returned by the Auth0 API and forwarded by the SDK. + * + * Example usage: + * ``` + * try { + * val authenticators = mfaClient.getAvailableAuthenticators(listOf("otp", "oob")).await() + * } catch (error: MfaListAuthenticatorsException) { + * when (error.getCode()) { + * "invalid_request" -> println("Invalid request: ${error.getDescription()}") + * else -> println("API error: ${error.getCode()} - ${error.getDescription()}") + * } + * } + * ``` + */ + public class MfaListAuthenticatorsException internal constructor( + private val code: String, + private val description: String, + private val values: Map = emptyMap(), + override val statusCode: Int = 0 + ) : MfaException("MFA authenticator listing failed: $code") { + + internal constructor(values: Map, statusCode: Int) : this( + code = (values["error"] as? String) ?: FALLBACK_ERROR_CODE, + description = (values["error_description"] as? String) ?: "Failed to list authenticators", + values = values, + statusCode = statusCode + ) + + override fun getCode(): String = code + override fun getDescription(): String = description + override fun getValue(key: String): Any? = values[key] + + public companion object { + internal const val FALLBACK_ERROR_CODE = "mfa_list_authenticators_error" + internal const val INVALID_REQUEST = "invalid_request" + + /** + * Creates an exception for SDK validation errors. + */ + internal fun invalidRequest(description: String): MfaListAuthenticatorsException { + return MfaListAuthenticatorsException( + code = INVALID_REQUEST, + description = description + ) + } + } + } + + /** + * Exception thrown when MFA enrollment fails. + * + * All errors come from the Auth0 API. If no error code is provided, + * defaults to `mfa_enrollment_error`. + * + * Example usage: + * ``` + * try { + * val challenge = mfaClient.enroll("phone", "+12025551234").await() + * } catch (error: MfaEnrollmentException) { + * println("Enrollment failed: ${error.getCode()} - ${error.getDescription()}") + * } + * ``` + */ + public class MfaEnrollmentException internal constructor( + private val code: String, + private val description: String, + private val values: Map = emptyMap(), + override val statusCode: Int = 0 + ) : MfaException("MFA enrollment failed: $code") { + + internal constructor(values: Map, statusCode: Int) : this( + code = (values["error"] as? String) ?: FALLBACK_ERROR_CODE, + description = (values["error_description"] as? String) ?: "Failed to enroll MFA authenticator", + values = values, + statusCode = statusCode + ) + + override fun getCode(): String = code + override fun getDescription(): String = description + override fun getValue(key: String): Any? = values[key] + + public companion object { + internal const val FALLBACK_ERROR_CODE = "mfa_enrollment_error" + } + } + + /** + * Exception thrown when MFA challenge fails. + * + * All errors come from the Auth0 API. If no error code is provided, + * defaults to `mfa_challenge_error`. + * + * Example usage: + * ``` + * try { + * val challenge = mfaClient.challenge("sms|dev_123").await() + * } catch (error: MfaChallengeException) { + * println("Challenge failed: ${error.getCode()} - ${error.getDescription()}") + * } + * ``` + */ + public class MfaChallengeException internal constructor( + private val code: String, + private val description: String, + private val values: Map = emptyMap(), + override val statusCode: Int = 0 + ) : MfaException("MFA challenge failed: $code") { + + internal constructor(values: Map, statusCode: Int) : this( + code = (values["error"] as? String) ?: FALLBACK_ERROR_CODE, + description = (values["error_description"] as? String) ?: "Failed to initiate MFA challenge", + values = values, + statusCode = statusCode + ) + + override fun getCode(): String = code + override fun getDescription(): String = description + override fun getValue(key: String): Any? = values[key] + + public companion object { + internal const val FALLBACK_ERROR_CODE = "mfa_challenge_error" + } + } + + /** + * Exception thrown when MFA verification fails. + * + * All errors come from the Auth0 API. If no error code is provided, + * defaults to `mfa_verify_error`. + * + * Example usage: + * ``` + * try { + * val credentials = mfaClient.verifyOtp("123456").await() + * } catch (error: MfaVerifyException) { + * println("Verification failed: ${error.getCode()} - ${error.getDescription()}") + * } + * ``` + */ + public class MfaVerifyException internal constructor( + private val code: String, + private val description: String, + private val values: Map = emptyMap(), + override val statusCode: Int = 0 + ) : MfaException("MFA verification failed: $code") { + + internal constructor(values: Map, statusCode: Int) : this( + code = (values["error"] as? String) ?: FALLBACK_ERROR_CODE, + description = (values["error_description"] as? String) ?: "Failed to verify MFA code", + values = values, + statusCode = statusCode + ) + + override fun getCode(): String = code + override fun getDescription(): String = description + override fun getValue(key: String): Any? = values[key] + + public companion object { + internal const val FALLBACK_ERROR_CODE = "mfa_verify_error" + } + } + + /** + * Exception thrown when MFA is required during token operations. + * + * This error is thrown when multi-factor authentication is required to complete + * a login or token refresh operation. Use the [mfaToken] to create an [MfaApiClient] + * and continue the MFA flow. + * + * Example usage: + * ``` + * try { + * val credentials = authClient.login("user@example.com", "password").await() + * } catch (error: MfaRequiredException) { + * val mfaToken = error.mfaToken + * val requirements = error.mfaRequirements + * + * // Check if user needs to enroll + * if (requirements?.enroll != null) { + * println("Available enrollment types: ${requirements.enroll}") + * } + * + * // Check if user can challenge existing factors + * if (requirements?.challenge != null) { + * println("Available challenge types: ${requirements.challenge}") + * } + * + * // Create MFA client to continue + * if (mfaToken != null) { + * val mfaClient = authClient.mfa(mfaToken) + * // Continue with MFA flow + * } + * } + * ``` + */ + public class MfaRequiredException internal constructor( + private val values: Map, + override val statusCode: Int = 0 + ) : MfaException("Multi-factor authentication required") { + + override fun getCode(): String = "mfa_required" + override fun getDescription(): String = + (values["error_description"] as? String) ?: "Multi-factor authentication required" + override fun getValue(key: String): Any? = values[key] + + /** + * The MFA token to use for subsequent MFA operations + */ + public val mfaToken: String? + get() = getValue("mfa_token") as? String + + /** + * The MFA requirements returned when multi-factor authentication is required. + * Contains information about available enrollment and challenge types. + */ + public val mfaRequirements: Map? + get() = getValue("mfa_requirements") as? Map + } +} diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt index 2be12fc2..290fac6b 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManager.kt @@ -544,6 +544,18 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting saveCredentials(credentials) callback.onSuccess(credentials) } catch (error: AuthenticationException) { + 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 + } val exception = when { error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED @@ -659,9 +671,20 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting saveApiCredentials(newApiCredentials, audience, scope) callback.onSuccess(newApiCredentials) } catch (error: AuthenticationException) { + 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 + } val exception = when { error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK else -> CredentialsManagerException.Code.API_ERROR } diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt index 8f8a981f..ec55d124 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CredentialsManagerException.kt @@ -2,6 +2,7 @@ package com.auth0.android.authentication.storage import com.auth0.android.Auth0Exception import com.auth0.android.result.Credentials +import com.auth0.android.result.MfaRequirements /** * Represents an error raised by the [CredentialsManager]. @@ -46,10 +47,13 @@ public class CredentialsManagerException : NO_NETWORK, API_ERROR, SSO_EXCHANGE_FAILED, + MFA_REQUIRED, UNKNOWN_ERROR } private var code: Code? + private var mfaTokenValue: String? = null + private var mfaRequirementsValue: MfaRequirements? = null internal constructor(code: Code, cause: Throwable? = null) : this( @@ -58,11 +62,19 @@ public class CredentialsManagerException : cause ) - internal constructor(code: Code, message: String, cause: Throwable? = null) : super( + internal constructor( + code: Code, + message: String, + cause: Throwable? = null, + mfaToken: String? = null, + mfaRequirements: MfaRequirements? = null + ) : super( message, cause ) { this.code = code + this.mfaTokenValue = mfaToken + this.mfaRequirementsValue = mfaRequirements } public companion object { @@ -147,6 +159,9 @@ public class CredentialsManagerException : public val SSO_EXCHANGE_FAILED: CredentialsManagerException = CredentialsManagerException(Code.SSO_EXCHANGE_FAILED) + public val MFA_REQUIRED: CredentialsManagerException = + CredentialsManagerException(Code.MFA_REQUIRED) + public val UNKNOWN_ERROR: CredentialsManagerException = CredentialsManagerException(Code.UNKNOWN_ERROR) @@ -194,11 +209,26 @@ public class CredentialsManagerException : Code.NO_NETWORK -> "Failed to execute the network request." Code.API_ERROR -> "An error occurred while processing the request." Code.SSO_EXCHANGE_FAILED ->"The exchange of the refresh token for SSO credentials failed." + Code.MFA_REQUIRED -> "Multi-factor authentication is required to complete the credential renewal." Code.UNKNOWN_ERROR -> "An unknown error has occurred while fetching the token. Please check the error cause for more details." } } } + /** + * 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 + override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is CredentialsManagerException) return false diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt index 70abe7ad..836e8167 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/SecureCredentialsManager.kt @@ -912,6 +912,18 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT fresh.scope ) } catch (error: AuthenticationException) { + 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 + } val exception = when { error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED @@ -1059,9 +1071,20 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT callback.onSuccess(newApiCredentials) } catch (error: AuthenticationException) { + 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 + } val exception = when { error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED - error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK else -> CredentialsManagerException.Code.API_ERROR } diff --git a/auth0/src/main/java/com/auth0/android/result/Authenticator.kt b/auth0/src/main/java/com/auth0/android/result/Authenticator.kt new file mode 100644 index 00000000..188128d0 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/Authenticator.kt @@ -0,0 +1,25 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName + +/** + * Represents an enrolled MFA authenticator. + */ +public data class Authenticator( + @SerializedName("id") + public val id: String, + @SerializedName("type") + public val type: String, + @SerializedName("authenticator_type") + public val authenticatorType: String?, + @SerializedName("active") + public val active: Boolean, + @SerializedName("oob_channel") + public val oobChannel: String?, + @SerializedName("name") + public val name: String?, + @SerializedName("created_at") + public val createdAt: String?, + @SerializedName("last_auth") + public val lastAuth: String? +) diff --git a/auth0/src/main/java/com/auth0/android/result/MfaRequirements.kt b/auth0/src/main/java/com/auth0/android/result/MfaRequirements.kt new file mode 100644 index 00000000..d7abf058 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/MfaRequirements.kt @@ -0,0 +1,20 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName + +/** + * Represents the MFA requirements returned by Auth0 when multi-factor authentication is required. + * Can contain either 'challenge' (for challenging existing authenticators) or 'enroll' (for enrolling new authenticators), + * but not both at the same time. + */ +public data class MfaRequirements( + @SerializedName("challenge") val challenge: List?, + @SerializedName("enroll") val enroll: List? +) + +/** + * Represents a single MFA challenge or enrollment requirement. + */ +public data class MfaChallengeRequirement( + @SerializedName("type") val type: String +)