diff --git a/EXAMPLES.md b/EXAMPLES.md index 5a8873454..a4a409fc7 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -658,7 +658,7 @@ authentication requirements?.enroll?.let { enrollTypes -> println("User needs to enroll MFA") println("Available enrollment types: ${enrollTypes.map { it.type }}") - // Example output: ["otp", "sms", "push-notification"] + // Example output: ["otp", "phone", "push-notification"] // Proceed with MFA enrollment using one of these types } @@ -666,7 +666,7 @@ authentication requirements?.challenge?.let { challengeTypes -> println("User has enrolled MFA factors") println("Available challenge types: ${challengeTypes.map { it.type }}") - // Example output: ["otp", "sms"] + // Example output: ["otp", "phone"] // Get authenticators and challenge one of them } @@ -748,14 +748,14 @@ try { requirements?.enroll?.let { enrollTypes -> println("User needs to enroll MFA") println("Available enrollment types: ${enrollTypes.map { it.type }}") - // Example output: ["otp", "sms", "push-notification"] + // Example output: ["otp", "phone", "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"] + // Example output: ["otp", "phone"] } // Proceed with MFA flow using mfaToken @@ -966,10 +966,13 @@ mfaClient override fun onFailure(exception: MfaEnrollmentException) { } override fun onSuccess(enrollment: EnrollmentChallenge) { - // Display QR code or secret for user to scan/enter in authenticator app + // Display QR code or secret for user to scan/enter in authenticator app. + // The `/mfa/associate` endpoint returns the manual-entry key as `secret` + // (not `manualInputCode`, which is only populated by the My Account API). if (enrollment is TotpEnrollmentChallenge) { - val secret = enrollment.manualInputCode + val secret = enrollment.secret val barcodeUri = enrollment.barcodeUri + val recoveryCodes = enrollment.recoveryCodes } } }) @@ -987,11 +990,14 @@ mfaClient @Override public void onSuccess(EnrollmentChallenge enrollment) { - // Display QR code or secret for user to scan/enter in authenticator app + // Display QR code or secret for user to scan/enter in authenticator app. + // The `/mfa/associate` endpoint returns the manual-entry key as `secret` + // (not `manualInputCode`, which is only populated by the My Account API). if (enrollment instanceof TotpEnrollmentChallenge) { TotpEnrollmentChallenge totpEnrollment = (TotpEnrollmentChallenge) enrollment; - String secret = totpEnrollment.getManualInputCode(); + String secret = totpEnrollment.getSecret(); String barcodeUri = totpEnrollment.getBarcodeUri(); + List recoveryCodes = totpEnrollment.getRecoveryCodes(); } } }); 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 index 5dced6da8..0a1e6566b 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/mfa/MfaApiClient.kt @@ -124,16 +124,19 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA * ## Usage * * ```kotlin - * mfaClient.getAuthenticators(listOf("otp", "oob")) + * mfaClient.getAuthenticators(listOf("otp", "phone")) * .start(object : Callback, MfaListAuthenticatorsException> { * override fun onSuccess(result: List) { - * // Only OTP and OOB authenticators returned + * // Only authenticators whose `type` is "otp" or "phone" are returned * } * override fun onFailure(error: MfaListAuthenticatorsException) { } * }) * ``` * - * @param factorsAllowed Array of factor types to filter the authenticators (e.g., `["otp", "oob", "recovery-code"]`). + * Filtering matches each authenticator's [Authenticator.type] field against the + * provided values using exact (case-sensitive) equality. + * + * @param factorsAllowed Array of factor types to filter the authenticators (e.g., `["otp", "phone", "email", "recovery-code"]`). * Must contain at least one factor type. * @return a request to configure and start that will yield a list of [Authenticator] * @@ -307,11 +310,11 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA * 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.) + * An authenticator is included when [Authenticator.type] exactly matches one of the + * provided [factorsAllowed] values (case-sensitive equality). This mirrors the + * filtering behavior of the Auth0.swift SDK. * - * @param factorsAllowed List of factor types to include (e.g., ["sms", "email", "otp"]) + * @param factorsAllowed List of factor types to include (e.g., ["phone", "email", "otp"]) * @return A JsonAdapter that produces a filtered list of authenticators */ private fun createFilteringAuthenticatorsAdapter(factorsAllowed: List): JsonAdapter> { @@ -321,66 +324,12 @@ public class MfaApiClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVA val allAuthenticators = baseAdapter.fromJson(reader, metadata) return allAuthenticators.filter { authenticator -> - matchesFactorType(authenticator, factorsAllowed) + factorsAllowed.contains(authenticator.type) } } } } - /** - * 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). */ 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 ecc4f7016..1ce73d986 100644 --- a/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt +++ b/auth0/src/main/java/com/auth0/android/result/EnrollmentChallenge.kt @@ -55,15 +55,53 @@ public data class OobEnrollmentChallenge( public val bindingMethod: String? = null ) : EnrollmentChallenge() +/** + * Enrollment challenge for TOTP (authenticator app) and Push factors, returned by both + * the MFA `/mfa/associate` endpoint and the My Account `/authentication-methods` endpoint. + * + * The two endpoints return different field sets, so every field except [barcodeUri] is + * optional: + * - `/mfa/associate` (ROPG MFA flow) returns [authenticatorType], [secret], [barcodeUri] + * and [recoveryCodes]. It does NOT return `id`, `auth_session` or `manual_input_code`. + * - `/authentication-methods` (My Account) returns [id], [authSession], [barcodeUri] and + * [manualInputCode]. + * + * [secret] and [manualInputCode] both carry the human-readable key for manual entry into an + * authenticator app; only one is populated depending on the endpoint. The [barcodeUri] + * (`otpauth://` URI) is always present and embeds the same secret. + * + * @property id Identifier of the created authentication method. `null` on the `/mfa/associate` + * response (which does not return it); populated by the My Account `/authentication-methods` + * endpoint. + * @property authSession Authentication session for the enrollment. `null` on the + * `/mfa/associate` response; populated by the My Account `/authentication-methods` endpoint. + * @property barcodeUri The `otpauth://` URI to render as a QR code. Always present on both + * endpoints. + * @property manualInputCode Human-readable key for manual entry, returned only by the My Account + * `/authentication-methods` endpoint; `null` on the `/mfa/associate` response (which returns the + * key as [secret] instead). + * @property authenticatorType Low-level authenticator type (e.g. `otp`), returned only by the + * `/mfa/associate` endpoint; `null` on the My Account response. + * @property secret Human-readable key for manual entry, returned only by the `/mfa/associate` + * endpoint; `null` on the My Account response (which returns the key as [manualInputCode]). + * @property recoveryCodes Recovery codes generated during enrollment, returned only by the + * `/mfa/associate` endpoint; `null` on the My Account response. + */ public data class TotpEnrollmentChallenge( @SerializedName("id") - override val id: String, + override val id: String? = null, @SerializedName("auth_session") - override val authSession: String, + override val authSession: String? = null, @SerializedName("barcode_uri") public val barcodeUri: String, @SerializedName("manual_input_code") - public val manualInputCode: String? + public val manualInputCode: String? = null, + @SerializedName("authenticator_type") + public val authenticatorType: String? = null, + @SerializedName("secret") + public val secret: String? = null, + @SerializedName("recovery_codes") + public val recoveryCodes: List? = null ) : EnrollmentChallenge() public data class RecoveryCodeEnrollmentChallenge( diff --git a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt index 006c817de..b528d2878 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/MfaApiClientTest.kt @@ -189,9 +189,9 @@ public class MfaApiClientTest { whenever(mockKeyStore.hasKeyPair()).thenReturn(true) whenever(mockKeyStore.getKeyPair()).thenReturn(Pair(FakeECPrivateKey(), FakeECPublicKey())) val dpopClient = MfaApiClient(auth0, MFA_TOKEN).useDPoP(mockContext) - enqueueMockResponse("""[{"id": "sms|dev_123", "type": "oob", "active": true}]""") + enqueueMockResponse("""[{"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true}]""") - dpopClient.getAuthenticators(listOf("oob")).await() + dpopClient.getAuthenticators(listOf("phone")).await() val request = mockServer.takeRequest() assertThat(request.path, `is`("/mfa/authenticators")) @@ -254,10 +254,10 @@ public class MfaApiClientTest { @Test public fun shouldIncludeAuth0ClientHeaderInGetAuthenticators(): Unit = runTest { - val json = """[{"id": "sms|dev_123", "type": "oob", "active": true}]""" + val json = """[{"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true}]""" enqueueMockResponse(json) - mfaClient.getAuthenticators(listOf("oob")).await() + mfaClient.getAuthenticators(listOf("phone")).await() val request = mockServer.takeRequest() assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue())) @@ -300,34 +300,38 @@ public class MfaApiClientTest { @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} + {"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "active": true, "oob_channel": "sms"}, + {"id": "voice|dev_123", "type": "phone", "authenticator_type": "oob", "active": true, "oob_channel": "voice"}, + {"id": "recovery|dev_789", "type": "recovery-code", "authenticator_type": "recovery-code", "active": true}, + {"id": "email|dev_456", "type": "email", "authenticator_type": "oob", "active": true, "oob_channel": "email"} ]""" enqueueMockResponse(json) - val authenticators = mfaClient.getAuthenticators(listOf("oob", "otp")).await() + val authenticators = mfaClient.getAuthenticators(listOf("phone", "email")).await() - assertThat(authenticators, hasSize(2)) + assertThat(authenticators, hasSize(3)) 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")) + assertThat(authenticators[0].type, `is`("phone")) + assertThat(authenticators[1].id, `is`("voice|dev_123")) + assertThat(authenticators[1].type, `is`("phone")) + assertThat(authenticators[2].id, `is`("email|dev_456")) + assertThat(authenticators[2].type, `is`("email")) } @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": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "active": true, "oob_channel": "sms"}, + {"id": "email|dev_456", "type": "email", "authenticator_type": "oob", "active": true, "oob_channel": "email"}, {"id": "recovery|dev_789", "type": "recovery-code", "authenticator_type": "recovery-code", "active": true} ]""" enqueueMockResponse(json) - val authenticators = mfaClient.getAuthenticators(listOf("otp")).await() + val authenticators = mfaClient.getAuthenticators(listOf("recovery-code")).await() assertThat(authenticators, hasSize(1)) - assertThat(authenticators[0].id, `is`("totp|dev_456")) - assertThat(authenticators[0].type, `is`("otp")) + assertThat(authenticators[0].id, `is`("recovery|dev_789")) + assertThat(authenticators[0].type, `is`("recovery-code")) } @Test @@ -343,10 +347,10 @@ public class MfaApiClientTest { @Test public fun shouldIncludeAuthorizationHeaderInGetAuthenticators(): Unit = runTest { - val json = """[{"id": "sms|dev_123", "type": "oob", "active": true}]""" + val json = """[{"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true}]""" enqueueMockResponse(json) - mfaClient.getAuthenticators(listOf("oob")).await() + mfaClient.getAuthenticators(listOf("phone")).await() val request = mockServer.takeRequest() assertThat(request.getHeader("Authorization"), `is`("Bearer $MFA_TOKEN")) @@ -360,7 +364,7 @@ public class MfaApiClientTest { val exception = assertThrows(MfaListAuthenticatorsException::class.java) { runTest { - mfaClient.getAuthenticators(listOf("oob")).await() + mfaClient.getAuthenticators(listOf("phone")).await() } } assertThat(exception.getCode(), `is`("access_denied")) @@ -371,7 +375,7 @@ public class MfaApiClientTest { @Test public fun shouldReturnEmptyListWhenNoMatchingFactors(): Unit = runTest { val json = """[ - {"id": "sms|dev_123", "type": "oob", "active": true} + {"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true} ]""" enqueueMockResponse(json) @@ -476,11 +480,13 @@ public class MfaApiClientTest { @Test public fun shouldEnrollOtpSuccess(): Unit = runTest { + // Real /mfa/associate TOTP response shape: authenticator_type + secret + barcode_uri + // + recovery_codes. It does NOT contain id, auth_session or manual_input_code. val json = """{ - "id": "totp|dev_789", - "auth_session": "session_ghi", + "authenticator_type": "otp", + "secret": "JBSWY3DPEHPK3PXP", "barcode_uri": "otpauth://totp/Example:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example", - "manual_input_code": "JBSWY3DPEHPK3PXP" + "recovery_codes": ["ABCD1234EFGH5678"] }""" enqueueMockResponse(json) @@ -488,10 +494,14 @@ public class MfaApiClientTest { 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.authenticatorType, `is`("otp")) + assertThat(totpChallenge.secret, `is`("JBSWY3DPEHPK3PXP")) assertThat(totpChallenge.barcodeUri, containsString("otpauth://")) - assertThat(totpChallenge.manualInputCode, `is`("JBSWY3DPEHPK3PXP")) + assertThat(totpChallenge.recoveryCodes, `is`(listOf("ABCD1234EFGH5678"))) + // Fields not returned by /mfa/associate must be null (not force-unwrapped non-null). + assertThat(totpChallenge.id, `is`(nullValue())) + assertThat(totpChallenge.authSession, `is`(nullValue())) + assertThat(totpChallenge.manualInputCode, `is`(nullValue())) } @Test @@ -838,12 +848,12 @@ public class MfaApiClientTest { @Test public fun shouldGetAuthenticatorsWithCallback(): Unit { - val json = """[{"id": "sms|dev_123", "authenticator_type": "oob", "active": true}]""" + val json = """[{"id": "sms|dev_123", "type": "phone", "authenticator_type": "oob", "oob_channel": "sms", "active": true}]""" enqueueMockResponse(json) val callback = MockCallback, MfaListAuthenticatorsException>() - mfaClient.getAuthenticators(listOf("oob")) + mfaClient.getAuthenticators(listOf("phone")) .start(callback) ShadowLooper.idleMainLooper()