diff --git a/EXAMPLES.md b/EXAMPLES.md index d124aa8c..c057a0e6 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -15,6 +15,12 @@ - [Authentication API](#authentication-api) - [Login with database connection](#login-with-database-connection) - [Login using MFA with One Time Password code](#login-using-mfa-with-one-time-password-code) + - [MFA Flexible Factors Grant](#mfa-flexible-factors-grant) + - [Handling MFA Required Errors](#handling-mfa-required-errors) + - [Getting Available Authenticators](#getting-available-authenticators) + - [Enrolling New Authenticators](#enrolling-new-authenticators) + - [Challenging an Authenticator](#challenging-an-authenticator) + - [Verifying MFA](#verifying-mfa) - [Passwordless Login](#passwordless-login) - [Step 1: Request the code](#step-1-request-the-code) - [Step 2: Input the code](#step-2-input-the-code) @@ -418,6 +424,326 @@ authentication > The default scope used is `openid profile email`. Regardless of the scopes set to the request, the `openid` scope is always enforced. +### MFA Flexible Factors Grant + +The MFA Flexible Factors Grant allows you to handle MFA challenges during the authentication flow when users sign in to MFA-enabled connections. This feature requires your Application to have the *MFA* grant type enabled. Check [this article](https://auth0.com/docs/clients/client-grant-types) to learn how to enable it. + +#### Handling MFA Required Errors + +When a user signs in to an MFA-enabled connection, the authentication request will fail with an `AuthenticationException` that contains the MFA requirements. You can extract the MFA token and requirements from the error to proceed with the MFA flow. + +```kotlin +authentication + .login("user@example.com", "password", "Username-Password-Authentication") + .validateClaims() + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { + if (exception.isMultifactorRequired) { + // MFA is required - extract the MFA payload + val mfaPayload = exception.mfaRequiredErrorPayload + val mfaToken = mfaPayload?.mfaToken + val requirements = mfaPayload?.mfaRequirements + + // Check what actions are available + val canChallenge = requirements?.challenge // List of authenticators to challenge + val canEnroll = requirements?.enroll // List of factor types that can be enrolled + + // Proceed with MFA flow using mfaToken + } + } + + override fun onSuccess(credentials: Credentials) { + // Login successful without MFA + } + }) +``` + +
+ Using coroutines + +```kotlin +try { + val credentials = authentication + .login("user@example.com", "password", "Username-Password-Authentication") + .validateClaims() + .await() + println(credentials) +} catch (e: AuthenticationException) { + if (e.isMultifactorRequired) { + val mfaPayload = e.mfaRequiredErrorPayload + val mfaToken = mfaPayload?.mfaToken + // Proceed with MFA flow + } +} +``` +
+ +#### Creating the MFA API Client + +Once you have the MFA token, create an MFA API client to perform MFA operations: + +```kotlin +val mfaClient = authentication.mfaClient(mfaToken) +``` + +#### Getting Available Authenticators + +Retrieve the list of authenticators that the user has enrolled and are allowed for this authentication flow. The `factorsAllowed` parameter filters the authenticators based on the allowed factor types from the MFA requirements. + +```kotlin +mfaClient + .getAuthenticators(factorsAllowed = requirements?.challenge ?: emptyList()) + .start(object: Callback, MfaListAuthenticatorsException> { + override fun onFailure(exception: MfaListAuthenticatorsException) { + // Handle error + } + + override fun onSuccess(authenticators: List) { + // Display authenticators for user to choose + authenticators.forEach { auth -> + println("Type: ${auth.authenticatorType}, ID: ${auth.id}") + } + } + }) +``` + +
+ Using coroutines + +```kotlin +try { + val authenticators = mfaClient + .getAuthenticators(factorsAllowed = requirements?.challenge ?: emptyList()) + .await() + println(authenticators) +} catch (e: MfaListAuthenticatorsException) { + e.printStackTrace() +} +``` +
+ +#### Enrolling New Authenticators + +If the user doesn't have an authenticator enrolled, or needs to enroll a new one, you can use the enrollment methods. The available enrollment types depend on your tenant configuration. + +##### Enroll Phone (SMS/Voice) + +```kotlin +mfaClient + .enrollPhone("+11234567890", PhoneEnrollmentType.SMS) + .start(object: Callback { + override fun onFailure(exception: MfaEnrollmentException) { } + + override fun onSuccess(enrollment: MfaEnrollment) { + // Phone enrolled - need to verify with OOB code + val oobCode = enrollment.oobCode + val bindingMethod = enrollment.bindingMethod + } + }) +``` + +##### Enroll Email + +```kotlin +mfaClient + .enrollEmail("user@example.com") + .start(object: Callback { + override fun onFailure(exception: MfaEnrollmentException) { } + + override fun onSuccess(enrollment: MfaEnrollment) { + // Email enrolled - need to verify with OOB code + val oobCode = enrollment.oobCode + } + }) +``` + +##### Enroll OTP (Authenticator App) + +```kotlin +mfaClient + .enrollOtp() + .start(object: Callback { + override fun onFailure(exception: MfaEnrollmentException) { } + + override fun onSuccess(enrollment: MfaEnrollment) { + // Display QR code or secret for user to scan/enter in authenticator app + val secret = enrollment.secret + val barcodeUri = enrollment.barcodeUri + } + }) +``` + +##### Enroll Push Notification + +```kotlin +mfaClient + .enrollPush() + .start(object: Callback { + override fun onFailure(exception: MfaEnrollmentException) { } + + override fun onSuccess(enrollment: MfaEnrollment) { + // Display QR code for user to scan with Guardian app + val barcodeUri = enrollment.barcodeUri + } + }) +``` + +#### Challenging an Authenticator + +After selecting an authenticator, initiate a challenge. This will send an OTP code (for email/SMS) or push notification to the user. + +```kotlin +mfaClient + .challenge(authenticatorId = "phone|dev_xxxx") + .start(object: Callback { + override fun onFailure(exception: MfaChallengeException) { } + + override fun onSuccess(challengeResponse: MfaChallengeResponse) { + // Challenge initiated + val challengeType = challengeResponse.challengeType + val oobCode = challengeResponse.oobCode + val bindingMethod = challengeResponse.bindingMethod + } + }) +``` + +
+ Using coroutines + +```kotlin +try { + val challengeResponse = mfaClient + .challenge(authenticatorId = "phone|dev_xxxx") + .await() + println(challengeResponse) +} catch (e: MfaChallengeException) { + e.printStackTrace() +} +``` +
+ +#### Verifying MFA + +Complete the MFA flow by verifying with the appropriate method based on the authenticator type. + +##### Verify with OTP (Authenticator App) + +```kotlin +mfaClient + .verifyOtp(otp = "123456") + .validateClaims() + .start(object: Callback { + override fun onFailure(exception: MfaVerifyException) { } + + override fun onSuccess(credentials: Credentials) { + // MFA verification successful - user is now logged in + } + }) +``` + +
+ Using coroutines + +```kotlin +try { + val credentials = mfaClient + .verifyOtp(otp = "123456") + .validateClaims() + .await() + println(credentials) +} catch (e: MfaVerifyException) { + e.printStackTrace() +} +``` +
+ +##### Verify with OOB (Email/SMS/Push) + +For email, SMS, or push notification verification, use the OOB code from the challenge response along with the binding code (OTP) received by the user: + +```kotlin +mfaClient + .verifyOob(oobCode = oobCode, bindingCode = "123456") // bindingCode is optional for push + .validateClaims() + .start(object: Callback { + override fun onFailure(exception: MfaVerifyException) { } + + override fun onSuccess(credentials: Credentials) { + // MFA verification successful + } + }) +``` + +##### Verify with Recovery Code + +If the user has lost access to their MFA device, they can use a recovery code: + +```kotlin +mfaClient + .verifyRecoveryCode(recoveryCode = "ABCD1234EFGH5678") + .validateClaims() + .start(object: Callback { + override fun onFailure(exception: MfaVerifyException) { } + + override fun onSuccess(credentials: Credentials) { + // MFA verification successful + // Note: A new recovery code may be returned in credentials + } + }) +``` + +#### Complete MFA Flow Example + +Here's a complete example showing the typical MFA flow: + +```kotlin +// Step 1: Attempt login +authentication + .login(email, password, connection) + .validateClaims() + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { + if (exception.isMultifactorRequired) { + val mfaPayload = exception.mfaRequiredErrorPayload ?: return + val mfaToken = mfaPayload.mfaToken ?: return + val requirements = mfaPayload.mfaRequirements + + // Step 2: Create MFA client + val mfaClient = authentication.mfaClient(mfaToken) + + // Step 3: Get available authenticators + mfaClient + .getAuthenticators(factorsAllowed = requirements?.challenge ?: emptyList()) + .start(object: Callback, MfaListAuthenticatorsException> { + override fun onSuccess(authenticators: List) { + if (authenticators.isNotEmpty()) { + // Step 4: Challenge the first authenticator + val authenticator = authenticators.first() + mfaClient + .challenge(authenticatorId = authenticator.id) + .start(object: Callback { + override fun onSuccess(challengeResponse: MfaChallengeResponse) { + // Step 5: Prompt user for OTP and verify + // ... show OTP input UI, then call verifyOtp/verifyOob + } + override fun onFailure(e: MfaChallengeException) { } + }) + } else { + // No authenticators enrolled - need to enroll one + // ... show enrollment UI + } + } + override fun onFailure(e: MfaListAuthenticatorsException) { } + }) + } + } + + override fun onSuccess(credentials: Credentials) { + // Login successful without MFA + } + }) +``` + ### Passwordless Login This feature requires your Application to have the *Passwordless OTP* enabled. See [this article](https://auth0.com/docs/clients/client-grant-types) to learn how to enable it. 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..90bb178b 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.mfaClient(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 mfaClient(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" 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..17166fe8 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,10 @@ 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.MfaFactor +import com.auth0.android.result.MfaRequiredErrorPayload +import com.auth0.android.result.MfaRequirements public class AuthenticationException : Auth0Exception { private var code: String? = null @@ -147,6 +151,52 @@ public class AuthenticationException : Auth0Exception { public val isMultifactorEnrollRequired: Boolean get() = "a0.mfa_registration_required" == code || "unsupported_challenge_type" == code + /** + * Extracts the MFA required error payload when multifactor authentication is required. + * + * This property decodes the error values into a structured [MfaRequiredErrorPayload] object + * containing the MFA token and enrollment requirements. + * + * ## Usage + * + * ```kotlin + * if (error.isMultifactorRequired) { + * val mfaPayload = error.mfaRequiredErrorPayload + * val mfaToken = mfaPayload?.mfaToken + * val enrollmentTypes = mfaPayload?.mfaRequirements?.enroll + * } + * ``` + * + * @see isMultifactorRequired + * @see MfaRequiredErrorPayload + */ + public val mfaRequiredErrorPayload: MfaRequiredErrorPayload? + get() { + val mfaToken = getValue("mfa_token") as? String ?: return null + val errorCode = getCode() + val errorDesc = getDescription() + val requirements = getValue("mfa_requirements") as? Map<*, *> + + @Suppress("UNCHECKED_CAST") + val challengeList = (requirements?.get("challenge") as? List>)?.map { + MfaFactor(it["type"] as? String ?: "") + } + + @Suppress("UNCHECKED_CAST") + val enrollList = (requirements?.get("enroll") as? List>)?.map { + MfaFactor(it["type"] as? String ?: "") + } + + return MfaRequiredErrorPayload( + error = errorCode, + errorDescription = errorDesc, + mfaToken = mfaToken, + mfaRequirements = if (challengeList != null || enrollList != null) { + MfaRequirements(enroll = enrollList, challenge = challengeList) + } else null + ) + } + /// 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..bb74ba83 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/MfaApiClient.kt @@ -0,0 +1,781 @@ +package com.auth0.android.authentication + +import androidx.annotation.VisibleForTesting +import com.auth0.android.Auth0 +import com.auth0.android.Auth0Exception +import com.auth0.android.authentication.MfaException.* +import com.auth0.android.request.ErrorAdapter +import com.auth0.android.request.JsonAdapter +import com.auth0.android.request.Request +import com.auth0.android.request.RequestOptions +import com.auth0.android.request.RequestValidator +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 provides methods to handle MFA challenges and enrollments following + * the Auth0 MFA API. It is typically obtained from [AuthenticationAPIClient.mfaClient] + * after receiving an `mfa_required` error during authentication. + * + * ## Usage + * + * ```kotlin + * val authClient = AuthenticationAPIClient(auth0) + * try { + * val credentials = authClient.login("user@example.com", "password").await() + * } catch (error: AuthenticationException) { + * if (error.isMultifactorRequired) { + * val mfaPayload = error.mfaRequiredErrorPayload + * if (mfaPayload != null) { + * val mfaClient = authClient.mfaClient(mfaPayload.mfaToken) + * // Use mfaClient to handle MFA flow + * } + * } + * } + * ``` + * + * @see AuthenticationAPIClient.mfaClient + * @see [MFA API Documentation](https://auth0.com/docs/api/authentication#multi-factor-authentication) + */ +public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor( + private val auth0: Auth0, + private val mfaToken: String, + 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, + GsonProvider.gson + ) + + private val clientId: String + get() = auth0.clientId + private val baseURL: String + get() = auth0.getDomainUrl() + + /** + * Retrieves the list of available authenticators for the user, filtered by the specified factor types. + * + * This endpoint returns all available authenticators that the user can use for MFA, + * filtered by the specified factor types. The filtering is performed by the SDK after + * receiving the response from the API. + * + * ## Usage + * + * ```kotlin + * mfaClient.getAuthenticators(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 Array of factor types to filter the authenticators (e.g., `["otp", "oob", "recovery-code"]`). + * Must contain at least one factor type. + * @return a request to configure and start that will yield a list of [Authenticator] + * + * @see [Authentication API Endpoint](https://auth0.com/docs/api/authentication#list-authenticators) + */ + public fun getAuthenticators( + factorsAllowed: List + ): Request, MfaListAuthenticatorsException> { + val url = baseURL.toHttpUrl().newBuilder() + .addPathSegment(MFA_PATH) + .addPathSegment(AUTHENTICATORS_PATH) + .build() + + val authenticatorsAdapter = createFilteringAuthenticatorsAdapter(factorsAllowed) + + val request = listAuthenticatorsFactory.get(url.toString(), authenticatorsAdapter) + .addHeader(HEADER_AUTHORIZATION, "Bearer $mfaToken") + + request.addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { + if (factorsAllowed.isEmpty()) { + throw MfaListAuthenticatorsException.invalidRequest( + "factorsAllowed is required and must contain at least one challenge type." + ) + } + } + }) + + return request + } + + /** + * Creates a JSON adapter that filters and deduplicates authenticators based on allowed factor types. + * + * This processing is performed internally by the SDK after receiving the API response. + * The client only specifies which factor types are allowed; all filtering and deduplication + * logic is handled transparently by the SDK. + * + * **Filtering:** + * Authenticators are filtered by their effective type: + * - OOB authenticators: matched by their channel ("sms" or "email") + * - Other authenticators: matched by their type ("otp", "recovery-code", etc.) + * + * **Deduplication:** + * Multiple enrollments of the same phone number or email are consolidated: + * - Active authenticators are preferred over inactive ones + * - Among authenticators with the same status, the most recently created is kept + * + * @param factorsAllowed List of factor types to include (e.g., ["sms", "email", "otp"]) + * @return A JsonAdapter that produces a filtered and deduplicated list of authenticators + */ + private fun createFilteringAuthenticatorsAdapter(factorsAllowed: List): JsonAdapter> { + val baseAdapter = GsonAdapter.forListOf(Authenticator::class.java, gson) + return object : JsonAdapter> { + override fun fromJson(reader: Reader, metadata: Map): List { + val allAuthenticators = baseAdapter.fromJson(reader, metadata) + + val filtered = allAuthenticators.filter { authenticator -> + matchesFactorType(authenticator, factorsAllowed) + } + + return deduplicateAuthenticators(filtered) + } + } + } + + /** + * Checks if an authenticator matches any of the allowed factor types. + * + * The matching logic handles various factor type aliases: + * - "sms" or "phone": matches OOB authenticators with SMS channel + * - "email": matches OOB authenticators with email channel + * - "otp" or "totp": matches time-based one-time password authenticators + * - "oob": matches any out-of-band authenticator regardless of channel + * - "recovery-code": matches recovery code authenticators + * - "push-notification": matches push notification authenticators + * + * @param authenticator The authenticator to check + * @param factorsAllowed List of allowed factor types + * @return true if the authenticator matches any allowed factor type + */ + private fun matchesFactorType(authenticator: Authenticator, factorsAllowed: List): Boolean { + val effectiveType = getEffectiveType(authenticator) + + return factorsAllowed.any { factor -> + when (factor.lowercase(java.util.Locale.ROOT)) { + "sms", "phone" -> effectiveType == "sms" || effectiveType == "phone" + "email" -> effectiveType == "email" + "otp", "totp" -> effectiveType == "otp" || effectiveType == "totp" + "oob" -> authenticator.authenticatorType == "oob" + "recovery-code" -> effectiveType == "recovery-code" + "push-notification" -> effectiveType == "push-notification" + else -> effectiveType == factor || authenticator.authenticatorType == factor + } + } + } + + /** + * Resolves the effective type of an authenticator for filtering purposes. + * + * OOB (out-of-band) authenticators use their channel ("sms" or "email") as the + * effective type, since users typically filter by delivery method rather than + * the generic "oob" type. Other authenticators use their authenticatorType directly. + * + * @param authenticator The authenticator to get the type for + * @return The effective type string used for filtering + */ + private fun getEffectiveType(authenticator: Authenticator): String { + return when (authenticator.authenticatorType) { + "oob" -> authenticator.oobChannel ?: "oob" + else -> authenticator.authenticatorType ?: authenticator.type ?: "" + } + } + + /** + * Removes duplicate authenticators to return only the most relevant enrollment per identity. + * + * Users may have multiple enrollments for the same phone number or email address + * (e.g., from re-enrolling after failed attempts). This method consolidates them + * to present a clean list: + * + * **Grouping strategy:** + * - SMS/Email (OOB): grouped by channel + name (e.g., all "+1234567890" SMS entries) + * - TOTP: each authenticator is unique (different authenticator apps) + * - Recovery code: only one per user + * + * **Selection criteria (in order of priority):** + * 1. Active authenticators are preferred over inactive ones + * 2. Among same status, the most recently created is selected + * + * @param authenticators The list of authenticators to deduplicate + * @return A deduplicated list with one authenticator per unique identity + */ + private fun deduplicateAuthenticators(authenticators: List): List { + val grouped = authenticators.groupBy { authenticator -> + when (authenticator.authenticatorType) { + "oob" -> { + val channel = authenticator.oobChannel ?: "unknown" + val name = authenticator.name ?: authenticator.id + "$channel:$name" + } + "otp" -> { + authenticator.id + } + "recovery-code" -> { + "recovery-code" + } + else -> { + authenticator.id + } + } + } + + return grouped.values.map { group -> + group.sortedWith( + compareByDescending { it.active } + .thenByDescending { it.createdAt ?: "" } + ).first() + } + } + + /** + * Enrolls a phone number for SMS-based MFA. + * + * This method initiates the enrollment of a phone number as an MFA factor. An SMS with a verification + * code will be sent to the specified phone number. + * + * ## Usage + * + * ```kotlin + * mfaClient.enrollPhone("+12025550135") + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * println("Enrollment initiated: ${result.oobCode}") + * } + * override fun onFailure(error: MfaEnrollmentException) { } + * }) + * ``` + * + * @param phoneNumber The phone number to enroll, including country code (e.g., `+12025550135`). + * @return a request to configure and start that will yield [EnrollmentChallenge] + * + * @see [Authentication API Endpoint](https://auth0.com/docs/api/authentication#enroll-and-challenge-a-sms-or-voice-authenticator) + */ + public fun enrollPhone(phoneNumber: String): Request { + return enrollOob(oobChannel = "sms", phoneNumber = phoneNumber) + } + + + /** + * Enrolls an email address for email-based MFA. + * + * This method initiates the enrollment of an email address as an MFA factor. Verification codes + * will be sent to the specified email address during authentication. + * + * ## Usage + * + * ```kotlin + * mfaClient.enrollEmail("user@example.com") + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * println("Email enrollment initiated: ${result.oobCode}") + * } + * override fun onFailure(error: MfaEnrollmentException) { } + * }) + * ``` + * + * @param email The email address to enroll for MFA. + * @return a request to configure and start that will yield [EnrollmentChallenge] + * + * @see [Authentication API Endpoint](https://auth0.com/docs/api/authentication#enroll-and-challenge-a-email-authenticator) + */ + public fun enrollEmail(email: String): Request { + return enrollOob(oobChannel = "email", email = email) + } + + + /** + * Enrolls a time-based one-time password (TOTP) authenticator for MFA. + * + * This method initiates the enrollment of an authenticator app (like Google Authenticator or Authy) + * as an MFA factor. It returns a challenge containing a QR code and secret that can be scanned + * by the authenticator app. + * + * ## Usage + * + * ```kotlin + * mfaClient.enrollOtp() + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * println("QR Code URI: ${result.barcodeUri}") + * println("Secret: ${result.secret}") + * } + * override fun onFailure(error: MfaEnrollmentException) { } + * }) + * ``` + * + * @return a request to configure and start that will yield [EnrollmentChallenge] containing QR code and secret. + * + * @see [Authentication API Endpoint](https://auth0.com/docs/api/authentication#enroll-and-challenge-a-otp-authenticator) + */ + public fun enrollOtp(): Request { + val url = baseURL.toHttpUrl().newBuilder() + .addPathSegment(MFA_PATH) + .addPathSegment(ASSOCIATE_PATH) + .build() + + val enrollmentAdapter: JsonAdapter = GsonAdapter( + EnrollmentChallenge::class.java, gson + ) + + return enrollmentFactory.post(url.toString(), enrollmentAdapter) + .addHeader(HEADER_AUTHORIZATION, "Bearer $mfaToken") + .addParameter(AUTHENTICATOR_TYPES_KEY, listOf("otp")) + } + + + /** + * Enrolls push notification as an MFA factor. + * + * This method initiates the enrollment of Auth0 Guardian push notifications as an MFA factor. + * Users will receive authentication requests via push notifications on their enrolled device. + * + * ## Usage + * + * ```kotlin + * mfaClient.enrollPush() + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * println("Push enrollment challenge: ${result.oobCode}") + * } + * override fun onFailure(error: MfaEnrollmentException) { } + * }) + * ``` + * + * @return a request to configure and start that will yield [EnrollmentChallenge] + * + * @see [Authentication API Endpoint](https://auth0.com/docs/api/authentication#enroll-and-challenge-push-notifications) + * @see [Auth0 Guardian](https://auth0.com/docs/secure/multi-factor-authentication/auth0-guardian) + */ + public fun enrollPush(): Request { + return enrollOob(oobChannel = "auth0") + } + + + /** + * Initiates an MFA challenge for an enrolled authenticator. + * + * This method requests a challenge (e.g., OTP code via SMS) for an already enrolled MFA factor. + * The user must complete the challenge to authenticate successfully. + * + * ## Usage + * + * ```kotlin + * mfaClient.challenge("sms|dev_authenticator_id") + * .start(object : Callback { + * override fun onSuccess(result: Challenge) { + * println("Challenge sent: ${result.oobCode}") + * } + * override fun onFailure(error: MfaChallengeException) { } + * }) + * ``` + * + * @param authenticatorId The ID of the enrolled authenticator. + * @return a request to configure and start that will yield [Challenge] + * + * @see [Authentication API Endpoint](https://auth0.com/docs/api/authentication#challenge-with-sms-oob-otp) + */ + public fun challenge(authenticatorId: String): Request { + val parameters = ParameterBuilder.newBuilder() + .setClientId(clientId) + .set(MFA_TOKEN_KEY, mfaToken) + .set(CHALLENGE_TYPE_KEY, "oob") + .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) + } + + + + /** + * Verifies an out-of-band (OOB) MFA challenge using a code received via SMS or email. + * + * This method completes the MFA authentication flow by verifying the OTP code sent to the user's + * phone or email. Upon successful verification, user credentials are returned. + * + * ## Usage + * + * ```kotlin + * mfaClient.verifyOob(oobCode = "oob_code", bindingCode = "123456") + * .start(object : Callback { + * override fun onSuccess(result: Credentials) { + * println("Obtained credentials: ${result.accessToken}") + * } + * override fun onFailure(error: MfaVerifyException) { } + * }) + * ``` + * + * @param oobCode The out-of-band code from the challenge response. + * @param bindingCode Optional binding code for additional security verification. + * @return a request to configure and start that will yield [Credentials] + * + * @see [Authentication API Endpoint](https://auth0.com/docs/api/authentication#verify-with-out-of-band-oob) + */ + public fun verifyOob( + oobCode: String, + bindingCode: String? = null + ): Request { + val parametersBuilder = ParameterBuilder.newBuilder() + .setClientId(clientId) + .setGrantType(GRANT_TYPE_MFA_OOB) + .set(MFA_TOKEN_KEY, mfaToken) + .set(OUT_OF_BAND_CODE_KEY, oobCode) + + if (bindingCode != null) { + parametersBuilder.set(BINDING_CODE_KEY, bindingCode) + } + + return tokenRequest(parametersBuilder.asDictionary()) + } + + + + /** + * Verifies an MFA challenge using a one-time password (OTP) code. + * + * This method completes the MFA authentication flow by verifying the OTP code from the user's + * authenticator app. Upon successful verification, user credentials are returned. + * + * ## Usage + * + * ```kotlin + * mfaClient.verifyOtp("123456") + * .start(object : Callback { + * override fun onSuccess(result: Credentials) { + * println("Obtained credentials: ${result.accessToken}") + * } + * override fun onFailure(error: MfaVerifyException) { } + * }) + * ``` + * + * @param otp The 6-digit one-time password code from the authenticator app. + * @return a request to configure and start that will yield [Credentials] + * + * @see [Authentication API Endpoint](https://auth0.com/docs/api/authentication#verify-with-one-time-password-otp) + */ + public fun verifyOtp(otp: String): Request { + val parameters = ParameterBuilder.newBuilder() + .setClientId(clientId) + .setGrantType(GRANT_TYPE_MFA_OTP) + .set(MFA_TOKEN_KEY, mfaToken) + .set(ONE_TIME_PASSWORD_KEY, otp) + .asDictionary() + + return tokenRequest(parameters) + } + + + + /** + * Verifies an MFA challenge using a recovery code. + * + * This method allows users to authenticate when they don't have access to their primary MFA factor. + * Recovery codes are typically provided during MFA enrollment and should be stored securely. + * + * ## Usage + * + * ```kotlin + * mfaClient.verifyRecoveryCode("RECOVERY_CODE_123") + * .start(object : Callback { + * override fun onSuccess(result: Credentials) { + * println("Obtained credentials: ${result.accessToken}") + * // result.recoveryCode contains a NEW recovery code to replace the used one + * } + * override fun onFailure(error: MfaVerifyException) { } + * }) + * ``` + * + * @param recoveryCode The recovery code provided during MFA enrollment. + * @return a request to configure and start that will yield [Credentials] + * + * @see [Authentication API Endpoint](https://auth0.com/docs/api/authentication#verify-with-recovery-code) + */ + public fun verifyRecoveryCode(recoveryCode: String): Request { + val parameters = ParameterBuilder.newBuilder() + .setClientId(clientId) + .setGrantType(GRANT_TYPE_MFA_RECOVERY_CODE) + .set(MFA_TOKEN_KEY, mfaToken) + .set(RECOVERY_CODE_KEY, recoveryCode) + .asDictionary() + + return tokenRequest(parameters) + } + + + + /** + * Helper function for OOB enrollment (SMS, email, push). + */ + private fun enrollOob( + oobChannel: String, + phoneNumber: String? = null, + email: String? = null + ): Request { + 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) + .addHeader(HEADER_AUTHORIZATION, "Bearer $mfaToken") + .addParameter(AUTHENTICATOR_TYPES_KEY, listOf("oob")) + .addParameter(OOB_CHANNELS_KEY, listOf(oobChannel)) + + if (phoneNumber != null) { + request.addParameter(PHONE_NUMBER_KEY, phoneNumber) + } + if (email != null) { + request.addParameter(EMAIL_KEY, email) + } + + return request + } + + /** + * Helper function to make a request to the /oauth/token endpoint. + */ + private fun tokenRequest(parameters: Map): Request { + val url = baseURL.toHttpUrl().newBuilder() + .addPathSegment(OAUTH_PATH) + .addPathSegment(TOKEN_PATH) + .build() + + val credentialsAdapter: JsonAdapter = GsonAdapter( + Credentials::class.java, gson + ) + + return verifyFactory.post(url.toString(), credentialsAdapter) + .addParameters(parameters) + } + + + + /** + * Creates error adapter for getAuthenticators() operations. + */ + 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 = Auth0Exception.UNKNOWN_ERROR, + description = cause.message ?: "Something went wrong" + ) + } + } + } + } + + /** + * Creates error adapter for enroll() operations. + */ + 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 = Auth0Exception.UNKNOWN_ERROR, + description = cause.message ?: "Something went wrong" + ) + } + } + } + } + + /** + * Creates error adapter for challenge() operations. + */ + 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 = Auth0Exception.UNKNOWN_ERROR, + description = cause.message ?: "Something went wrong" + ) + } + } + } + } + + /** + * Creates error adapter for verify() operations. + */ + 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 = Auth0Exception.UNKNOWN_ERROR, + 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..7ae0a575 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/MfaException.kt @@ -0,0 +1,192 @@ +package com.auth0.android.authentication + +import com.auth0.android.Auth0Exception +import com.auth0.android.Auth0Exception.Companion.UNKNOWN_ERROR + +/** + * 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) ?: UNKNOWN_ERROR, + 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 INVALID_REQUEST = "invalid_request" + + /**feature discovery on the SDKevaluating/learning the usage patternsimplementationdeployment to production + * 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 `a0.sdk.internal_error.unknown`. + * + * 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) ?: UNKNOWN_ERROR, + 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] + } + + /** + * Exception thrown when MFA challenge fails. + * + * All errors come from the Auth0 API. If no error code is provided, + * defaults to `a0.sdk.internal_error.unknown`. + * + * 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) ?: UNKNOWN_ERROR, + 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] + } + + /** + * Exception thrown when MFA verification fails. + * + * All errors come from the Auth0 API. If no error code is provided, + * defaults to `a0.sdk.internal_error.unknown`. + * + * 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) ?: UNKNOWN_ERROR, + 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] + } +} 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..31f8e62f 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,17 @@ 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.mfaRequiredErrorPayload + ) + ) + return@execute + } val exception = when { error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED @@ -659,9 +670,19 @@ 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.mfaRequiredErrorPayload + ) + ) + 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..9796dbe6 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.MfaRequiredErrorPayload /** * Represents an error raised by the [CredentialsManager]. @@ -46,10 +47,12 @@ public class CredentialsManagerException : NO_NETWORK, API_ERROR, SSO_EXCHANGE_FAILED, + MFA_REQUIRED, UNKNOWN_ERROR } private var code: Code? + private var mfaRequiredErrorPayloadValue: MfaRequiredErrorPayload? = null internal constructor(code: Code, cause: Throwable? = null) : this( @@ -58,11 +61,17 @@ public class CredentialsManagerException : cause ) - internal constructor(code: Code, message: String, cause: Throwable? = null) : super( + internal constructor( + code: Code, + message: String, + cause: Throwable? = null, + mfaRequiredErrorPayload: MfaRequiredErrorPayload? = null + ) : super( message, cause ) { this.code = code + this.mfaRequiredErrorPayloadValue = mfaRequiredErrorPayload } public companion object { @@ -147,6 +156,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 +206,29 @@ 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 required error payload when multi-factor authentication is required. + * This contains the MFA token and requirements for completing the authentication flow. + * This is only available when the error code is [Code.MFA_REQUIRED]. + */ + @get:JvmName("getMfaRequiredErrorPayload") + public val mfaRequiredErrorPayload: MfaRequiredErrorPayload? + get() = mfaRequiredErrorPayloadValue + + /** + * The MFA token required to continue the multi-factor authentication flow. + * This is only available when the error code is [Code.MFA_REQUIRED]. + */ + @get:JvmName("getMfaToken") + public val mfaToken: String? + get() = mfaRequiredErrorPayloadValue?.mfaToken + 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..a6e86c49 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,17 @@ 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.mfaRequiredErrorPayload + ) + ) + return@execute + } val exception = when { error.isRefreshTokenDeleted || error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED @@ -1059,9 +1070,19 @@ 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.mfaRequiredErrorPayload + ) + ) + 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/EnrollmentChallenge.kt b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt index f79df9ab..03e7bbb0 100644 --- a/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt +++ b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt @@ -10,7 +10,8 @@ import java.lang.reflect.Type @JsonAdapter(EnrollmentChallenge.Deserializer::class) public sealed class EnrollmentChallenge { public abstract val id: String? - public abstract val authSession: String + public abstract val authSession: String? + public open val oobCode: String? = null internal class Deserializer : JsonDeserializer { override fun deserialize( @@ -23,6 +24,7 @@ public sealed class EnrollmentChallenge { jsonObject.has("barcode_uri") -> TotpEnrollmentChallenge::class.java jsonObject.has("recovery_code") -> RecoveryCodeEnrollmentChallenge::class.java jsonObject.has("authn_params_public_key") -> PasskeyEnrollmentChallenge::class.java + jsonObject.has("oob_code") -> OobEnrollmentChallenge::class.java else -> MfaEnrollmentChallenge::class.java } return context.deserialize(jsonObject, targetClass) @@ -32,9 +34,24 @@ public sealed class EnrollmentChallenge { public data class MfaEnrollmentChallenge( @SerializedName("id") - override val id: String, + override val id: String?, + @SerializedName("auth_session") + override val authSession: String? +) : EnrollmentChallenge() + +/** + * Enrollment challenge for OOB factors (SMS/Email) that includes the oob_code + * needed for verification. + */ +public data class OobEnrollmentChallenge( + @SerializedName("id") + override val id: String?, @SerializedName("auth_session") - override val authSession: String + override val authSession: String?, + @SerializedName("oob_code") + override val oobCode: String?, + @SerializedName("binding_method") + public val bindingMethod: String? = null ) : EnrollmentChallenge() public data class TotpEnrollmentChallenge( 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..bb57d23a --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/MfaRequirements.kt @@ -0,0 +1,65 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName + +/** + * Represents the payload returned when multifactor authentication is required. + * + * This structure contains the MFA token needed to complete the authentication flow + * and the available enrollment options for MFA factors. + * + * ## Usage + * + * ```kotlin + * if (error.isMultifactorRequired) { + * val mfaPayload = error.mfaRequiredErrorPayload + * val mfaToken = mfaPayload?.mfaToken + * val enrollmentTypes = mfaPayload?.mfaRequirements?.enroll?.map { it.type } + * } + * ``` + * + * @see [com.auth0.android.authentication.AuthenticationException.isMultifactorRequired] + * @see [com.auth0.android.authentication.AuthenticationException.mfaRequiredErrorPayload] + */ +public data class MfaRequiredErrorPayload( + /** The error code returned by Auth0 (e.g., "mfa_required"). */ + @SerializedName("error") val error: String, + + /** A human-readable description of the error. */ + @SerializedName("error_description") val errorDescription: String, + + /** The MFA token required to complete the authentication flow. */ + @SerializedName("mfa_token") val mfaToken: String, + + /** The MFA requirements containing available enrollment options. */ + @SerializedName("mfa_requirements") val mfaRequirements: MfaRequirements? +) + +/** + * Represents the MFA requirements including enrollment and challenge options. + * + * Can contain either 'challenge' (for challenging existing authenticators) or 'enroll' + * (for enrolling new authenticators). + */ +public data class MfaRequirements( + /** Array of available MFA enrollment types. */ + @SerializedName("enroll") val enroll: List?, + + /** Array of available MFA challenge types. */ + @SerializedName("challenge") val challenge: List? +) + +/** + * Represents an MFA factor type option. + * + * Common factor types include: + * - `"recovery-code"`: Recovery codes for account recovery + * - `"otp"`: Time-based one-time password (TOTP) + * - `"phone"`: SMS-based authentication + * - `"push-notification"`: Push notification-based authentication + * - `"email"`: Email-based authentication + */ +public data class MfaFactor( + /** The type of MFA factor available for enrollment or challenge. */ + @SerializedName("type") val type: String +) diff --git a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt new file mode 100644 index 00000000..622c54ea --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt @@ -0,0 +1,821 @@ +package com.auth0.android.authentication + +import com.auth0.android.Auth0 +import com.auth0.android.authentication.MfaException.* +import com.auth0.android.callback.Callback +import com.auth0.android.request.internal.ThreadSwitcherShadow +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.auth0.android.result.MfaEnrollmentChallenge +import com.auth0.android.result.TotpEnrollmentChallenge +import com.auth0.android.util.SSLTestUtils +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.* +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowLooper +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +@RunWith(RobolectricTestRunner::class) +@Config(shadows = [ThreadSwitcherShadow::class]) +@OptIn(ExperimentalCoroutinesApi::class) +public class MfaApiClientTest { + + private lateinit var mockServer: MockWebServer + private lateinit var auth0: Auth0 + private lateinit var mfaClient: MfaApiClient + private lateinit var gson: Gson + + @Before + public fun setUp(): Unit { + mockServer = SSLTestUtils.createMockWebServer() + mockServer.start() + val domain = mockServer.url("/").toString() + auth0 = Auth0.getInstance(CLIENT_ID, domain, domain) + auth0.networkingClient = SSLTestUtils.testClient + mfaClient = MfaApiClient(auth0, MFA_TOKEN) + gson = GsonBuilder().serializeNulls().create() + } + + @After + public fun tearDown(): Unit { + mockServer.shutdown() + } + + private fun enqueueMockResponse(json: String, statusCode: Int = 200): Unit { + mockServer.enqueue( + MockResponse() + .setResponseCode(statusCode) + .addHeader("Content-Type", "application/json") + .setBody(json) + ) + } + + private fun enqueueErrorResponse(error: String, description: String, statusCode: Int = 400): Unit { + val json = """{"error": "$error", "error_description": "$description"}""" + enqueueMockResponse(json, statusCode) + } + + private inline fun bodyFromRequest(request: RecordedRequest): Map { + val mapType = object : TypeToken>() {}.type + return gson.fromJson(request.body.readUtf8(), mapType) + } + + + @Test + public fun shouldCreateClientWithAuth0AndMfaToken(): Unit { + val client = MfaApiClient(auth0, "test_mfa_token") + assertThat(client, `is`(notNullValue())) + } + + + @Test + public fun shouldGetAuthenticatorsSuccess(): Unit = runTest { + val json = """[ + {"id": "sms|dev_123", "type": "oob", "authenticator_type": "oob", "active": true, "oob_channel": "sms"}, + {"id": "totp|dev_456", "type": "otp", "authenticator_type": "otp", "active": true} + ]""" + enqueueMockResponse(json) + + val authenticators = mfaClient.getAuthenticators(listOf("oob", "otp")).await() + + assertThat(authenticators, hasSize(2)) + assertThat(authenticators[0].id, `is`("sms|dev_123")) + assertThat(authenticators[0].type, `is`("oob")) + assertThat(authenticators[1].id, `is`("totp|dev_456")) + assertThat(authenticators[1].type, `is`("otp")) + } + + @Test + public fun shouldFilterAuthenticatorsByFactorsAllowed(): Unit = runTest { + val json = """[ + {"id": "sms|dev_123", "type": "oob", "authenticator_type": "oob", "active": true, "oob_channel": "sms"}, + {"id": "totp|dev_456", "type": "otp", "authenticator_type": "otp", "active": true}, + {"id": "recovery|dev_789", "type": "recovery-code", "authenticator_type": "recovery-code", "active": true} + ]""" + enqueueMockResponse(json) + + val authenticators = mfaClient.getAuthenticators(listOf("otp")).await() + + assertThat(authenticators, hasSize(1)) + assertThat(authenticators[0].id, `is`("totp|dev_456")) + assertThat(authenticators[0].type, `is`("otp")) + } + + @Test + public fun shouldFailWithEmptyFactorsAllowed(): Unit { + val exception = assertThrows(MfaListAuthenticatorsException::class.java) { + runTest { + mfaClient.getAuthenticators(emptyList()).await() + } + } + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("factorsAllowed is required")) + } + + @Test + public fun shouldIncludeAuthorizationHeaderInGetAuthenticators(): Unit = runTest { + val json = """[{"id": "sms|dev_123", "type": "oob", "active": true}]""" + enqueueMockResponse(json) + + mfaClient.getAuthenticators(listOf("oob")).await() + + val request = mockServer.takeRequest() + assertThat(request.getHeader("Authorization"), `is`("Bearer $MFA_TOKEN")) + assertThat(request.path, `is`("/mfa/authenticators")) + assertThat(request.method, `is`("GET")) + } + + @Test + public fun shouldHandleGetAuthenticatorsApiError(): Unit { + enqueueErrorResponse("access_denied", "Invalid MFA token", 401) + + val exception = assertThrows(MfaListAuthenticatorsException::class.java) { + runTest { + mfaClient.getAuthenticators(listOf("oob")).await() + } + } + assertThat(exception.getCode(), `is`("access_denied")) + assertThat(exception.getDescription(), `is`("Invalid MFA token")) + assertThat(exception.statusCode, `is`(401)) + } + + @Test + public fun shouldReturnEmptyListWhenNoMatchingFactors(): Unit = runTest { + val json = """[ + {"id": "sms|dev_123", "type": "oob", "active": true} + ]""" + enqueueMockResponse(json) + + val authenticators = mfaClient.getAuthenticators(listOf("otp")).await() + + assertThat(authenticators, hasSize(0)) + } + + @Test + public fun shouldEnrollPhoneSuccess(): Unit = runTest { + val json = """{ + "id": "sms|dev_123", + "auth_session": "session_abc" + }""" + enqueueMockResponse(json) + + val challenge = mfaClient.enrollPhone("+12025550135").await() + + assertThat(challenge, `is`(notNullValue())) + assertThat(challenge.id, `is`("sms|dev_123")) + assertThat(challenge.authSession, `is`("session_abc")) + } + + @Test + public fun shouldEnrollPhoneWithCorrectParameters(): Unit = runTest { + val json = """{"id": "sms|dev_123", "auth_session": "session_abc"}""" + enqueueMockResponse(json) + + mfaClient.enrollPhone("+12025550135").await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/mfa/associate")) + assertThat(request.method, `is`("POST")) + assertThat(request.getHeader("Authorization"), `is`("Bearer $MFA_TOKEN")) + + val body = bodyFromRequest(request) + assertThat(body["authenticator_types"], `is`(listOf("oob"))) + assertThat(body["oob_channels"], `is`(listOf("sms"))) + assertThat(body["phone_number"], `is`("+12025550135")) + } + + @Test + public fun shouldEnrollPhoneFailure(): Unit { + enqueueErrorResponse("invalid_phone", "Invalid phone number format", 400) + + val exception = assertThrows(MfaEnrollmentException::class.java) { + runTest { + mfaClient.enrollPhone("invalid").await() + } + } + assertThat(exception.getCode(), `is`("invalid_phone")) + assertThat(exception.getDescription(), `is`("Invalid phone number format")) + } + + + @Test + public fun shouldEnrollEmailSuccess(): Unit = runTest { + val json = """{ + "id": "email|dev_456", + "auth_session": "session_def" + }""" + enqueueMockResponse(json) + + val challenge = mfaClient.enrollEmail("user@example.com").await() + + assertThat(challenge, `is`(notNullValue())) + assertThat(challenge.id, `is`("email|dev_456")) + assertThat(challenge.authSession, `is`("session_def")) + } + + @Test + public fun shouldEnrollEmailWithCorrectParameters(): Unit = runTest { + val json = """{"id": "email|dev_456", "auth_session": "session_def"}""" + enqueueMockResponse(json) + + mfaClient.enrollEmail("user@example.com").await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/mfa/associate")) + assertThat(request.method, `is`("POST")) + assertThat(request.getHeader("Authorization"), `is`("Bearer $MFA_TOKEN")) + + val body = bodyFromRequest(request) + assertThat(body["authenticator_types"], `is`(listOf("oob"))) + assertThat(body["oob_channels"], `is`(listOf("email"))) + assertThat(body["email"], `is`("user@example.com")) + } + + @Test + public fun shouldEnrollEmailFailure(): Unit { + enqueueErrorResponse("invalid_email", "Invalid email address", 400) + + val exception = assertThrows(MfaEnrollmentException::class.java) { + runTest { + mfaClient.enrollEmail("invalid").await() + } + } + assertThat(exception.getCode(), `is`("invalid_email")) + assertThat(exception.getDescription(), `is`("Invalid email address")) + } + + + @Test + public fun shouldEnrollOtpSuccess(): Unit = runTest { + val json = """{ + "id": "totp|dev_789", + "auth_session": "session_ghi", + "barcode_uri": "otpauth://totp/Example:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", + "manual_input_code": "JBSWY3DPEHPK3PXP" + }""" + enqueueMockResponse(json) + + val challenge = mfaClient.enrollOtp().await() + + assertThat(challenge, `is`(instanceOf(TotpEnrollmentChallenge::class.java))) + val totpChallenge = challenge as TotpEnrollmentChallenge + assertThat(totpChallenge.id, `is`("totp|dev_789")) + assertThat(totpChallenge.authSession, `is`("session_ghi")) + assertThat(totpChallenge.barcodeUri, containsString("otpauth://")) + assertThat(totpChallenge.manualInputCode, `is`("JBSWY3DPEHPK3PXP")) + } + + @Test + public fun shouldEnrollOtpWithCorrectParameters(): Unit = runTest { + val json = """{ + "id": "totp|dev_789", + "auth_session": "session_ghi", + "barcode_uri": "otpauth://totp/test", + "manual_input_code": "SECRET" + }""" + enqueueMockResponse(json) + + mfaClient.enrollOtp().await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/mfa/associate")) + assertThat(request.method, `is`("POST")) + assertThat(request.getHeader("Authorization"), `is`("Bearer $MFA_TOKEN")) + + val body = bodyFromRequest(request) + assertThat(body["authenticator_types"], `is`(listOf("otp"))) + } + + @Test + public fun shouldEnrollOtpFailure(): Unit { + enqueueErrorResponse("enrollment_failed", "OTP enrollment failed", 400) + + val exception = assertThrows(MfaEnrollmentException::class.java) { + runTest { + mfaClient.enrollOtp().await() + } + } + assertThat(exception.getCode(), `is`("enrollment_failed")) + assertThat(exception.getDescription(), `is`("OTP enrollment failed")) + } + + + @Test + public fun shouldEnrollPushSuccess(): Unit = runTest { + val json = """{ + "id": "push|dev_abc", + "auth_session": "session_jkl" + }""" + enqueueMockResponse(json) + + val challenge = mfaClient.enrollPush().await() + + assertThat(challenge, `is`(notNullValue())) + assertThat(challenge.id, `is`("push|dev_abc")) + assertThat(challenge.authSession, `is`("session_jkl")) + } + + @Test + public fun shouldEnrollPushWithAuth0Channel(): Unit = runTest { + val json = """{"id": "push|dev_abc", "auth_session": "session_jkl"}""" + enqueueMockResponse(json) + + mfaClient.enrollPush().await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/mfa/associate")) + assertThat(request.method, `is`("POST")) + assertThat(request.getHeader("Authorization"), `is`("Bearer $MFA_TOKEN")) + + val body = bodyFromRequest(request) + assertThat(body["authenticator_types"], `is`(listOf("oob"))) + assertThat(body["oob_channels"], `is`(listOf("auth0"))) + } + + @Test + public fun shouldEnrollPushFailure(): Unit { + enqueueErrorResponse("enrollment_failed", "Push enrollment failed", 400) + + val exception = assertThrows(MfaEnrollmentException::class.java) { + runTest { + mfaClient.enrollPush().await() + } + } + assertThat(exception.getCode(), `is`("enrollment_failed")) + assertThat(exception.getDescription(), `is`("Push enrollment failed")) + } + + + @Test + public fun shouldChallengeSuccess(): Unit = runTest { + val json = """{ + "challenge_type": "oob", + "oob_code": "oob_code_123", + "binding_method": "prompt" + }""" + enqueueMockResponse(json) + + val challenge = mfaClient.challenge("sms|dev_123").await() + + assertThat(challenge, `is`(notNullValue())) + assertThat(challenge.challengeType, `is`("oob")) + assertThat(challenge.oobCode, `is`("oob_code_123")) + assertThat(challenge.bindingMethod, `is`("prompt")) + } + + @Test + public fun shouldChallengeWithCorrectParameters(): Unit = runTest { + val json = """{"challenge_type": "oob", "oob_code": "oob_123"}""" + enqueueMockResponse(json) + + mfaClient.challenge("sms|dev_123").await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/mfa/challenge")) + assertThat(request.method, `is`("POST")) + + val body = bodyFromRequest(request) + assertThat(body["client_id"], `is`(CLIENT_ID)) + assertThat(body["mfa_token"], `is`(MFA_TOKEN)) + assertThat(body["challenge_type"], `is`("oob")) + assertThat(body["authenticator_id"], `is`("sms|dev_123")) + } + + @Test + public fun shouldChallengeFailure(): Unit { + enqueueErrorResponse("invalid_authenticator", "Authenticator not found", 404) + + val exception = assertThrows(MfaChallengeException::class.java) { + runTest { + mfaClient.challenge("invalid|dev").await() + } + } + assertThat(exception.getCode(), `is`("invalid_authenticator")) + assertThat(exception.getDescription(), `is`("Authenticator not found")) + assertThat(exception.statusCode, `is`(404)) + } + + + @Test + public fun shouldVerifyOtpSuccess(): Unit = runTest { + val json = """{ + "access_token": "$ACCESS_TOKEN", + "id_token": "$ID_TOKEN", + "refresh_token": "$REFRESH_TOKEN", + "token_type": "Bearer", + "expires_in": 86400 + }""" + enqueueMockResponse(json) + + val credentials = mfaClient.verifyOtp("123456").await() + + assertThat(credentials, `is`(notNullValue())) + assertThat(credentials.accessToken, `is`(ACCESS_TOKEN)) + assertThat(credentials.idToken, `is`(ID_TOKEN)) + assertThat(credentials.refreshToken, `is`(REFRESH_TOKEN)) + } + + @Test + public fun shouldVerifyOtpWithCorrectGrantType(): Unit = runTest { + val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + enqueueMockResponse(json) + + mfaClient.verifyOtp("123456").await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/oauth/token")) + assertThat(request.method, `is`("POST")) + + val body = bodyFromRequest(request) + assertThat(body["client_id"], `is`(CLIENT_ID)) + assertThat(body["mfa_token"], `is`(MFA_TOKEN)) + assertThat(body["grant_type"], `is`("http://auth0.com/oauth/grant-type/mfa-otp")) + assertThat(body["otp"], `is`("123456")) + } + + @Test + public fun shouldVerifyOtpFailWithInvalidCode(): Unit { + enqueueErrorResponse("invalid_grant", "Invalid OTP code", 403) + + val exception = assertThrows(MfaVerifyException::class.java) { + runTest { + mfaClient.verifyOtp("000000").await() + } + } + assertThat(exception.getCode(), `is`("invalid_grant")) + assertThat(exception.getDescription(), `is`("Invalid OTP code")) + } + + @Test + public fun shouldVerifyOtpFailWithExpiredToken(): Unit { + enqueueErrorResponse("expired_token", "MFA token has expired", 401) + + val exception = assertThrows(MfaVerifyException::class.java) { + runTest { + mfaClient.verifyOtp("123456").await() + } + } + assertThat(exception.getCode(), `is`("expired_token")) + assertThat(exception.getDescription(), `is`("MFA token has expired")) + assertThat(exception.statusCode, `is`(401)) + } + + + @Test + public fun shouldVerifyOobWithBindingCodeSuccess(): Unit = runTest { + val json = """{ + "access_token": "$ACCESS_TOKEN", + "id_token": "$ID_TOKEN", + "token_type": "Bearer", + "expires_in": 86400 + }""" + enqueueMockResponse(json) + + val credentials = mfaClient.verifyOob( + oobCode = "oob_code_123", + bindingCode = "654321" + ).await() + + assertThat(credentials, `is`(notNullValue())) + assertThat(credentials.accessToken, `is`(ACCESS_TOKEN)) + } + + @Test + public fun shouldVerifyOobWithoutBindingCodeSuccess(): Unit = runTest { + val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + enqueueMockResponse(json) + + val credentials = mfaClient.verifyOob(oobCode = "oob_code_123").await() + + assertThat(credentials, `is`(notNullValue())) + assertThat(credentials.accessToken, `is`(ACCESS_TOKEN)) + } + + @Test + public fun shouldVerifyOobWithCorrectParameters(): Unit = runTest { + val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + enqueueMockResponse(json) + + mfaClient.verifyOob(oobCode = "oob_code_123", bindingCode = "654321").await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/oauth/token")) + assertThat(request.method, `is`("POST")) + + val body = bodyFromRequest(request) + assertThat(body["client_id"], `is`(CLIENT_ID)) + assertThat(body["mfa_token"], `is`(MFA_TOKEN)) + assertThat(body["grant_type"], `is`("http://auth0.com/oauth/grant-type/mfa-oob")) + assertThat(body["oob_code"], `is`("oob_code_123")) + assertThat(body["binding_code"], `is`("654321")) + } + + @Test + public fun shouldVerifyOobWithoutBindingCodeInRequest(): Unit = runTest { + val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + enqueueMockResponse(json) + + mfaClient.verifyOob(oobCode = "oob_code_123").await() + + val request = mockServer.takeRequest() + val body = bodyFromRequest(request) + assertThat(body.containsKey("binding_code"), `is`(false)) + } + + @Test + public fun shouldVerifyOobFailure(): Unit { + enqueueErrorResponse("invalid_grant", "Invalid OOB code", 403) + + val exception = assertThrows(MfaVerifyException::class.java) { + runTest { + mfaClient.verifyOob(oobCode = "invalid").await() + } + } + assertThat(exception.getCode(), `is`("invalid_grant")) + assertThat(exception.getDescription(), `is`("Invalid OOB code")) + } + + + @Test + public fun shouldVerifyRecoveryCodeSuccess(): Unit = runTest { + val json = """{ + "access_token": "$ACCESS_TOKEN", + "id_token": "$ID_TOKEN", + "token_type": "Bearer", + "expires_in": 86400, + "recovery_code": "NEW_RECOVERY_CODE_123" + }""" + enqueueMockResponse(json) + + val credentials = mfaClient.verifyRecoveryCode("OLD_RECOVERY_CODE").await() + + assertThat(credentials, `is`(notNullValue())) + assertThat(credentials.accessToken, `is`(ACCESS_TOKEN)) + assertThat(credentials.recoveryCode, `is`("NEW_RECOVERY_CODE_123")) + } + + @Test + public fun shouldVerifyRecoveryCodeWithCorrectParameters(): Unit = runTest { + val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + enqueueMockResponse(json) + + mfaClient.verifyRecoveryCode("RECOVERY_123").await() + + val request = mockServer.takeRequest() + assertThat(request.path, `is`("/oauth/token")) + assertThat(request.method, `is`("POST")) + + val body = bodyFromRequest(request) + assertThat(body["client_id"], `is`(CLIENT_ID)) + assertThat(body["mfa_token"], `is`(MFA_TOKEN)) + assertThat(body["grant_type"], `is`("http://auth0.com/oauth/grant-type/mfa-recovery-code")) + assertThat(body["recovery_code"], `is`("RECOVERY_123")) + } + + @Test + public fun shouldVerifyRecoveryCodeFailWithInvalidCode(): Unit { + enqueueErrorResponse("invalid_grant", "Invalid recovery code", 403) + + val exception = assertThrows(MfaVerifyException::class.java) { + runTest { + mfaClient.verifyRecoveryCode("INVALID_CODE").await() + } + } + assertThat(exception.getCode(), `is`("invalid_grant")) + assertThat(exception.getDescription(), `is`("Invalid recovery code")) + } + + @Test + public fun shouldVerifyRecoveryCodeFailWithExpiredToken(): Unit { + enqueueErrorResponse("expired_token", "MFA token has expired", 401) + + val exception = assertThrows(MfaVerifyException::class.java) { + runTest { + mfaClient.verifyRecoveryCode("RECOVERY_CODE").await() + } + } + assertThat(exception.getCode(), `is`("expired_token")) + assertThat(exception.getDescription(), `is`("MFA token has expired")) + assertThat(exception.statusCode, `is`(401)) + } + + + @Test + public fun shouldGetAuthenticatorsWithCallback(): Unit { + val json = """[{"id": "sms|dev_123", "authenticator_type": "oob", "active": true}]""" + enqueueMockResponse(json) + + val latch = CountDownLatch(1) + var callbackResult: List? = null + var callbackError: MfaListAuthenticatorsException? = null + + mfaClient.getAuthenticators(listOf("oob")) + .start(object : Callback, MfaListAuthenticatorsException> { + override fun onSuccess(result: List) { + callbackResult = result + latch.countDown() + } + + override fun onFailure(error: MfaListAuthenticatorsException) { + callbackError = error + latch.countDown() + } + }) + + ShadowLooper.idleMainLooper() + latch.await(5, TimeUnit.SECONDS) + + assertThat(callbackResult, `is`(notNullValue())) + assertThat(callbackResult, hasSize(1)) + assertThat(callbackError, `is`(nullValue())) + } + + @Test + public fun shouldEnrollPhoneWithCallback(): Unit { + val json = """{"id": "sms|dev_123", "auth_session": "session_abc"}""" + enqueueMockResponse(json) + + val latch = CountDownLatch(1) + var callbackResult: EnrollmentChallenge? = null + var callbackError: MfaEnrollmentException? = null + + mfaClient.enrollPhone("+12025550135") + .start(object : Callback { + override fun onSuccess(result: EnrollmentChallenge) { + callbackResult = result + latch.countDown() + } + + override fun onFailure(error: MfaEnrollmentException) { + callbackError = error + latch.countDown() + } + }) + + ShadowLooper.idleMainLooper() + latch.await(5, TimeUnit.SECONDS) + + assertThat(callbackResult, `is`(notNullValue())) + assertThat(callbackResult!!.id, `is`("sms|dev_123")) + assertThat(callbackError, `is`(nullValue())) + } + + @Test + public fun shouldChallengeWithCallback(): Unit { + val json = """{"challenge_type": "oob", "oob_code": "oob_123"}""" + enqueueMockResponse(json) + + val latch = CountDownLatch(1) + var callbackResult: Challenge? = null + var callbackError: MfaChallengeException? = null + + mfaClient.challenge("sms|dev_123") + .start(object : Callback { + override fun onSuccess(result: Challenge) { + callbackResult = result + latch.countDown() + } + + override fun onFailure(error: MfaChallengeException) { + callbackError = error + latch.countDown() + } + }) + + ShadowLooper.idleMainLooper() + latch.await(5, TimeUnit.SECONDS) + + assertThat(callbackResult, `is`(notNullValue())) + assertThat(callbackResult!!.challengeType, `is`("oob")) + assertThat(callbackError, `is`(nullValue())) + } + + @Test + public fun shouldVerifyOtpWithCallback(): Unit { + val json = """{"access_token": "$ACCESS_TOKEN", "id_token": "$ID_TOKEN", "token_type": "Bearer", "expires_in": 86400}""" + enqueueMockResponse(json) + + val latch = CountDownLatch(1) + var callbackResult: Credentials? = null + var callbackError: MfaVerifyException? = null + + mfaClient.verifyOtp("123456") + .start(object : Callback { + override fun onSuccess(result: Credentials) { + callbackResult = result + latch.countDown() + } + + override fun onFailure(error: MfaVerifyException) { + callbackError = error + latch.countDown() + } + }) + + ShadowLooper.idleMainLooper() + latch.await(5, TimeUnit.SECONDS) + + assertThat(callbackResult, `is`(notNullValue())) + assertThat(callbackResult!!.accessToken, `is`(ACCESS_TOKEN)) + assertThat(callbackError, `is`(nullValue())) + } + + + @Test + public fun shouldMfaListAuthenticatorsExceptionParseValues(): Unit { + val values = mapOf( + "error" to "access_denied", + "error_description" to "Access denied", + "custom_field" to "custom_value" + ) + val exception = MfaListAuthenticatorsException(values, 403) + + assertThat(exception.getCode(), `is`("access_denied")) + assertThat(exception.getDescription(), `is`("Access denied")) + assertThat(exception.statusCode, `is`(403)) + assertThat(exception.getValue("custom_field"), `is`("custom_value")) + } + + @Test + public fun shouldMfaEnrollmentExceptionParseValues(): Unit { + val values = mapOf( + "error" to "enrollment_failed", + "error_description" to "Enrollment failed" + ) + val exception = MfaEnrollmentException(values, 400) + + assertThat(exception.getCode(), `is`("enrollment_failed")) + assertThat(exception.getDescription(), `is`("Enrollment failed")) + assertThat(exception.statusCode, `is`(400)) + } + + @Test + public fun shouldMfaChallengeExceptionParseValues(): Unit { + val values = mapOf( + "error" to "invalid_authenticator", + "error_description" to "Authenticator not found" + ) + val exception = MfaChallengeException(values, 404) + + assertThat(exception.getCode(), `is`("invalid_authenticator")) + assertThat(exception.getDescription(), `is`("Authenticator not found")) + assertThat(exception.statusCode, `is`(404)) + } + + @Test + public fun shouldMfaVerifyExceptionParseValues(): Unit { + val values = mapOf( + "error" to "invalid_grant", + "error_description" to "Invalid code" + ) + val exception = MfaVerifyException(values, 403) + + assertThat(exception.getCode(), `is`("invalid_grant")) + assertThat(exception.getDescription(), `is`("Invalid code")) + assertThat(exception.statusCode, `is`(403)) + } + + @Test + public fun shouldExceptionUseUnknownErrorWhenNoErrorCode(): Unit { + val values = mapOf("error_description" to "Something went wrong") + val exception = MfaVerifyException(values, 500) + + assertThat(exception.getCode(), `is`("a0.sdk.internal_error.unknown")) + assertThat(exception.getDescription(), `is`("Something went wrong")) + } + + @Test + public fun shouldExceptionUseDefaultDescriptionWhenNoDescription(): Unit { + val values = mapOf("error" to "unknown_error") + val exception = MfaVerifyException(values, 500) + + assertThat(exception.getCode(), `is`("unknown_error")) + assertThat(exception.getDescription(), `is`("Failed to verify MFA code")) + } + + + private companion object { + private const val CLIENT_ID = "CLIENT_ID" + private const val MFA_TOKEN = "MFA_TOKEN_123" + private const val ACCESS_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + private const val ID_TOKEN = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.Gfx6VO9tcxwk6xqx9yYzSfebfeakZp5JYIgP_edcw_A" + private const val REFRESH_TOKEN = "REFRESH_TOKEN" + } +} diff --git a/auth0/src/test/java/com/auth0/android/authentication/MfaExceptionTest.kt b/auth0/src/test/java/com/auth0/android/authentication/MfaExceptionTest.kt new file mode 100644 index 00000000..f60a7b48 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/authentication/MfaExceptionTest.kt @@ -0,0 +1,302 @@ +package com.auth0.android.authentication + +import com.auth0.android.authentication.MfaException.* +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Unit tests for MFA exception classes. + */ +@RunWith(RobolectricTestRunner::class) +public class MfaExceptionTest { + + + @Test + public fun shouldCreateMfaListAuthenticatorsExceptionFromValues(): Unit { + val values = mapOf( + "error" to "access_denied", + "error_description" to "The MFA token is invalid" + ) + val exception = MfaListAuthenticatorsException(values, 401) + + assertThat(exception.getCode(), `is`("access_denied")) + assertThat(exception.getDescription(), `is`("The MFA token is invalid")) + assertThat(exception.statusCode, `is`(401)) + assertThat(exception.message, containsString("access_denied")) + } + + @Test + public fun shouldMfaListAuthenticatorsExceptionGetCustomValue(): Unit { + val values = mapOf( + "error" to "custom_error", + "error_description" to "Custom description", + "custom_field" to "custom_value", + "another_field" to 123.0 + ) + val exception = MfaListAuthenticatorsException(values, 400) + + assertThat(exception.getValue("custom_field"), `is`("custom_value")) + assertThat(exception.getValue("another_field"), `is`(123.0)) + assertThat(exception.getValue("non_existent"), `is`(nullValue())) + } + + @Test + public fun shouldMfaListAuthenticatorsExceptionUseDefaultsWhenMissing(): Unit { + val values = emptyMap() + val exception = MfaListAuthenticatorsException(values, 500) + + assertThat(exception.getCode(), `is`("a0.sdk.internal_error.unknown")) + assertThat(exception.getDescription(), `is`("Failed to list authenticators")) + } + + @Test + public fun shouldCreateInvalidRequestException(): Unit { + val exception = MfaListAuthenticatorsException.invalidRequest( + "factorsAllowed is required and must contain at least one challenge type." + ) + + assertThat(exception.getCode(), `is`("invalid_request")) + assertThat(exception.getDescription(), containsString("factorsAllowed is required")) + assertThat(exception.statusCode, `is`(0)) + } + + + @Test + public fun shouldCreateMfaEnrollmentExceptionFromValues(): Unit { + val values = mapOf( + "error" to "invalid_phone_number", + "error_description" to "The phone number format is invalid" + ) + val exception = MfaEnrollmentException(values, 400) + + assertThat(exception.getCode(), `is`("invalid_phone_number")) + assertThat(exception.getDescription(), `is`("The phone number format is invalid")) + assertThat(exception.statusCode, `is`(400)) + assertThat(exception.message, containsString("invalid_phone_number")) + } + + @Test + public fun shouldMfaEnrollmentExceptionGetCustomValue(): Unit { + val values = mapOf( + "error" to "enrollment_failed", + "error_description" to "Enrollment failed", + "authenticator_type" to "oob" + ) + val exception = MfaEnrollmentException(values, 400) + + assertThat(exception.getValue("authenticator_type"), `is`("oob")) + assertThat(exception.getValue("missing_key"), `is`(nullValue())) + } + + @Test + public fun shouldMfaEnrollmentExceptionUseDefaultsWhenMissing(): Unit { + val values = emptyMap() + val exception = MfaEnrollmentException(values, 500) + + assertThat(exception.getCode(), `is`("a0.sdk.internal_error.unknown")) + assertThat(exception.getDescription(), `is`("Failed to enroll MFA authenticator")) + } + + @Test + public fun shouldMfaEnrollmentExceptionHandleNullValues(): Unit { + val values = mapOf( + "error" to "test_error", + "null_value" to null + ) + val exception = MfaEnrollmentException(values as Map, 400) + + assertThat(exception.getCode(), `is`("test_error")) + assertThat(exception.getValue("null_value"), `is`(nullValue())) + } + + + @Test + public fun shouldCreateMfaChallengeExceptionFromValues(): Unit { + val values = mapOf( + "error" to "invalid_authenticator", + "error_description" to "The authenticator ID is not valid" + ) + val exception = MfaChallengeException(values, 404) + + assertThat(exception.getCode(), `is`("invalid_authenticator")) + assertThat(exception.getDescription(), `is`("The authenticator ID is not valid")) + assertThat(exception.statusCode, `is`(404)) + assertThat(exception.message, containsString("invalid_authenticator")) + } + + @Test + public fun shouldMfaChallengeExceptionGetCustomValue(): Unit { + val values = mapOf( + "error" to "challenge_failed", + "error_description" to "Challenge failed", + "challenge_type" to "oob" + ) + val exception = MfaChallengeException(values, 400) + + assertThat(exception.getValue("challenge_type"), `is`("oob")) + assertThat(exception.getValue("missing_key"), `is`(nullValue())) + } + + @Test + public fun shouldMfaChallengeExceptionUseDefaultsWhenMissing(): Unit { + val values = emptyMap() + val exception = MfaChallengeException(values, 500) + + assertThat(exception.getCode(), `is`("a0.sdk.internal_error.unknown")) + assertThat(exception.getDescription(), `is`("Failed to initiate MFA challenge")) + } + + @Test + public fun shouldMfaChallengeExceptionHandleMfaTokenExpired(): Unit { + val values = mapOf( + "error" to "expired_token", + "error_description" to "The mfa_token has expired" + ) + val exception = MfaChallengeException(values, 401) + + assertThat(exception.getCode(), `is`("expired_token")) + assertThat(exception.getDescription(), `is`("The mfa_token has expired")) + assertThat(exception.statusCode, `is`(401)) + } + + + @Test + public fun shouldCreateMfaVerifyExceptionFromValues(): Unit { + val values = mapOf( + "error" to "invalid_grant", + "error_description" to "The OTP code is invalid" + ) + val exception = MfaVerifyException(values, 403) + + assertThat(exception.getCode(), `is`("invalid_grant")) + assertThat(exception.getDescription(), `is`("The OTP code is invalid")) + assertThat(exception.statusCode, `is`(403)) + assertThat(exception.message, containsString("invalid_grant")) + } + + @Test + public fun shouldMfaVerifyExceptionGetCustomValue(): Unit { + val values = mapOf( + "error" to "invalid_code", + "error_description" to "Invalid code", + "attempts_remaining" to 2.0 + ) + val exception = MfaVerifyException(values, 400) + + assertThat(exception.getValue("attempts_remaining"), `is`(2.0)) + assertThat(exception.getValue("missing_key"), `is`(nullValue())) + } + + @Test + public fun shouldMfaVerifyExceptionUseDefaultsWhenMissing(): Unit { + val values = emptyMap() + val exception = MfaVerifyException(values, 500) + + assertThat(exception.getCode(), `is`("a0.sdk.internal_error.unknown")) + assertThat(exception.getDescription(), `is`("Failed to verify MFA code")) + } + + @Test + public fun shouldMfaVerifyExceptionHandleMfaTokenExpired(): Unit { + val values = mapOf( + "error" to "expired_token", + "error_description" to "The mfa_token has expired. Please start the authentication flow again." + ) + val exception = MfaVerifyException(values, 401) + + assertThat(exception.getCode(), `is`("expired_token")) + assertThat(exception.getDescription(), containsString("mfa_token has expired")) + assertThat(exception.statusCode, `is`(401)) + } + + @Test + public fun shouldMfaVerifyExceptionHandleInvalidBindingCode(): Unit { + val values = mapOf( + "error" to "invalid_binding_code", + "error_description" to "The binding code is invalid" + ) + val exception = MfaVerifyException(values, 403) + + assertThat(exception.getCode(), `is`("invalid_binding_code")) + assertThat(exception.getDescription(), `is`("The binding code is invalid")) + } + + @Test + public fun shouldMfaVerifyExceptionHandleInvalidRecoveryCode(): Unit { + val values = mapOf( + "error" to "invalid_grant", + "error_description" to "The recovery code is invalid" + ) + val exception = MfaVerifyException(values, 403) + + assertThat(exception.getCode(), `is`("invalid_grant")) + assertThat(exception.getDescription(), `is`("The recovery code is invalid")) + } + + + @Test + public fun shouldAllExceptionsInheritFromMfaException(): Unit { + val listException = MfaListAuthenticatorsException(emptyMap(), 400) + val enrollException = MfaEnrollmentException(emptyMap(), 400) + val challengeException = MfaChallengeException(emptyMap(), 400) + val verifyException = MfaVerifyException(emptyMap(), 400) + + assertThat(listException, `is`(instanceOf(MfaException::class.java))) + assertThat(enrollException, `is`(instanceOf(MfaException::class.java))) + assertThat(challengeException, `is`(instanceOf(MfaException::class.java))) + assertThat(verifyException, `is`(instanceOf(MfaException::class.java))) + } + + @Test + public fun shouldMfaExceptionInheritFromAuth0Exception(): Unit { + val exception = MfaVerifyException(emptyMap(), 400) + + assertThat(exception, `is`(instanceOf(com.auth0.android.Auth0Exception::class.java))) + assertThat(exception, `is`(instanceOf(Exception::class.java))) + } + + + @Test + public fun shouldExceptionsReturnCorrectStatusCodes(): Unit { + val exception400 = MfaVerifyException(emptyMap(), 400) + val exception401 = MfaVerifyException(emptyMap(), 401) + val exception403 = MfaVerifyException(emptyMap(), 403) + val exception404 = MfaVerifyException(emptyMap(), 404) + val exception500 = MfaVerifyException(emptyMap(), 500) + + assertThat(exception400.statusCode, `is`(400)) + assertThat(exception401.statusCode, `is`(401)) + assertThat(exception403.statusCode, `is`(403)) + assertThat(exception404.statusCode, `is`(404)) + assertThat(exception500.statusCode, `is`(500)) + } + + @Test + public fun shouldExceptionHaveZeroStatusCodeByDefault(): Unit { + val exception = MfaListAuthenticatorsException.invalidRequest("test") + assertThat(exception.statusCode, `is`(0)) + } + + + @Test + public fun shouldExceptionMessageContainErrorCode(): Unit { + val values = mapOf( + "error" to "custom_error_code", + "error_description" to "Description" + ) + + val listException = MfaListAuthenticatorsException(values, 400) + val enrollException = MfaEnrollmentException(values, 400) + val challengeException = MfaChallengeException(values, 400) + val verifyException = MfaVerifyException(values, 400) + + assertThat(listException.message, containsString("custom_error_code")) + assertThat(enrollException.message, containsString("custom_error_code")) + assertThat(challengeException.message, containsString("custom_error_code")) + assertThat(verifyException.message, containsString("custom_error_code")) + } + +}