Skip to content

Commit cffe42a

Browse files
committed
feat: SMS OTP POC implementation for flexible grant factor
1 parent 3e24a2b commit cffe42a

File tree

10 files changed

+1371
-1
lines changed

10 files changed

+1371
-1
lines changed

auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import com.auth0.android.request.internal.GsonAdapter.Companion.forMapOf
2424
import com.auth0.android.request.internal.GsonProvider
2525
import com.auth0.android.request.internal.RequestFactory
2626
import com.auth0.android.request.internal.ResponseUtils.isNetworkError
27+
import com.auth0.android.result.Authenticator
28+
import com.auth0.android.result.AuthenticatorsList
2729
import com.auth0.android.result.Challenge
2830
import com.auth0.android.result.Credentials
2931
import com.auth0.android.result.DatabaseUser
@@ -474,6 +476,47 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
474476
.addParameters(parameters)
475477
}
476478

479+
/**
480+
* List the available MFA authenticators for the user.
481+
* This method should be called after receiving an `mfa_required` error to determine which
482+
* authenticators are available for completing the MFA challenge.
483+
*
484+
* Example usage:
485+
*
486+
* ```
487+
* client.listAuthenticators("{mfa token}")
488+
* .start(object : Callback<AuthenticatorsList, AuthenticationException> {
489+
* override fun onSuccess(result: AuthenticatorsList) {
490+
* // Use result.smsAuthenticators to get SMS authenticators
491+
* val smsAuth = result.firstActiveSmsAuthenticator
492+
* if (smsAuth != null) {
493+
* // Trigger SMS challenge with this authenticator
494+
* }
495+
* }
496+
* override fun onFailure(error: AuthenticationException) { }
497+
* })
498+
* ```
499+
*
500+
* @param mfaToken the MFA token received in the `mfa_required` error response
501+
* @return a request to configure and start that will yield [AuthenticatorsList]
502+
*/
503+
public fun listAuthenticators(
504+
mfaToken: String
505+
): Request<AuthenticatorsList, AuthenticationException> {
506+
val parameters = ParameterBuilder.newBuilder()
507+
.set(MFA_TOKEN_KEY, mfaToken)
508+
.asDictionary()
509+
val url = auth0.getDomainUrl().toHttpUrl().newBuilder()
510+
.addPathSegment(MFA_PATH)
511+
.addPathSegment(AUTHENTICATORS_PATH)
512+
.build()
513+
val authenticatorsListAdapter: JsonAdapter<AuthenticatorsList> = GsonAdapter(
514+
AuthenticatorsList::class.java, gson
515+
)
516+
return factory.post(url.toString(), authenticatorsListAdapter)
517+
.addParameters(parameters)
518+
}
519+
477520
/**
478521
* Log in a user using a token obtained from a Native Social Identity Provider, such as Facebook, using ['\oauth\token' endpoint](https://auth0.com/docs/api/authentication#token-exchange-for-native-social)
479522
* The default scope used is 'openid profile email'.
@@ -1116,6 +1159,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
11161159
private const val REVOKE_PATH = "revoke"
11171160
private const val MFA_PATH = "mfa"
11181161
private const val CHALLENGE_PATH = "challenge"
1162+
private const val AUTHENTICATORS_PATH = "authenticators"
11191163
private const val PASSKEY_PATH = "passkey"
11201164
private const val REGISTER_PATH = "register"
11211165
private const val HEADER_AUTHORIZATION = "Authorization"

auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,15 @@ public class AuthenticationException : Auth0Exception {
147147
public val isMultifactorEnrollRequired: Boolean
148148
get() = "a0.mfa_registration_required" == code || "unsupported_challenge_type" == code
149149

150+
/**
151+
* Get the MFA token from the error response when MFA is required.
152+
* This token should be used in subsequent MFA operations like listing authenticators or completing the challenge.
153+
*
154+
* @return the MFA token if present, null otherwise
155+
*/
156+
public val mfaToken: String?
157+
get() = getValue("mfa_token") as? String
158+
150159
/// When Bot Protection flags the request as suspicious
151160
public val isVerificationRequired: Boolean
152161
get() = "requires_verification" == code
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.auth0.android.result
2+
3+
import com.google.gson.annotations.SerializedName
4+
5+
/**
6+
* Represents an MFA authenticator that can be used for multi-factor authentication.
7+
*
8+
* @see [com.auth0.android.authentication.AuthenticationAPIClient.listAuthenticators]
9+
*/
10+
public data class Authenticator(
11+
/**
12+
* Unique identifier for this authenticator
13+
*/
14+
@SerializedName("id")
15+
public val id: String,
16+
17+
/**
18+
* Type of authenticator (e.g., "otp", "oob", "recovery-code")
19+
*/
20+
@SerializedName("authenticator_type")
21+
public val authenticatorType: String,
22+
23+
/**
24+
* Whether this authenticator is active
25+
*/
26+
@SerializedName("active")
27+
public val active: Boolean,
28+
29+
/**
30+
* OOB channel if this is an out-of-band authenticator (e.g., "sms", "email", "auth0")
31+
*/
32+
@SerializedName("oob_channel")
33+
public val oobChannel: String? = null,
34+
35+
/**
36+
* Name of the authenticator
37+
*/
38+
@SerializedName("name")
39+
public val name: String? = null,
40+
41+
/**
42+
* Creation timestamp
43+
*/
44+
@SerializedName("created_at")
45+
public val createdAt: String? = null,
46+
47+
/**
48+
* Last update timestamp
49+
*/
50+
@SerializedName("updated_at")
51+
public val updatedAt: String? = null
52+
) {
53+
/**
54+
* Check if this is an SMS authenticator
55+
*/
56+
public val isSms: Boolean
57+
get() = authenticatorType == "oob" && oobChannel == "sms"
58+
59+
/**
60+
* Check if this is an OTP authenticator (TOTP)
61+
*/
62+
public val isOTP: Boolean
63+
get() = authenticatorType == "otp"
64+
65+
/**
66+
* Check if this is an email authenticator
67+
*/
68+
public val isEmail: Boolean
69+
get() = authenticatorType == "oob" && oobChannel == "email"
70+
71+
/**
72+
* Check if this is a recovery code authenticator
73+
*/
74+
public val isRecoveryCode: Boolean
75+
get() = authenticatorType == "recovery-code"
76+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.auth0.android.result
2+
3+
import com.auth0.android.request.internal.JsonRequired
4+
import com.google.gson.annotations.SerializedName
5+
6+
/**
7+
* Response containing a list of available MFA authenticators for the user.
8+
*
9+
* @see [com.auth0.android.authentication.AuthenticationAPIClient.listAuthenticators]
10+
*/
11+
public data class AuthenticatorsList(
12+
/**
13+
* List of authenticators available for this user
14+
*/
15+
@field:JsonRequired
16+
@SerializedName("authenticators")
17+
public val authenticators: List<Authenticator>
18+
) {
19+
/**
20+
* Get all SMS authenticators
21+
*/
22+
public val smsAuthenticators: List<Authenticator>
23+
get() = authenticators.filter { it.isSms }
24+
25+
/**
26+
* Get all OTP authenticators (TOTP)
27+
*/
28+
public val otpAuthenticators: List<Authenticator>
29+
get() = authenticators.filter { it.isOTP }
30+
31+
/**
32+
* Get all email authenticators
33+
*/
34+
public val emailAuthenticators: List<Authenticator>
35+
get() = authenticators.filter { it.isEmail }
36+
37+
/**
38+
* Get the first active SMS authenticator, if available
39+
*/
40+
public val firstActiveSmsAuthenticator: Authenticator?
41+
get() = smsAuthenticators.firstOrNull { it.active }
42+
43+
/**
44+
* Get the first active OTP authenticator, if available
45+
*/
46+
public val firstActiveOtpAuthenticator: Authenticator?
47+
get() = otpAuthenticators.firstOrNull { it.active }
48+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package com.auth0.android.authentication
2+
3+
import org.junit.Assert.*
4+
import org.junit.Test
5+
6+
/**
7+
* Unit tests for AuthenticationException MFA token extraction
8+
* Part of the SMS OTP MFA POC implementation
9+
*/
10+
class AuthenticationExceptionMfaTest {
11+
12+
@Test
13+
fun `should extract mfa_token from error response`() {
14+
val errorValues = mapOf(
15+
"error" to "mfa_required",
16+
"error_description" to "Multifactor authentication required",
17+
"mfa_token" to "test_mfa_token_12345"
18+
)
19+
20+
val exception = AuthenticationException(errorValues, 403)
21+
22+
assertTrue(exception.isMultifactorRequired)
23+
assertNotNull(exception.mfaToken)
24+
assertEquals("test_mfa_token_12345", exception.mfaToken)
25+
}
26+
27+
@Test
28+
fun `should return null when mfa_token is not present`() {
29+
val errorValues = mapOf(
30+
"error" to "mfa_required",
31+
"error_description" to "Multifactor authentication required"
32+
)
33+
34+
val exception = AuthenticationException(errorValues, 403)
35+
36+
assertTrue(exception.isMultifactorRequired)
37+
assertNull(exception.mfaToken)
38+
}
39+
40+
@Test
41+
fun `should handle non-MFA error without mfa_token`() {
42+
val errorValues = mapOf(
43+
"error" to "invalid_grant",
44+
"error_description" to "Wrong username or password"
45+
)
46+
47+
val exception = AuthenticationException(errorValues, 401)
48+
49+
assertFalse(exception.isMultifactorRequired)
50+
assertNull(exception.mfaToken)
51+
}
52+
53+
@Test
54+
fun `should identify mfa_required with code variant`() {
55+
val errorValues = mapOf(
56+
"error" to "mfa_required",
57+
"error_description" to "Multifactor authentication required",
58+
"mfa_token" to "token_abc"
59+
)
60+
61+
val exception = AuthenticationException(errorValues, 403)
62+
63+
assertTrue(exception.isMultifactorRequired)
64+
assertEquals("token_abc", exception.mfaToken)
65+
}
66+
67+
@Test
68+
fun `should identify a0_mfa_required variant`() {
69+
val errorValues = mapOf(
70+
"error" to "a0.mfa_required",
71+
"error_description" to "MFA is required",
72+
"mfa_token" to "token_xyz"
73+
)
74+
75+
val exception = AuthenticationException(errorValues, 403)
76+
77+
assertTrue(exception.isMultifactorRequired)
78+
assertEquals("token_xyz", exception.mfaToken)
79+
}
80+
81+
@Test
82+
fun `should handle mfa_token as different type gracefully`() {
83+
// In case the API returns mfa_token as non-string (edge case)
84+
val errorValues = mapOf(
85+
"error" to "mfa_required",
86+
"error_description" to "Multifactor authentication required",
87+
"mfa_token" to 12345 // Integer instead of String
88+
)
89+
90+
val exception = AuthenticationException(errorValues, 403)
91+
92+
assertTrue(exception.isMultifactorRequired)
93+
// Should return null when type is not String
94+
assertNull(exception.mfaToken)
95+
}
96+
97+
@Test
98+
fun `should extract mfa_token with real-world response format`() {
99+
// Simulating actual Auth0 API response
100+
val errorValues = mapOf(
101+
"error" to "mfa_required",
102+
"error_description" to "Multifactor authentication required",
103+
"mfa_token" to "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2V4YW1wbGUuYXV0aDAuY29tLyIsImF1ZCI6Imh0dHBzOi8vZXhhbXBsZS5hdXRoMC5jb20vbWZhLyJ9.test",
104+
"statusCode" to 403
105+
)
106+
107+
val exception = AuthenticationException(errorValues, 403)
108+
109+
assertTrue(exception.isMultifactorRequired)
110+
assertNotNull(exception.mfaToken)
111+
assertTrue(exception.mfaToken!!.startsWith("eyJ"))
112+
}
113+
114+
@Test
115+
fun `should handle getValue for mfa_token correctly`() {
116+
val errorValues = mapOf(
117+
"error" to "mfa_required",
118+
"mfa_token" to "test_token"
119+
)
120+
121+
val exception = AuthenticationException(errorValues, 403)
122+
123+
// Test both ways of accessing mfa_token
124+
assertEquals("test_token", exception.mfaToken)
125+
assertEquals("test_token", exception.getValue("mfa_token"))
126+
}
127+
128+
@Test
129+
fun `should work with empty error values map`() {
130+
val errorValues = emptyMap<String, Any>()
131+
val exception = AuthenticationException(errorValues, 500)
132+
133+
assertNull(exception.mfaToken)
134+
assertFalse(exception.isMultifactorRequired)
135+
}
136+
}

0 commit comments

Comments
 (0)