diff --git a/EXAMPLES.md b/EXAMPLES.md index d124aa8c..cae42b8c 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -15,6 +15,14 @@ - [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) + - [Understanding the mfa_required Error Payload](#understanding-the-mfa_required-error-payload) + - [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) + - [MFA Client Errors](#mfa-client-errors) - [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 +426,875 @@ 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 + +> [!IMPORTANT] +> Multi Factor Authentication support via SDKs is currently in Early Access. To request access to this feature, contact your Auth0 representative. + +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. + +#### Understanding the mfa_required Error Payload + +When MFA is required during authentication, the error response contains a structured payload with the following properties: + +| Property | Type | Description | +|----------|------|-------------| +| `mfaToken` | `String` | A token that must be used for all subsequent MFA operations. This token is short-lived. | +| `mfaRequirements` | `MfaRequirements?` | Contains the available MFA actions. | +| `mfaRequirements.enroll` | `List?` | Factor types available for enrollment. Present when the user **has not enrolled** any MFA factors yet. | +| `mfaRequirements.challenge` | `List?` | Factor types available for challenge. Present when the user **has already enrolled** MFA factors. | + +**Enroll vs Challenge Flows:** +- **Enroll flow**: When `mfaRequirements.enroll` is present and not empty, the user needs to enroll a new MFA factor before they can authenticate. Use `mfaClient.enroll()` to register a new authenticator. +- **Challenge flow**: When `mfaRequirements.challenge` is present and not empty, the user has already enrolled MFA factors. Use `mfaClient.getAuthenticators()` to list their enrolled authenticators, then `mfaClient.challenge()` to initiate verification. + +> **Note**: Check both `enroll` and `challenge` independently. While typically only one will be present, your code should handle both scenarios defensively. + +#### 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 if enrollment is required (user has not enrolled MFA yet) + requirements?.enroll?.let { enrollTypes -> + println("User needs to enroll MFA") + println("Available enrollment types: ${enrollTypes.map { it.type }}") + // Example output: ["otp", "sms", "push-notification"] + // Proceed with MFA enrollment using one of these types + } + + // Check if challenge is available (user already enrolled) + requirements?.challenge?.let { challengeTypes -> + println("User has enrolled MFA factors") + println("Available challenge types: ${challengeTypes.map { it.type }}") + // Example output: ["otp", "sms"] + // Get authenticators and challenge one of them + } + + // Proceed with MFA flow using mfaToken + } + } + + override fun onSuccess(credentials: Credentials) { + // Login successful without MFA + } + }) +``` + +
+ Using Java + +```java +authentication + .login("user@example.com", "password", "Username-Password-Authentication") + .validateClaims() + .start(new Callback() { + @Override + public void onFailure(@NonNull AuthenticationException exception) { + if (exception.isMultifactorRequired()) { + // MFA is required - extract the MFA payload + MfaRequiredErrorPayload mfaPayload = exception.getMfaRequiredErrorPayload(); + if (mfaPayload != null) { + String mfaToken = mfaPayload.getMfaToken(); + MfaRequirements requirements = mfaPayload.getMfaRequirements(); + + // Check if enrollment is required (user has not enrolled MFA yet) + if (requirements != null && requirements.getEnroll() != null) { + List enrollTypes = requirements.getEnroll(); + Log.d(TAG, "User needs to enroll MFA"); + for (MfaFactor factor : enrollTypes) { + Log.d(TAG, "Available enrollment type: " + factor.getType()); + } + } + + // Check if challenge is available (user already enrolled) + if (requirements != null && requirements.getChallenge() != null) { + List challengeTypes = requirements.getChallenge(); + Log.d(TAG, "User has enrolled MFA factors"); + for (MfaFactor factor : challengeTypes) { + Log.d(TAG, "Available challenge type: " + factor.getType()); + } + } + + // Proceed with MFA flow using mfaToken + } + } + } + + @Override + public void 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 + val requirements = mfaPayload?.mfaRequirements + + // Check if enrollment is required + requirements?.enroll?.let { enrollTypes -> + println("User needs to enroll MFA") + println("Available enrollment types: ${enrollTypes.map { it.type }}") + // Example output: ["otp", "sms", "push-notification"] + } + + // Check if challenge is available + requirements?.challenge?.let { challengeTypes -> + println("User has enrolled MFA factors") + println("Available challenge types: ${challengeTypes.map { it.type }}") + // Example output: ["otp", "sms"] + } + + // Proceed with MFA flow using mfaToken + } +} +``` +
+ +#### 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) +``` + +
+ Using Java + +```java +MfaApiClient 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 +// Convert List to List for the factorsAllowed parameter +val factorTypes = requirements?.challenge?.map { it.type } ?: emptyList() + +mfaClient + .getAuthenticators(factorsAllowed = factorTypes) + .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 factorTypes = requirements?.challenge?.map { it.type } ?: emptyList() + val authenticators = mfaClient + .getAuthenticators(factorsAllowed = factorTypes) + .await() + println(authenticators) +} catch (e: MfaListAuthenticatorsException) { + e.printStackTrace() +} +``` +
+ +
+ Using Java + +```java +// Convert List to List for the factorsAllowed parameter +List factorTypes = new ArrayList<>(); +if (requirements != null && requirements.getChallenge() != null) { + for (MfaFactor factor : requirements.getChallenge()) { + factorTypes.add(factor.getType()); + } +} + +mfaClient + .getAuthenticators(factorTypes) + .start(new Callback, MfaListAuthenticatorsException>() { + @Override + public void onFailure(@NonNull MfaListAuthenticatorsException exception) { + // Handle error + } + + @Override + public void onSuccess(List authenticators) { + // Display authenticators for user to choose + for (Authenticator auth : authenticators) { + Log.d(TAG, "Type: " + auth.getAuthenticatorType() + ", ID: " + auth.getId()); + } + } + }); +``` +
+ +#### 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) + +```kotlin +mfaClient + .enroll(MfaEnrollmentType.Phone("+11234567890")) + .start(object: Callback { + override fun onFailure(exception: MfaEnrollmentException) { } + + override fun onSuccess(enrollment: EnrollmentChallenge) { + // Phone enrolled - need to verify with OOB code + val oobCode = enrollment.oobCode + // For OOB challenges, cast to OobEnrollmentChallenge to access bindingMethod + if (enrollment is OobEnrollmentChallenge) { + val bindingMethod = enrollment.bindingMethod + } + } + }) +``` + +
+ Using Java + +```java +mfaClient + .enroll(MfaEnrollmentType.Phone.INSTANCE.invoke("+11234567890")) + .start(new Callback() { + @Override + public void onFailure(@NonNull MfaEnrollmentException exception) { } + + @Override + public void onSuccess(EnrollmentChallenge enrollment) { + // Phone enrolled - need to verify with OOB code + String oobCode = enrollment.getOobCode(); + // For OOB challenges, cast to OobEnrollmentChallenge to access bindingMethod + if (enrollment instanceof OobEnrollmentChallenge) { + String bindingMethod = ((OobEnrollmentChallenge) enrollment).getBindingMethod(); + } + } + }); +``` +
+ +##### Enroll Email + +```kotlin +mfaClient + .enroll(MfaEnrollmentType.Email("user@example.com")) + .start(object: Callback { + override fun onFailure(exception: MfaEnrollmentException) { } + + override fun onSuccess(enrollment: EnrollmentChallenge) { + // Email enrolled - need to verify with OOB code + val oobCode = enrollment.oobCode + } + }) +``` + +
+ Using Java + +```java +mfaClient + .enroll(MfaEnrollmentType.Email.INSTANCE.invoke("user@example.com")) + .start(new Callback() { + @Override + public void onFailure(@NonNull MfaEnrollmentException exception) { } + + @Override + public void onSuccess(EnrollmentChallenge enrollment) { + // Email enrolled - need to verify with OOB code + String oobCode = enrollment.getOobCode(); + } + }); +``` +
+ +##### Enroll OTP (Authenticator App) + +```kotlin +mfaClient + .enroll(MfaEnrollmentType.Otp) + .start(object: Callback { + override fun onFailure(exception: MfaEnrollmentException) { } + + override fun onSuccess(enrollment: EnrollmentChallenge) { + // Display QR code or secret for user to scan/enter in authenticator app + if (enrollment is TotpEnrollmentChallenge) { + val secret = enrollment.manualInputCode + val barcodeUri = enrollment.barcodeUri + } + } + }) +``` + +
+ Using Java + +```java +mfaClient + .enroll(MfaEnrollmentType.Otp.INSTANCE) + .start(new Callback() { + @Override + public void onFailure(@NonNull MfaEnrollmentException exception) { } + + @Override + public void onSuccess(EnrollmentChallenge enrollment) { + // Display QR code or secret for user to scan/enter in authenticator app + if (enrollment instanceof TotpEnrollmentChallenge) { + TotpEnrollmentChallenge totpEnrollment = (TotpEnrollmentChallenge) enrollment; + String secret = totpEnrollment.getManualInputCode(); + String barcodeUri = totpEnrollment.getBarcodeUri(); + } + } + }); +``` +
+ +##### Enroll Push Notification + +```kotlin +mfaClient + .enroll(MfaEnrollmentType.Push) + .start(object: Callback { + override fun onFailure(exception: MfaEnrollmentException) { } + + override fun onSuccess(enrollment: EnrollmentChallenge) { + // Display QR code for user to scan with Guardian app + if (enrollment is TotpEnrollmentChallenge) { + val barcodeUri = enrollment.barcodeUri + } + } + }) +``` + +
+ Using Java + +```java +mfaClient + .enroll(MfaEnrollmentType.Push.INSTANCE) + .start(new Callback() { + @Override + public void onFailure(@NonNull MfaEnrollmentException exception) { } + + @Override + public void onSuccess(EnrollmentChallenge enrollment) { + // Display QR code for user to scan with Guardian app + if (enrollment instanceof TotpEnrollmentChallenge) { + String barcodeUri = ((TotpEnrollmentChallenge) enrollment).getBarcodeUri(); + } + } + }); +``` +
+ +#### 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(challenge: Challenge) { + // Challenge initiated + val challengeType = challenge.challengeType + val oobCode = challenge.oobCode + val bindingMethod = challenge.bindingMethod + } + }) +``` + +
+ Using Java + +```java +mfaClient + .challenge("phone|dev_xxxx") + .start(new Callback() { + @Override + public void onFailure(@NonNull MfaChallengeException exception) { } + + @Override + public void onSuccess(Challenge challenge) { + // Challenge initiated + String challengeType = challenge.getChallengeType(); + String oobCode = challenge.getOobCode(); + String bindingMethod = challenge.getBindingMethod(); + } + }); +``` +
+ +
+ Using coroutines + +```kotlin +try { + val challenge = mfaClient + .challenge(authenticatorId = "phone|dev_xxxx") + .await() + println("Challenge type: ${challenge.challengeType}") +} catch (e: MfaChallengeException) { + e.printStackTrace() +} +``` +
+ +##### Verify with OTP (Authenticator App) + +```kotlin +mfaClient + .verify(MfaVerificationType.Otp(otp = "123456")) + .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 + .verify(MfaVerificationType.Otp(otp = "123456")) + .await() + println(credentials) +} catch (e: MfaVerifyException) { + e.printStackTrace() +} +``` +
+ +
+ Using Java + +```java +mfaClient + .verify(MfaVerificationType.Otp.INSTANCE.invoke("123456")) + .start(new Callback() { + @Override + public void onFailure(@NonNull MfaVerifyException exception) { } + + @Override + public void onSuccess(Credentials credentials) { + // MFA verification successful - user is now logged in + } + }); +``` +
+ +##### 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 + .verify(MfaVerificationType.Oob(oobCode = oobCode, bindingCode = "123456")) // bindingCode is optional for push + .start(object: Callback { + override fun onFailure(exception: MfaVerifyException) { } + + override fun onSuccess(credentials: Credentials) { + // MFA verification successful + } + }) +``` + +
+ Using Java + +```java +mfaClient + .verify(MfaVerificationType.Oob.INSTANCE.invoke(oobCode, "123456")) // bindingCode is optional for push + .start(new Callback() { + @Override + public void onFailure(@NonNull MfaVerifyException exception) { } + + @Override + public void 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 + .verify(MfaVerificationType.RecoveryCode(code = "ABCD1234EFGH5678")) + .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 + } + }) +``` + +
+ Using Java + +```java +mfaClient + .verify(MfaVerificationType.RecoveryCode.INSTANCE.invoke("ABCD1234EFGH5678")) + .start(new Callback() { + @Override + public void onFailure(@NonNull MfaVerifyException exception) { } + + @Override + public void 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 + // Convert List to List for the factorsAllowed parameter + val factorTypes = requirements?.challenge?.map { it.type } ?: emptyList() + mfaClient + .getAuthenticators(factorsAllowed = factorTypes) + .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(challenge: Challenge) { + // Step 5: Prompt user for OTP and verify + // ... show OTP input UI, then call verify() + } + 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 + } + }) +``` + +
+ Using Java + +```java +// Step 1: Attempt login +authentication + .login(email, password, connection) + .validateClaims() + .start(new Callback() { + @Override + public void onFailure(@NonNull AuthenticationException exception) { + if (exception.isMultifactorRequired()) { + MfaRequiredErrorPayload mfaPayload = exception.getMfaRequiredErrorPayload(); + if (mfaPayload == null) return; + String mfaToken = mfaPayload.getMfaToken(); + if (mfaToken == null) return; + MfaRequirements requirements = mfaPayload.getMfaRequirements(); + + // Step 2: Create MFA client + MfaApiClient mfaClient = authentication.mfaClient(mfaToken); + + // Step 3: Get available authenticators + List factorTypes = new ArrayList<>(); + if (requirements != null && requirements.getChallenge() != null) { + for (MfaFactor factor : requirements.getChallenge()) { + factorTypes.add(factor.getType()); + } + } + + mfaClient + .getAuthenticators(factorTypes) + .start(new Callback, MfaListAuthenticatorsException>() { + @Override + public void onSuccess(List authenticators) { + if (!authenticators.isEmpty()) { + // Step 4: Challenge the first authenticator + Authenticator authenticator = authenticators.get(0); + mfaClient + .challenge(authenticator.getId()) + .start(new Callback() { + @Override + public void onSuccess(Challenge challenge) { + // Step 5: Prompt user for OTP and verify + // ... show OTP input UI, then call verify() + } + @Override + public void onFailure(@NonNull MfaChallengeException e) { } + }); + } else { + // No authenticators enrolled - need to enroll one + // ... show enrollment UI + } + } + @Override + public void onFailure(@NonNull MfaListAuthenticatorsException e) { } + }); + } + } + + @Override + public void onSuccess(Credentials credentials) { + // Login successful without MFA + } + }); +``` +
+ +#### MFA Client Errors + +The MFA client produces specific exception types for different operations: + +- **`MfaListAuthenticatorsException`**: Returned by `getAuthenticators()` when listing authenticators fails +- **`MfaEnrollmentException`**: Returned by `enroll()` methods when enrollment fails +- **`MfaChallengeException`**: Returned by `challenge()` when initiating a challenge fails +- **`MfaVerifyException`**: Returned by `verify()` methods when verification fails + +All MFA exception types provide: +- `code`: The error code from the API response +- `description`: A human-readable error description +- `statusCode`: The HTTP status code +- `getValue(key)`: Access to additional error properties from the response +- `cause`: The underlying `Throwable`, if any (useful for network errors) +- `isNetworkError`: Whether the request failed due to network issues + +##### Example error handling + +```kotlin +mfaClient + .verify(MfaVerificationType.Otp(otp = "123456")) + .start(object: Callback { + override fun onFailure(exception: MfaVerifyException) { + println("Failed with code: ${exception.code}") + println("Description: ${exception.description}") + println("Status code: ${exception.statusCode}") + } + + override fun onSuccess(credentials: Credentials) { + // MFA verification successful + } + }) +``` + +
+ Using coroutines + +```kotlin +try { + val credentials = mfaClient + .verify(MfaVerificationType.Otp(otp = "123456")) + .await() + println(credentials) +} catch (e: MfaVerifyException) { + println("Failed with code: ${e.code}") + println("Description: ${e.description}") + println("Status code: ${e.statusCode}") +} +``` +
+ +
+ Using Java + +```java +mfaClient + .verify(MfaVerificationType.Otp.INSTANCE.invoke("123456")) + .start(new Callback() { + @Override + public void onFailure(@NonNull MfaVerifyException exception) { + Log.e(TAG, "Failed with code: " + exception.getCode()); + Log.e(TAG, "Description: " + exception.getDescription()); + Log.e(TAG, "Status code: " + exception.getStatusCode()); + } + + @Override + public void onSuccess(Credentials credentials) { + // MFA verification successful + } + }); +``` +
+ +##### Common error codes + +Each MFA exception type includes specific error codes to help you handle different scenarios: + +**MfaListAuthenticatorsException** (from `getAuthenticators()`): +- `invalid_request`: Request parameters are invalid (e.g., missing or empty factorsAllowed) +- `invalid_token`: MFA token is invalid or expired +- `access_denied`: User lacks permission to access this resource + +**MfaEnrollmentException** (from `enroll()` methods): +- `invalid_request`: Enrollment parameters are invalid +- `invalid_token`: MFA token is invalid or expired +- `enrollment_conflict`: Authenticator is already enrolled +- `unsupported_challenge_type`: Requested factor type is not enabled + +**MfaChallengeException** (from `challenge()`): +- `invalid_request`: Challenge parameters are invalid +- `invalid_token`: MFA token is invalid or expired +- `authenticator_not_found`: Specified authenticator doesn't exist +- `unsupported_challenge_type`: Authenticator type doesn't support challenges + +**MfaVerifyException** (from `verify()` methods): +- `invalid_grant`: Verification code is incorrect or expired +- `invalid_token`: MFA token is invalid or expired +- `invalid_oob_code`: Out-of-band code is invalid +- `invalid_binding_code`: Binding code (SMS/email code) is incorrect +- `expired_token`: Verification code has expired + +##### Handling specific error cases + +You can check the `code` property to handle specific error scenarios: + +```kotlin +mfaClient + .enroll(MfaEnrollmentType.Phone("+12025550135")) + .start(object: Callback { + override fun onFailure(exception: MfaEnrollmentException) { + when (exception.code) { + "invalid_token" -> println("MFA token is invalid or expired") + "invalid_phone_number" -> println("Phone number format is invalid") + "unsupported_challenge_type" -> println("This MFA factor is not supported") + else -> println("Enrollment failed: ${exception.description}") + } + } + + override fun onSuccess(enrollment: EnrollmentChallenge) { + // Enrollment successful + } + }) +``` + +##### Network errors + +MFA exceptions include an `isNetworkError` property to help handle transient network failures: + +```kotlin +mfaClient + .verify(MfaVerificationType.Otp(otp = "123456")) + .start(object: Callback { + override fun onFailure(exception: MfaVerifyException) { + if (exception.isNetworkError) { + println("Network connectivity issue - check your connection") + } else { + println("Verification failed: ${exception.description}") + } + } + + override fun onSuccess(credentials: Credentials) { + // MFA verification successful + } + }) +``` + +The `isNetworkError` property returns `true` for network-related failures such as: +- No internet connection +- DNS lookup failures +- Connection timeouts + +##### Authentication flow errors + +When handling MFA-required errors from the authentication flow (not the MFA client), you'll receive `AuthenticationException` values. Use these properties to identify MFA-related scenarios: + +- `isMultifactorRequired`: MFA is required to authenticate +- `mfaRequiredErrorPayload`: Contains the MFA token and requirements when MFA is required + +```kotlin +authentication + .login(email, password, connection) + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { + if (exception.isMultifactorRequired) { + val mfaPayload = exception.mfaRequiredErrorPayload + val mfaToken = mfaPayload?.mfaToken + // Proceed with MFA flow + } + } + + override fun onSuccess(credentials: Credentials) { + // Login successful + } + }) +``` + +> [!WARNING] +> Do not parse or otherwise rely on the error messages to handle the errors. The error messages are not part of the API and can change. Use the error `code` property and exception types instead, which are part of the API. + ### 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..eec6bd1f 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -5,6 +5,7 @@ import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException +import com.auth0.android.authentication.mfa.MfaApiClient import com.auth0.android.dpop.DPoP import com.auth0.android.dpop.DPoPException import com.auth0.android.dpop.SenderConstraining @@ -84,6 +85,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.mfaRequiredErrorPayload?.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 +1107,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..78f9df29 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,9 @@ import android.util.Log import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.provider.TokenValidationException +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 +150,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/mfa/MfaApiClient.kt b/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt new file mode 100644 index 00000000..c6087705 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt @@ -0,0 +1,648 @@ +package com.auth0.android.authentication.mfa + +import androidx.annotation.VisibleForTesting +import com.auth0.android.Auth0 +import com.auth0.android.Auth0Exception +import com.auth0.android.authentication.ParameterBuilder +import com.auth0.android.authentication.mfa.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 [com.auth0.android.authentication.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 com.auth0.android.authentication.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 = auth0.clientId + private val baseURL: String = 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 factor type." + ) + } + } + }) + + return request + } + + /** + * Enrolls a new MFA factor for the user. + * + * This method initiates the enrollment of a new MFA factor based on the specified enrollment type. + * The response contains the information needed to complete the enrollment process. + * + * ## Usage + * + * ```kotlin + * // Phone (SMS) enrollment + * mfaClient.enroll(MfaEnrollmentType.Phone("+12025550135")) + * .start(object : Callback { + * override fun onSuccess(result: EnrollmentChallenge) { + * println("Enrollment initiated: ${result.oobCode}") + * } + * override fun onFailure(error: MfaEnrollmentException) { } + * }) + * + * // Email enrollment + * mfaClient.enroll(MfaEnrollmentType.Email("user@example.com")) + * + * // TOTP (Authenticator app) enrollment + * mfaClient.enroll(MfaEnrollmentType.Otp) + * + * // Push notification enrollment + * mfaClient.enroll(MfaEnrollmentType.Push) + * ``` + * + * @param type The type of MFA enrollment to perform. + * @return a request to configure and start that will yield [EnrollmentChallenge] + * + * @see MfaEnrollmentType + * @see [Authentication API Endpoint](https://auth0.com/docs/api/authentication#enroll-authenticator) + */ + public fun enroll(type: MfaEnrollmentType): Request { + return when (type) { + is MfaEnrollmentType.Phone -> enrollOob(oobChannel = "sms", phoneNumber = type.phoneNumber) + is MfaEnrollmentType.Email -> enrollOob(oobChannel = "email", email = type.email) + is MfaEnrollmentType.Otp -> enrollOtpInternal() + is MfaEnrollmentType.Push -> 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 MFA challenge using the specified verification type. + * + * This method completes the MFA authentication flow by verifying the user's credentials + * based on the verification type. Upon successful verification, user credentials are returned. + * + * ## Usage + * + * ```kotlin + * // Verify with OOB code (SMS/Email) + * mfaClient.verify(MfaVerificationType.Oob(oobCode = "Fe26.2*...", bindingCode = "123456")) + * .start(object : Callback { + * override fun onSuccess(result: Credentials) { + * println("Obtained credentials: ${result.accessToken}") + * } + * override fun onFailure(error: MfaVerifyException) { } + * }) + * + * // Verify with OTP code (Authenticator app) + * mfaClient.verify(MfaVerificationType.Otp(otp = "123456")) + * + * // Verify with recovery code + * mfaClient.verify(MfaVerificationType.RecoveryCode(code = "RECOVERY_CODE_123")) + * ``` + * + * @param type The type of MFA verification to perform. + * @return a request to configure and start that will yield [Credentials] + * + * @see MfaVerificationType + * @see [Authentication API Endpoint](https://auth0.com/docs/api/authentication#verify-with-mfa) + */ + public fun verify(type: MfaVerificationType): Request { + return when (type) { + is MfaVerificationType.Oob -> verifyOobInternal(type.oobCode, type.bindingCode) + is MfaVerificationType.Otp -> verifyOtpInternal(type.otp) + is MfaVerificationType.RecoveryCode -> verifyRecoveryCodeInternal(type.code) + } + } + + // ========== Private Helper Methods ========== + + /** + * Creates a JSON adapter that filters 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 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.) + * + * @param factorsAllowed List of factor types to include (e.g., ["sms", "email", "otp"]) + * @return A JsonAdapter that produces a filtered 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) + + return allAuthenticators.filter { authenticator -> + matchesFactorType(authenticator, factorsAllowed) + } + } + } + } + + /** + * 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 -> + val normalizedFactor = factor.lowercase(java.util.Locale.ROOT) + when (normalizedFactor) { + "sms", "phone" -> effectiveType == "sms" || effectiveType == "phone" + "email" -> effectiveType == "email" + "otp", "totp" -> effectiveType == "otp" || effectiveType == "totp" + "oob" -> authenticator.authenticatorType == "oob" || authenticator.type == "oob" + "recovery-code" -> effectiveType == "recovery-code" + "push-notification" -> effectiveType == "push-notification" + else -> effectiveType == normalizedFactor || + authenticator.authenticatorType?.lowercase(java.util.Locale.ROOT) == normalizedFactor || + authenticator.type.lowercase(java.util.Locale.ROOT) == normalizedFactor + } + } + } + + /** + * 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 + } + } + + /** + * 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 + } + + /** + * Internal helper for OTP enrollment. + */ + private fun enrollOtpInternal(): 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")) + } + + /** + * Internal helper for OOB verification. + */ + private fun verifyOobInternal( + 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()) + } + + /** + * Internal helper for OTP verification. + */ + private fun verifyOtpInternal(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) + } + + /** + * Internal helper for recovery code verification. + */ + private fun verifyRecoveryCodeInternal(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 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", + cause = cause + ) + } else { + MfaListAuthenticatorsException( + code = Auth0Exception.UNKNOWN_ERROR, + description = cause.message ?: "Something went wrong", + cause = cause + ) + } + } + } + } + + /** + * 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", + cause = cause + ) + } else { + MfaEnrollmentException( + code = Auth0Exception.UNKNOWN_ERROR, + description = cause.message ?: "Something went wrong", + cause = cause + ) + } + } + } + } + + /** + * 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", + cause = cause + ) + } else { + MfaChallengeException( + code = Auth0Exception.UNKNOWN_ERROR, + description = cause.message ?: "Something went wrong", + cause = cause + ) + } + } + } + } + + /** + * 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", + cause = cause + ) + } else { + MfaVerifyException( + code = Auth0Exception.UNKNOWN_ERROR, + description = cause.message ?: "Something went wrong", + cause = cause + ) + } + } + } + } + + 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/mfa/MfaException.kt b/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaException.kt new file mode 100644 index 00000000..63df304d --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaException.kt @@ -0,0 +1,196 @@ +package com.auth0.android.authentication.mfa + +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`: factorsAllowed is required and must contain at least one factor 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, + cause: Throwable? = null + ) : MfaException("MFA authenticator listing failed: $code", cause) { + + 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" + + /** + * 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, + cause: Throwable? = null + ) : MfaException("MFA enrollment failed: $code", cause) { + + 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, + cause: Throwable? = null + ) : MfaException("MFA challenge failed: $code", cause) { + + 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, + cause: Throwable? = null + ) : MfaException("MFA verification failed: $code", cause) { + + 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/mfa/MfaTypes.kt b/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaTypes.kt new file mode 100644 index 00000000..2fb7af9d --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaTypes.kt @@ -0,0 +1,116 @@ +package com.auth0.android.authentication.mfa + +/** + * Represents the type of MFA enrollment to perform. + * + * Use one of the subclasses to specify the enrollment method when calling [MfaApiClient.enroll]. + * + * ## Usage + * + * ```kotlin + * // Phone (SMS) enrollment + * mfaClient.enroll(MfaEnrollmentType.Phone("+12025550135")) + * + * // Email enrollment + * mfaClient.enroll(MfaEnrollmentType.Email("user@example.com")) + * + * // TOTP (Authenticator app) enrollment + * mfaClient.enroll(MfaEnrollmentType.Otp) + * + * // Push notification enrollment + * mfaClient.enroll(MfaEnrollmentType.Push) + * ``` + * + * @see MfaApiClient.enroll + */ +public sealed class MfaEnrollmentType { + /** + * Enrolls a phone number for SMS-based MFA. + * + * An SMS with a verification code will be sent to the specified phone number. + * + * @property phoneNumber The phone number to enroll, including country code (e.g., `+12025550135`). + */ + public data class Phone(val phoneNumber: String) : MfaEnrollmentType() + + /** + * Enrolls an email address for email-based MFA. + * + * Verification codes will be sent to the specified email address during authentication. + * + * @property email The email address to enroll for MFA. + */ + public data class Email(val email: String) : MfaEnrollmentType() + + /** + * Enrolls a time-based one-time password (TOTP) authenticator for MFA. + * + * The response will contain a QR code and secret that can be scanned by authenticator apps + * like Google Authenticator or Authy. + */ + public object Otp : MfaEnrollmentType() + + /** + * Enrolls push notification as an MFA factor. + * + * Users will receive authentication requests via push notifications on their enrolled device + * using Auth0 Guardian. + */ + public object Push : MfaEnrollmentType() +} + +/** + * Represents the type of MFA verification to perform. + * + * Use one of the subclasses to specify the verification method when calling [MfaApiClient.verify]. + * + * ## Usage + * + * ```kotlin + * // Verify with OOB code (SMS/Email) + * mfaClient.verify(MfaVerificationType.Oob(oobCode = "Fe26.2*...", bindingCode = "123456")) + * + * // Verify with OTP code (Authenticator app) + * mfaClient.verify(MfaVerificationType.Otp(otp = "123456")) + * + * // Verify with recovery code + * mfaClient.verify(MfaVerificationType.RecoveryCode(code = "RECOVERY_CODE_123")) + * ``` + * + * @see MfaApiClient.verify + */ +public sealed class MfaVerificationType { + /** + * Verifies an MFA challenge using an out-of-band (OOB) code. + * + * This is used after receiving an SMS or email challenge. The oobCode is obtained from the + * challenge response, and the bindingCode is the verification code entered by the user. + * + * @property oobCode The out-of-band code from the challenge response. + * @property bindingCode Optional binding code (the code sent to the user's phone/email). + */ + public data class Oob( + val oobCode: String, + val bindingCode: String? = null + ) : MfaVerificationType() + + /** + * Verifies an MFA challenge using a one-time password (OTP) code. + * + * This is used when the user has an authenticator app (like Google Authenticator or Authy) + * that generates time-based codes. + * + * @property otp The 6-digit one-time password code from the authenticator app. + */ + public data class Otp(val otp: String) : MfaVerificationType() + + /** + * Verifies an MFA challenge using a recovery code. + * + * Recovery codes are used when users don't have access to their primary MFA factor. + * Upon successful verification, a new recovery code is returned in the credentials. + * + * @property code The recovery code provided during MFA enrollment. + */ + public data class RecoveryCode(val code: String) : MfaVerificationType() +} 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..5357a3fc 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) @@ -37,6 +39,21 @@ public data class MfaEnrollmentChallenge( 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?, + @SerializedName("oob_code") + override val oobCode: String?, + @SerializedName("binding_method") + public val bindingMethod: String? = null +) : EnrollmentChallenge() + public data class TotpEnrollmentChallenge( @SerializedName("id") override val id: String, diff --git a/auth0/src/main/java/com/auth0/android/result/MfaRequirements.kt b/auth0/src/main/java/com/auth0/android/result/MfaRequirements.kt new file mode 100644 index 00000000..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..e152464c --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt @@ -0,0 +1,823 @@ +package com.auth0.android.authentication + +import com.auth0.android.Auth0 +import com.auth0.android.authentication.mfa.MfaApiClient +import com.auth0.android.authentication.mfa.MfaEnrollmentType +import com.auth0.android.authentication.mfa.MfaVerificationType +import com.auth0.android.authentication.mfa.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.enroll(MfaEnrollmentType.Phone("+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.enroll(MfaEnrollmentType.Phone("+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.enroll(MfaEnrollmentType.Phone("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.enroll(MfaEnrollmentType.Email("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.enroll(MfaEnrollmentType.Email("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.enroll(MfaEnrollmentType.Email("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.enroll(MfaEnrollmentType.Otp).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.enroll(MfaEnrollmentType.Otp).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.enroll(MfaEnrollmentType.Otp).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.enroll(MfaEnrollmentType.Push).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.enroll(MfaEnrollmentType.Push).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.enroll(MfaEnrollmentType.Push).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.verify(MfaVerificationType.Otp("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.verify(MfaVerificationType.Otp("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.verify(MfaVerificationType.Otp("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.verify(MfaVerificationType.Otp("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.verify( + MfaVerificationType.Oob(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.verify(MfaVerificationType.Oob(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.verify(MfaVerificationType.Oob(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.verify(MfaVerificationType.Oob(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.verify(MfaVerificationType.Oob(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.verify(MfaVerificationType.RecoveryCode("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.verify(MfaVerificationType.RecoveryCode("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.verify(MfaVerificationType.RecoveryCode("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.verify(MfaVerificationType.RecoveryCode("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.enroll(MfaEnrollmentType.Phone("+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.verify(MfaVerificationType.Otp("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..3700b16c --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/authentication/MfaExceptionTest.kt @@ -0,0 +1,303 @@ +package com.auth0.android.authentication + +import com.auth0.android.authentication.mfa.MfaException +import com.auth0.android.authentication.mfa.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")) + } + +} diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt index cf392476..e5b86be5 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CredentialsManagerTest.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.test.runTest import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.hamcrest.core.Is +import org.hamcrest.core.IsInstanceOf import org.junit.Assert import org.junit.Assert.assertThrows import org.junit.Before @@ -1948,6 +1949,262 @@ public class CredentialsManagerTest { MatcherAssert.assertThat(retrievedCredentials.scope, Is.`is`("scope")) } + // ========== MFA Required During Token Renewal Tests ========== + + @Test + public fun shouldFailWithMfaRequiredWhenRenewingExpiredCredentials() { + // Arrange: Set up expired credentials that need renewal + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // Create an AuthenticationException that simulates MFA required response + val mfaRequiredValues: MutableMap = mutableMapOf( + "error" to "mfa_required", + "error_description" to "Multifactor authentication required", + "mfa_token" to "test-mfa-token-12345", + "mfa_requirements" to mutableMapOf( + "challenge" to listOf( + mutableMapOf("type" to "otp"), + mutableMapOf("type" to "oob") + ) + ) + ) + val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403) + + // Verify the exception is correctly configured + MatcherAssert.assertThat(mfaRequiredException.isMultifactorRequired, Is.`is`(true)) + MatcherAssert.assertThat(mfaRequiredException.getCode(), Is.`is`("mfa_required")) + + Mockito.`when`(request.execute()).thenThrow(mfaRequiredException) + + // Act: Try to get credentials, which triggers renewal + manager.getCredentials(callback) + + // Assert: Verify the callback receives MFA_REQUIRED exception with payload + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + // Note: CredentialsManager uses error.message which is the DEFAULT_MESSAGE from AuthenticationException + MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate")) + MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException)) + + // Verify MFA payload is properly passed through + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaToken, Is.`is`("test-mfa-token-12345")) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, Is.`is`(2)) + } + + @Test + public fun shouldFailWithMfaRequiredWithEnrollmentOptionsWhenRenewingCredentials() { + // Arrange: Set up expired credentials that need renewal + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // Create an AuthenticationException with enrollment options (user needs to enroll MFA) + val mfaRequiredValues = mapOf( + "error" to "mfa_required", + "error_description" to "Multifactor authentication required", + "mfa_token" to "enroll-mfa-token", + "mfa_requirements" to mapOf( + "enroll" to listOf( + mapOf("type" to "otp"), + mapOf("type" to "sms"), + mapOf("type" to "push-notification") + ) + ) + ) + val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403) + Mockito.`when`(request.execute()).thenThrow(mfaRequiredException) + + // Act: Try to get credentials + manager.getCredentials(callback) + + // Assert: Verify MFA required with enrollment options + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + // Note: CredentialsManager uses error.message which is the DEFAULT_MESSAGE from AuthenticationException + MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate")) + MatcherAssert.assertThat(exception.mfaToken, Is.`is`("enroll-mfa-token")) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, Is.`is`(3)) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.nullValue())) + } + + @Test + public fun shouldNotStoreMfaPayloadWhenNonMfaApiErrorOccurs() { + // Arrange: Set up expired credentials that need renewal + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // Create a regular API error (not MFA required) + val regularApiError = AuthenticationException( + mapOf( + "error" to "invalid_grant", + "error_description" to "Invalid refresh token" + ), + 400 + ) + Mockito.`when`(request.execute()).thenThrow(regularApiError) + + // Act: Try to get credentials + manager.getCredentials(callback) + + // Assert: Verify no MFA payload is present for non-MFA errors + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + // For non-MFA API errors, message is "An error occurred while processing the request." + MatcherAssert.assertThat(exception.message, Matchers.containsString("processing the request")) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.nullValue())) + MatcherAssert.assertThat(exception.mfaToken, Is.`is`(Matchers.nullValue())) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldThrowMfaRequiredExceptionWhenAwaitingExpiredCredentials(): Unit = runTest { + // Arrange: Set up expired credentials that need renewal + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // Create an AuthenticationException that simulates MFA required response + val mfaRequiredValues = mapOf( + "error" to "mfa_required", + "error_description" to "Multifactor authentication required", + "mfa_token" to "await-mfa-token-12345", + "mfa_requirements" to mapOf( + "challenge" to listOf( + mapOf("type" to "otp") + ) + ) + ) + val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403) + Mockito.`when`(request.execute()).thenThrow(mfaRequiredException) + + // Act & Assert: Verify awaitCredentials throws CredentialsManagerException with MFA payload + val exception = assertThrows(CredentialsManagerException::class.java) { + runBlocking { manager.awaitCredentials() } + } + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException)) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaToken, Is.`is`("await-mfa-token-12345")) + } + + @Test + public fun shouldFailWithMfaRequiredContainingBothChallengeAndEnrollOptions() { + // Arrange: Set up expired credentials that need renewal + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // Create MFA required with BOTH challenge (existing factors) AND enroll (can add more factors) + val mfaRequiredValues = mapOf( + "error" to "mfa_required", + "error_description" to "Multifactor authentication required", + "mfa_token" to "combined-mfa-token", + "mfa_requirements" to mapOf( + "challenge" to listOf( + mapOf("type" to "otp") + ), + "enroll" to listOf( + mapOf("type" to "sms"), + mapOf("type" to "push-notification") + ) + ) + ) + val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403) + Mockito.`when`(request.execute()).thenThrow(mfaRequiredException) + + // Act: Try to get credentials + manager.getCredentials(callback) + + // Assert: Verify both challenge and enroll are present in the payload + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaToken, Is.`is`("combined-mfa-token")) + + // Verify challenge factors + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, Is.`is`(1)) + + // Verify enroll factors + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, Is.`is`(2)) + } + + @Test + public fun shouldPreserveOriginalAuthenticationExceptionAsCauseForMfaRequired() { + // Arrange: Set up expired credentials that need renewal + Mockito.`when`(storage.retrieveString("com.auth0.id_token")).thenReturn("idToken") + Mockito.`when`(storage.retrieveString("com.auth0.access_token")).thenReturn("accessToken") + Mockito.`when`(storage.retrieveString("com.auth0.refresh_token")).thenReturn("refreshToken") + Mockito.`when`(storage.retrieveString("com.auth0.token_type")).thenReturn("type") + val expirationTime = CredentialsMock.CURRENT_TIME_MS // Expired + Mockito.`when`(storage.retrieveLong("com.auth0.expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveLong("com.auth0.cache_expires_at")).thenReturn(expirationTime) + Mockito.`when`(storage.retrieveString("com.auth0.scope")).thenReturn("scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + val mfaRequiredValues = mapOf( + "error" to "mfa_required", + "error_description" to "MFA is required for this action", + "mfa_token" to "cause-test-token" + ) + val originalException = AuthenticationException(mfaRequiredValues, 403) + Mockito.`when`(request.execute()).thenThrow(originalException) + + // Act + manager.getCredentials(callback) + + // Assert: Verify the original exception is preserved as cause + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + + // The cause should be the original AuthenticationException + MatcherAssert.assertThat(exception.cause, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.cause, IsInstanceOf.instanceOf(AuthenticationException::class.java)) + + val causeException = exception.cause as AuthenticationException + MatcherAssert.assertThat(causeException.getCode(), Is.`is`("mfa_required")) + MatcherAssert.assertThat(causeException.isMultifactorRequired, Is.`is`(true)) + MatcherAssert.assertThat(causeException.getDescription(), Is.`is`("MFA is required for this action")) + } + private fun prepareJwtDecoderMock(expiresAt: Date?) { val jwtMock = mock() Mockito.`when`(jwtMock.expiresAt).thenReturn(expiresAt) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index 893b56fb..c4d430bd 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -1878,6 +1878,245 @@ public class SecureCredentialsManagerTest { ) } + // ========== MFA Required During Token Renewal Tests ========== + + @Test + public fun shouldFailWithMfaRequiredWhenRenewingExpiredCredentials() { + // Arrange: Set up local authentication to pass + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + // Arrange: Set up expired credentials that need renewal + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) + insertTestCredentials(false, true, true, expiresAt, "scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // Create an AuthenticationException that simulates MFA required response + val mfaRequiredValues = mapOf( + "error" to "mfa_required", + "error_description" to "Multifactor authentication required", + "mfa_token" to "test-mfa-token-12345", + "mfa_requirements" to mapOf( + "challenge" to listOf( + mapOf("type" to "otp"), + mapOf("type" to "oob") + ) + ) + ) + val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403) + Mockito.`when`(request.execute()).thenThrow(mfaRequiredException) + + // Act: Try to get credentials, which triggers renewal + manager.getCredentials(callback) + + // Assert: Verify the callback receives MFA_REQUIRED exception with payload + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + // Note: CredentialsManager uses error.message which is the DEFAULT_MESSAGE from AuthenticationException + MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate")) + MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException)) + + // Verify MFA payload is properly passed through + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaToken, Is.`is`("test-mfa-token-12345")) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, Is.`is`(2)) + } + + @Test + public fun shouldFailWithMfaRequiredWithEnrollmentOptionsWhenRenewingCredentials() { + // Arrange: Set up local authentication to pass + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + // Arrange: Set up expired credentials that need renewal + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) + insertTestCredentials(false, true, true, expiresAt, "scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // Create an AuthenticationException with enrollment options (user needs to enroll MFA) + val mfaRequiredValues = mapOf( + "error" to "mfa_required", + "error_description" to "Multifactor authentication required", + "mfa_token" to "enroll-mfa-token", + "mfa_requirements" to mapOf( + "enroll" to listOf( + mapOf("type" to "otp"), + mapOf("type" to "sms"), + mapOf("type" to "push-notification") + ) + ) + ) + val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403) + Mockito.`when`(request.execute()).thenThrow(mfaRequiredException) + + // Act: Try to get credentials + manager.getCredentials(callback) + + // Assert: Verify MFA required with enrollment options + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + // Note: CredentialsManager uses error.message which is the DEFAULT_MESSAGE from AuthenticationException + MatcherAssert.assertThat(exception.message, Matchers.containsString("authenticate")) + MatcherAssert.assertThat(exception.mfaToken, Is.`is`("enroll-mfa-token")) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, Is.`is`(3)) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.nullValue())) + } + + @Test + public fun shouldNotStoreMfaPayloadWhenNonMfaApiErrorOccurs() { + // Arrange: Set up local authentication to pass + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + // Arrange: Set up expired credentials that need renewal + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) + insertTestCredentials(false, true, true, expiresAt, "scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // Create a regular API error (not MFA required) + val regularApiError = AuthenticationException( + mapOf( + "error" to "invalid_grant", + "error_description" to "Invalid refresh token" + ), + 400 + ) + Mockito.`when`(request.execute()).thenThrow(regularApiError) + + // Act: Try to get credentials + manager.getCredentials(callback) + + // Assert: Verify no MFA payload is present for non-MFA errors + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + // For non-MFA API errors, message is "An error occurred while processing the request." + MatcherAssert.assertThat(exception.message, Matchers.containsString("processing the request")) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.nullValue())) + MatcherAssert.assertThat(exception.mfaToken, Is.`is`(Matchers.nullValue())) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldThrowMfaRequiredExceptionWhenAwaitingExpiredCredentials(): Unit = runTest { + // Arrange: Set up local authentication to pass + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + // Arrange: Set up expired credentials that need renewal + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) + insertTestCredentials(false, true, true, expiresAt, "scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // Create an AuthenticationException that simulates MFA required response + val mfaRequiredValues = mapOf( + "error" to "mfa_required", + "error_description" to "Multifactor authentication required", + "mfa_token" to "await-mfa-token-12345", + "mfa_requirements" to mapOf( + "challenge" to listOf( + mapOf("type" to "otp") + ) + ) + ) + val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403) + Mockito.`when`(request.execute()).thenThrow(mfaRequiredException) + + // Act & Assert: Verify awaitCredentials throws CredentialsManagerException with MFA payload + val exception = assertThrows(CredentialsManagerException::class.java) { + runBlocking { manager.awaitCredentials() } + } + MatcherAssert.assertThat(exception, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.cause, Is.`is`(mfaRequiredException)) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaToken, Is.`is`("await-mfa-token-12345")) + } + + @Test + public fun shouldFailWithMfaRequiredContainingBothChallengeAndEnrollOptions() { + // Arrange: Set up local authentication to pass + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + // Arrange: Set up expired credentials that need renewal + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) + insertTestCredentials(false, true, true, expiresAt, "scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + // Create MFA required with BOTH challenge (existing factors) AND enroll (can add more factors) + val mfaRequiredValues = mapOf( + "error" to "mfa_required", + "error_description" to "Multifactor authentication required", + "mfa_token" to "combined-mfa-token", + "mfa_requirements" to mapOf( + "challenge" to listOf( + mapOf("type" to "otp") + ), + "enroll" to listOf( + mapOf("type" to "sms"), + mapOf("type" to "push-notification") + ) + ) + ) + val mfaRequiredException = AuthenticationException(mfaRequiredValues, 403) + Mockito.`when`(request.execute()).thenThrow(mfaRequiredException) + + // Act: Try to get credentials + manager.getCredentials(callback) + + // Assert: Verify both challenge and enroll are present in the payload + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaToken, Is.`is`("combined-mfa-token")) + + // Verify challenge factors + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.challenge?.size, Is.`is`(1)) + + // Verify enroll factors + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.mfaRequiredErrorPayload?.mfaRequirements?.enroll?.size, Is.`is`(2)) + } + + @Test + public fun shouldPreserveOriginalAuthenticationExceptionAsCauseForMfaRequired() { + // Arrange: Set up local authentication to pass + Mockito.`when`(localAuthenticationManager.authenticate()).then { + localAuthenticationManager.resultCallback.onSuccess(true) + } + // Arrange: Set up expired credentials that need renewal + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS) + insertTestCredentials(false, true, true, expiresAt, "scope") + Mockito.`when`(client.renewAuth("refreshToken")).thenReturn(request) + + val mfaRequiredValues = mapOf( + "error" to "mfa_required", + "error_description" to "MFA is required for this action", + "mfa_token" to "cause-test-token" + ) + val originalException = AuthenticationException(mfaRequiredValues, 403) + Mockito.`when`(request.execute()).thenThrow(originalException) + + // Act + manager.getCredentials(callback) + + // Assert: Verify the original exception is preserved as cause + verify(callback).onFailure(exceptionCaptor.capture()) + val exception = exceptionCaptor.firstValue + + // The cause should be the original AuthenticationException + MatcherAssert.assertThat(exception.cause, Is.`is`(Matchers.notNullValue())) + MatcherAssert.assertThat(exception.cause, IsInstanceOf.instanceOf(AuthenticationException::class.java)) + + val causeException = exception.cause as AuthenticationException + MatcherAssert.assertThat(causeException.getCode(), Is.`is`("mfa_required")) + MatcherAssert.assertThat(causeException.isMultifactorRequired, Is.`is`(true)) + MatcherAssert.assertThat(causeException.getDescription(), Is.`is`("MFA is required for this action")) + } + /** * Testing that getCredentials execution from multiple threads via multiple instances of SecureCredentialsManager should trigger only one network request. */