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 8a616efb5..9757b7d32 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -6,6 +6,7 @@ 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.authentication.passwordless.DeliveryMethod import com.auth0.android.authentication.passwordless.PasswordlessClient import com.auth0.android.authentication.request.ActorToken import com.auth0.android.dpop.DPoP @@ -31,6 +32,7 @@ import com.auth0.android.result.Credentials import com.auth0.android.result.DatabaseUser import com.auth0.android.result.PasskeyChallenge import com.auth0.android.result.PasskeyRegistrationChallenge +import com.auth0.android.result.PasskeyVerificationMethod import com.auth0.android.result.SSOCredentials import com.auth0.android.result.UserProfile import com.google.gson.Gson @@ -234,13 +236,19 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * @param authResponse the [PublicKeyCredentials] authentication response * @param realm the connection to use. If excluded, the application will use the default connection configured in the tenant * @param organization id of the organization to be associated with the user while signing in + * @param verification a map of identifier to the OTP code collected from the user, e.g. + * `mapOf(PasskeyVerificationMethod.EMAIL to "123456")`. The keys must match the identifiers returned in + * [PasskeyRegistrationChallenge.verificationRequired]. Defaults to an empty map when no identifier verification is + * required; the `verification` object is then omitted from the request. * @return a request to configure and start that will yield [Credentials] */ + @JvmOverloads public fun signinWithPasskey( authSession: String, authResponse: PublicKeyCredentials, realm: String? = null, organization: String? = null, + verification: Map = emptyMap(), ): AuthenticationRequest { val params = ParameterBuilder.newBuilder().apply { setGrantType(ParameterBuilder.GRANT_TYPE_PASSKEY) @@ -249,11 +257,18 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe organization?.let { set(ORGANIZATION_KEY, organization) } }.asDictionary() - return loginWithToken(params) + val request = loginWithToken(params) .addParameter( AUTH_RESPONSE_KEY, Gson().toJsonTree(authResponse) - ) as AuthenticationRequest + ) + if (verification.isNotEmpty()) { + request.addParameter( + VERIFICATION_KEY, + gson.toJsonTree(verification.mapKeys { it.key.value }) + ) + } + return request as AuthenticationRequest } @@ -281,19 +296,25 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * @param authResponse the public key credential authentication response in JSON string format that follows the standard webauthn json format * @param realm the connection to use. If excluded, the application will use the default connection configured in the tenant * @param organization id of the organization to be associated with the user while signing in + * @param verification a map of identifier to the OTP code collected from the user, e.g. + * `mapOf(PasskeyVerificationMethod.EMAIL to "123456")`. The keys must match the identifiers returned in + * [PasskeyRegistrationChallenge.verificationRequired]. Defaults to an empty map when no identifier verification is + * required; the `verification` object is then omitted from the request. * @return a request to configure and start that will yield [Credentials] */ + @JvmOverloads public fun signinWithPasskey( authSession: String, authResponse: String, realm: String? = null, organization: String? = null, + verification: Map = emptyMap(), ): AuthenticationRequest { val publicKeyCredentials = gson.fromJson( authResponse, PublicKeyCredentials::class.java ) - return signinWithPasskey(authSession, publicKeyCredentials, realm, organization) + return signinWithPasskey(authSession, publicKeyCredentials, realm, organization, verification) } @@ -325,12 +346,17 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * @param userData user information for registration. * @param realm the connection to use. If excluded, the application will use the default connection configured in the tenant * @param organization id of the organization to be associated with the user while signing up + * @param deliveryMethod the channel used to deliver the phone OTP. Only applies when the connection requires phone + * identifier verification. If the requested channel is not enabled on the connection, the server responds with an + * `invalid_request` error. Pass `null` to use the connection default. * @return a request to configure and start that will yield [PasskeyRegistrationChallenge] */ + @JvmOverloads public fun signupWithPasskey( userData: UserData, realm: String? = null, - organization: String? = null + organization: String? = null, + deliveryMethod: DeliveryMethod? = null ): Request { val url = auth0.getDomainUrl().toHttpUrl().newBuilder() .addPathSegment(PASSKEY_PATH) @@ -351,6 +377,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe .addParameters(params) as BaseRequest post.addParameter(USER_PROFILE_KEY, gson.toJsonTree(userData.toUserProfile())) userData.userMetadata?.let { post.addParameter(USER_METADATA_KEY, it) } + deliveryMethod?.let { post.addParameter(DELIVERY_METHOD_KEY, it.value) } return post } @@ -1042,6 +1069,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private const val USER_METADATA_KEY = "user_metadata" private const val AUTH_SESSION_KEY = "auth_session" private const val AUTH_RESPONSE_KEY = "authn_response" + private const val VERIFICATION_KEY = "verification" + private const val DELIVERY_METHOD_KEY = "delivery_method" private const val USER_PROFILE_KEY = "user_profile" private const val SIGN_UP_PATH = "signup" private const val DB_CONNECTIONS_PATH = "dbconnections" 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 78f9df294..3ceaae813 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt @@ -8,6 +8,7 @@ import com.auth0.android.provider.TokenValidationException import com.auth0.android.result.MfaFactor import com.auth0.android.result.MfaRequiredErrorPayload import com.auth0.android.result.MfaRequirements +import com.auth0.android.result.PasskeyVerificationMethod public class AuthenticationException : Auth0Exception { private var code: String? = null @@ -200,6 +201,48 @@ public class AuthenticationException : Auth0Exception { public val isVerificationRequired: Boolean get() = "requires_verification" == code + /** + * Whether a failed passkey signup identifier verification can be retried without restarting + * the registration. `true` when the error contains an `auth_session` and is either: + * - an `invalid_request`, because the `verification` codes were missing or empty. Re-collect + * the codes for the challenge's `verificationRequired`; or + * - an `invalid_grant`, because a code was wrong. Re-collect the codes for + * [passkeyVerificationRequired]. + * + * Then call [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey] again + * with the same credential and [passkeyAuthSession]. When `false`, restart the registration. + * + * @see passkeyAuthSession + * @see passkeyVerificationRequired + */ + public val isPasskeyVerificationRetryable: Boolean + get() = ("invalid_grant" == code || "invalid_request" == code) && passkeyAuthSession != null + + /** + * The auth session returned with a failed passkey verification, to be reused on the retry so + * the same passkey credential and session are kept. `null` when the error carries none. + * + * @see isPasskeyVerificationRetryable + */ + public val passkeyAuthSession: String? + get() = getValue(AUTH_SESSION_KEY) as? String + + /** + * The identifiers whose OTP code was wrong and must be re-collected before retrying. Only set + * for a retryable `invalid_grant`; `null` otherwise, including for a retryable + * `invalid_request`, where the challenge's `verificationRequired` applies. Identifiers this + * version of the SDK does not know are skipped. + * + * @see isPasskeyVerificationRetryable + */ + public val passkeyVerificationRequired: List? + get() { + if (!isPasskeyVerificationRetryable || "invalid_grant" != code) return null + return (getValue(VERIFICATION_REQUIRED_KEY) as? List<*>) + ?.filterIsInstance() + ?.mapNotNull { value -> PasskeyVerificationMethod.values().firstOrNull { it.value == value } } + } + /// When the MFA Token used on the login request is malformed or has expired public val isMultifactorTokenInvalid: Boolean get() = "expired_token" == code && "mfa_token is expired" == description || @@ -267,6 +310,8 @@ public class AuthenticationException : Auth0Exception { internal const val ERROR_VALUE_CT_OPTIONS_INVALID = "Custom tab options are received as null from the intent" private const val ERROR_KEY = "error" + private const val AUTH_SESSION_KEY = "auth_session" + private const val VERIFICATION_REQUIRED_KEY = "verification_required" private const val CODE_KEY = "code" private const val DESCRIPTION_KEY = "description" private const val ERROR_DESCRIPTION_KEY = "error_description" diff --git a/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt b/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt index f4abbbb0e..8e396be5d 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt @@ -3,8 +3,9 @@ package com.auth0.android.authentication.passwordless /** * Delivery method for a phone-number OTP challenge. * - * Maps to the `delivery_method` request parameter of `POST /otp/challenge`. [TEXT] sends the - * one-time code via SMS (the server default); [VOICE] delivers it through a voice call. + * Maps to the `delivery_method` request parameter of `POST /otp/challenge` and + * `POST /passkey/register`. [TEXT] sends the one-time code via SMS (the server default); + * [VOICE] delivers it through a voice call. * * @property value the wire value sent to the server. */ diff --git a/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationChallenge.kt b/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationChallenge.kt index e0b7d3c58..ef84a1e20 100644 --- a/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationChallenge.kt +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationChallenge.kt @@ -6,11 +6,20 @@ import com.google.gson.annotations.SerializedName /** * Represents a challenge when user tries to register via passkeys. */ -public data class PasskeyRegistrationChallenge( +public data class PasskeyRegistrationChallenge @JvmOverloads constructor( @SerializedName("auth_session") val authSession: String, @SerializedName("authn_params_public_key") - val authParamsPublicKey: AuthnParamsPublicKey + val authParamsPublicKey: AuthnParamsPublicKey, + /** + * The identifiers (e.g. `[EMAIL]` or `[EMAIL, PHONE]`) that require OTP verification + * before a credential can be created and tokens issued. `null` when the server did not + * request any identifier verification. The OTP codes collected for these identifiers must + * be passed to [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey] + * via the `verification` parameter. + */ + @SerializedName("verification_required") + val verificationRequired: List? = null ) public data class AuthnParamsPublicKey( diff --git a/auth0/src/main/java/com/auth0/android/result/PasskeyVerificationMethod.kt b/auth0/src/main/java/com/auth0/android/result/PasskeyVerificationMethod.kt new file mode 100644 index 000000000..ebe73d4eb --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyVerificationMethod.kt @@ -0,0 +1,28 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName + +/** + * An identifier whose ownership must be verified with a one-time code before a passkey signup + * can complete. + * + * Returned in [PasskeyRegistrationChallenge.verificationRequired] and + * [com.auth0.android.authentication.AuthenticationException.passkeyVerificationRequired]. Use it as + * the key when passing the collected codes in the `verification` map of + * [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey]. + * + * ```kotlin + * val verification = mapOf(PasskeyVerificationMethod.EMAIL to emailCode) + * ``` + * + * @property value the value used by the Auth0 Authentication API, e.g. `"email"`. + */ +public enum class PasskeyVerificationMethod(public val value: String) { + /** The user's email address. */ + @SerializedName("email") + EMAIL("email"), + + /** The user's phone number. */ + @SerializedName("phone") + PHONE("phone") +} diff --git a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt index e9a07b654..911135e67 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.Resources import com.auth0.android.Auth0 import com.auth0.android.authentication.ParameterBuilder.Companion.newBuilder +import com.auth0.android.authentication.passwordless.DeliveryMethod import com.auth0.android.authentication.request.ActorToken import com.auth0.android.dpop.DPoPException import com.auth0.android.dpop.DPoPKeyStore @@ -22,6 +23,7 @@ import com.auth0.android.request.internal.ThreadSwitcherShadow import com.auth0.android.result.Authentication import com.auth0.android.result.Credentials import com.auth0.android.result.DatabaseUser +import com.auth0.android.result.PasskeyVerificationMethod import com.auth0.android.result.SSOCredentials import com.auth0.android.result.UserProfile import com.auth0.android.util.Auth0UserAgent @@ -302,6 +304,292 @@ public class AuthenticationAPIClientTest { } + @Test + public fun shouldSignupWithPasskeyWithNullVerificationRequiredWhenAbsent() { + mockAPI.willReturnSuccessfulPasskeyRegistration() + val client = AuthenticationAPIClient(auth0) + val registrationResponse = client.signupWithPasskey( + UserData(email = "test@example.com"), + MY_CONNECTION + ).execute() + mockAPI.takeRequest() + assertThat(registrationResponse.verificationRequired, Matchers.nullValue()) + } + + @Test + public fun shouldSignupWithPasskeyReturningVerificationRequired() { + mockAPI.willReturnSuccessfulPasskeyRegistrationWithVerificationRequired("email") + val client = AuthenticationAPIClient(auth0) + val registrationResponse = client.signupWithPasskey( + UserData(email = "test@example.com"), + MY_CONNECTION + ).execute() + mockAPI.takeRequest() + assertThat( + registrationResponse.verificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL) + ) + } + + @Test + public fun shouldSignupWithPasskeyReturningMultipleVerificationRequired() { + mockAPI.willReturnSuccessfulPasskeyRegistrationWithVerificationRequired("email", "phone") + val client = AuthenticationAPIClient(auth0) + val registrationResponse = client.signupWithPasskey( + UserData(email = "test@example.com"), + MY_CONNECTION + ).execute() + mockAPI.takeRequest() + assertThat( + registrationResponse.verificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE) + ) + } + + @Test + public fun shouldSignupWithPasskeyForwardingDeliveryMethod() { + mockAPI.willReturnSuccessfulPasskeyRegistration() + val client = AuthenticationAPIClient(auth0) + client.signupWithPasskey( + UserData(phoneNumber = "+1234567890"), + MY_CONNECTION, + deliveryMethod = DeliveryMethod.VOICE + ).execute() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("delivery_method", "voice")) + } + + @Test + public fun shouldSignupWithPasskeyOmittingDeliveryMethodWhenNull() { + mockAPI.willReturnSuccessfulPasskeyRegistration() + val client = AuthenticationAPIClient(auth0) + client.signupWithPasskey( + UserData(email = "test@example.com"), + MY_CONNECTION + ).execute() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.not(Matchers.hasKey("delivery_method"))) + } + + @Test + public fun shouldSigninWithPasskeyForwardingVerification() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf( + PasskeyVerificationMethod.EMAIL to "123456", + PasskeyVerificationMethod.PHONE to "654321" + ) + ).start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasKey("verification")) + @Suppress("UNCHECKED_CAST") + val verification = body["verification"] as Map + assertThat(verification, Matchers.hasEntry("email", "123456")) + assertThat(verification, Matchers.hasEntry("phone", "654321")) + } + + @Test + public fun shouldSigninWithPasskeyOmittingVerificationByDefault() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION + ).start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.not(Matchers.hasKey("verification"))) + } + + @Test + public fun shouldSigninWithPasskeyOmittingEmptyVerification() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = emptyMap() + ).start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.not(Matchers.hasKey("verification"))) + } + + @Test + public fun shouldSigninWithPasskeySendingBlankVerificationCodeToTheServer() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf( + PasskeyVerificationMethod.EMAIL to "123456", + PasskeyVerificationMethod.PHONE to "" + ) + ).start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + @Suppress("UNCHECKED_CAST") + val verification = body["verification"] as Map + assertThat(verification, Matchers.hasEntry("email", "123456")) + assertThat(verification, Matchers.hasEntry("phone", "")) + } + + @Test + public fun shouldClassifyRetryablePasskeyVerificationError() { + mockAPI.willReturnPasskeyVerificationRetryableError("email") + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "000000") + ).execute() + } + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(true)) + assertThat(exception.passkeyAuthSession, Matchers.`is`(SESSION_ID)) + assertThat( + exception.passkeyVerificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL) + ) + } + + @Test + public fun shouldClassifyTerminalPasskeyVerificationError() { + mockAPI.willReturnPasskeyVerificationTerminalError() + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "000000") + ).execute() + } + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + assertThat(exception.passkeyAuthSession, Matchers.nullValue()) + assertThat(exception.passkeyVerificationRequired, Matchers.nullValue()) + } + + @Test + public fun shouldClassifyInvalidRequestWithSessionAsRetryable() { + mockAPI.willReturnPasskeyVerificationInvalidRequestError() + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION + ).execute() + } + assertThat(exception.getCode(), Matchers.`is`("invalid_request")) + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(true)) + assertThat(exception.passkeyAuthSession, Matchers.`is`(SESSION_ID)) + assertThat(exception.passkeyVerificationRequired, Matchers.nullValue()) + } + + @Test + public fun shouldNotClassifyInvalidRequestWithoutSessionAsRetryable() { + mockAPI.willReturnInvalidRequestWithoutSessionError() + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION + ).execute() + } + assertThat(exception.getCode(), Matchers.`is`("invalid_request")) + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + assertThat(exception.passkeyAuthSession, Matchers.nullValue()) + assertThat(exception.passkeyVerificationRequired, Matchers.nullValue()) + } + + @Test + public fun shouldSkipUnknownIdentifiersInPasskeyVerificationRequired() { + mockAPI.willReturnPasskeyVerificationRetryableError("email", "username") + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "000000") + ).execute() + } + assertThat( + exception.passkeyVerificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL) + ) + } + + @Test + public fun shouldClassifyConsumedSessionPasskeyVerificationErrorAsTerminal() { + mockAPI.willReturnPasskeyVerificationConsumedSessionError() + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "123456") + ).execute() + } + assertThat(exception.statusCode, Matchers.`is`(403)) + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + } + + @Test + public fun shouldNotClassifyMfaRequiredOnPasskeyAsVerificationRetryable() { + mockAPI.willReturnMfaRequiredError() + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "123456") + ).execute() + } + assertThat(exception.isMultifactorRequired, Matchers.`is`(true)) + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + assertThat(exception.mfaRequiredErrorPayload?.mfaToken, Matchers.`is`("MFA_TOKEN")) + } + + @Test + public fun shouldSurface429PlainTextRateLimitOnPasskeyRegister() { + mockAPI.willReturnTooManyRequestsPlainText() + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signupWithPasskey( + UserData(email = "test@example.com"), + MY_CONNECTION + ).execute() + } + assertThat(exception.statusCode, Matchers.`is`(429)) + assertThat(exception.getDescription(), Matchers.`is`("Too Many Requests")) + assertThat(exception.isTooManyAttempts, Matchers.`is`(false)) + } + @Test public fun shouldLoginWithUserAndPasswordSync() { val jwt = JwtTestUtils.createTestJWT("HS256", mapOf("sub" to "auth0|123456")) diff --git a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt index caa89b3a2..f9087700c 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt @@ -3,6 +3,7 @@ package com.auth0.android.authentication import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.request.internal.GsonProvider +import com.auth0.android.result.PasskeyVerificationMethod import com.google.gson.reflect.TypeToken import org.hamcrest.CoreMatchers import org.hamcrest.MatcherAssert @@ -532,6 +533,55 @@ public class AuthenticationExceptionTest { MatcherAssert.assertThat(ex.isTooManyAttempts, CoreMatchers.`is`(true)) } + @Test + public fun shouldBeRetryablePasskeyVerificationForInvalidGrantWithSession() { + values[ERROR_KEY] = "invalid_grant" + values["auth_session"] = "session" + values["verification_required"] = listOf("email", "phone") + val ex = AuthenticationException(values, 400) + MatcherAssert.assertThat(ex.isPasskeyVerificationRetryable, CoreMatchers.`is`(true)) + MatcherAssert.assertThat(ex.passkeyAuthSession, CoreMatchers.`is`("session")) + MatcherAssert.assertThat( + ex.passkeyVerificationRequired, + CoreMatchers.`is`(listOf(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE)) + ) + } + + @Test + public fun shouldBeRetryablePasskeyVerificationForInvalidRequestWithSession() { + values[ERROR_KEY] = "invalid_request" + values["auth_session"] = "session" + val ex = AuthenticationException(values, 400) + MatcherAssert.assertThat(ex.isPasskeyVerificationRetryable, CoreMatchers.`is`(true)) + MatcherAssert.assertThat(ex.passkeyVerificationRequired, CoreMatchers.nullValue()) + } + + @Test + public fun shouldNotExposeVerificationRequiredForInvalidRequest() { + values[ERROR_KEY] = "invalid_request" + values["auth_session"] = "session" + values["verification_required"] = listOf("email") + val ex = AuthenticationException(values, 400) + MatcherAssert.assertThat(ex.isPasskeyVerificationRetryable, CoreMatchers.`is`(true)) + MatcherAssert.assertThat(ex.passkeyVerificationRequired, CoreMatchers.nullValue()) + } + + @Test + public fun shouldNotBeRetryablePasskeyVerificationWithoutSession() { + values[ERROR_KEY] = "invalid_grant" + val ex = AuthenticationException(values, 400) + MatcherAssert.assertThat(ex.isPasskeyVerificationRetryable, CoreMatchers.`is`(false)) + MatcherAssert.assertThat(ex.passkeyAuthSession, CoreMatchers.nullValue()) + } + + @Test + public fun shouldNotBeRetryablePasskeyVerificationForOtherErrorsWithSession() { + values[ERROR_KEY] = "server_error" + values["auth_session"] = "session" + val ex = AuthenticationException(values, 500) + MatcherAssert.assertThat(ex.isPasskeyVerificationRetryable, CoreMatchers.`is`(false)) + } + private companion object { private const val PASSWORD_STRENGTH_ERROR_RESPONSE = "src/test/resources/password_strength_error.json" diff --git a/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt b/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt index 8c770ae5d..42ed3b878 100755 --- a/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt @@ -201,6 +201,107 @@ internal class AuthenticationAPIMockServer : APIMockServer() { return this } + fun willReturnSuccessfulPasskeyRegistrationWithVerificationRequired( + vararg identifiers: String + ): AuthenticationAPIMockServer { + val verificationRequired = identifiers.joinToString(prefix = "[", postfix = "]") { "\"$it\"" } + val json = """{ + "authn_params_public_key":{ + "challenge": "$CHALLENGE", + "timeout": 900000, + "rp": { + "id": "auth0.passkey.com", + "name": "Passkey Test" + }, + "pubKeyCredParams": [ + { + "type": "public-key", + "alg": -7 + } + ], + "authenticatorSelection": { + "authenticatorAttachment": "platform", + "residentKey": "required" + }, + "user": { + "id": "53b995f8bce68d9fc900099c", + "name": "p", + "displayName": "d" + } + }, + "auth_session": "$SESSION_ID", + "verification_required": $verificationRequired + }""" + server.enqueue(responseWithJSON(json, 200)) + return this + } + + fun willReturnPasskeyVerificationRetryableError( + vararg identifiers: String + ): AuthenticationAPIMockServer { + val verificationRequired = identifiers.joinToString(prefix = "[", postfix = "]") { "\"$it\"" } + val json = """{ + "error": "invalid_grant", + "error_description": "Invalid or expired session", + "auth_session": "$SESSION_ID", + "verification_required": $verificationRequired + }""" + server.enqueue(responseWithJSON(json, 400)) + return this + } + + fun willReturnPasskeyVerificationTerminalError(): AuthenticationAPIMockServer { + val json = """{ + "error": "invalid_grant", + "error_description": "Invalid or expired session" + }""" + server.enqueue(responseWithJSON(json, 400)) + return this + } + + fun willReturnPasskeyVerificationInvalidRequestError(): AuthenticationAPIMockServer { + val json = """{ + "error": "invalid_request", + "error_description": "Missing required parameter: verification.email", + "auth_session": "$SESSION_ID" + }""" + server.enqueue(responseWithJSON(json, 400)) + return this + } + + fun willReturnInvalidRequestWithoutSessionError(): AuthenticationAPIMockServer { + val json = """{ + "error": "invalid_request", + "error_description": "Invalid realm" + }""" + server.enqueue(responseWithJSON(json, 400)) + return this + } + + fun willReturnMfaRequiredError(): AuthenticationAPIMockServer { + val json = """{ + "error": "mfa_required", + "error_description": "Multifactor authentication required", + "mfa_token": "MFA_TOKEN" + }""" + server.enqueue(responseWithJSON(json, 403)) + return this + } + + fun willReturnPasskeyVerificationConsumedSessionError(): AuthenticationAPIMockServer { + val json = """{ + "error": "invalid_grant", + "error_description": "Invalid or expired session" + }""" + server.enqueue(responseWithJSON(json, 403)) + return this + } + + fun willReturnTooManyRequestsPlainText(): AuthenticationAPIMockServer { + server.enqueue(responseWithPlainText("Too Many Requests", 429)) + return this + } + private fun responseEmpty(statusCode: Int): MockResponse { return MockResponse() .setResponseCode(statusCode) diff --git a/examples/passkeys.md b/examples/passkeys.md index 0ab11a1e6..3ed3a3b71 100644 --- a/examples/passkeys.md +++ b/examples/passkeys.md @@ -98,6 +98,144 @@ try { ``` +### Passkey signup with identifier verification + +When the database connection has verification enabled for email and phone, the `signupWithPasskey` +response includes a `verificationRequired` list naming the identifiers +(`PasskeyVerificationMethod.EMAIL`, `PasskeyVerificationMethod.PHONE`) that must be verified before tokens +can be issued. It is `null` when no verification is required. You must: + +1. Collect the OTP code for every identifier in `verificationRequired` **before** creating the + platform credential with `CredentialManager.createCredential(...)`. Creating the credential first + and then failing verification leaves an orphaned local passkey and shows a second biometric prompt + on retry. +2. Pass the collected codes to `signinWithPasskey` via the `verification` map (identifier -> code). +3. On a retryable error (`AuthenticationException.isPasskeyVerificationRetryable == true`), re-collect + the codes and call `signinWithPasskey` again **reusing the same `PublicKeyCredentials`** — the + biometric prompt will not reappear. + +A failed `signinWithPasskey` can be retried with the same credential when `isPasskeyVerificationRetryable` is +`true`. There are two cases: + +- **A code is missing or empty** (the `verification` map is empty, or the email or phone code is blank). The error is + `invalid_request` with an `auth_session`. Collect the codes for `challenge.verificationRequired` and retry. +- **A code is wrong.** The error is `invalid_grant` with an `auth_session`, and `passkeyVerificationRequired` lists + the identifiers whose code was wrong. Collect new codes for those identifiers and retry. + +Any other error is terminal, so restart the registration from `signupWithPasskey`. + +Optionally pass `deliveryMethod` (`DeliveryMethod.TEXT` or `DeliveryMethod.VOICE`) to +`signupWithPasskey` to choose the phone OTP channel. + +```kotlin +// Using Coroutines +try { + val userData = UserData(email = "user@example.com") + + val challenge = authenticationApiClient.signupWithPasskey( + userData, + "{realm}", + "{organization-id}", + deliveryMethod = DeliveryMethod.TEXT // optional, phone OTP channel + ).await() + + // Collect OTP codes BEFORE creating the credential. + var codes: Map = challenge.verificationRequired.orEmpty() + .associateWith { identifier -> promptUserForOtp(identifier) } // app-defined + + // Now create the platform credential. + val request = CreatePublicKeyCredentialRequest( + Gson().toJson(challenge.authParamsPublicKey) + ) + val result = credentialManager.createCredential(requireContext(), request) + val authRequest = Gson().fromJson( + (result as CreatePublicKeyCredentialResponse).registrationResponseJson, + PublicKeyCredentials::class.java + ) + + var authSession = challenge.authSession + val maxAttempts = 3 // retry limit; adjust it to suit your app + var attempts = 0 + while (true) { + try { + val userCredential = authenticationApiClient.signinWithPasskey( + authSession, + authRequest, + "{realm}", + "{organization-id}", + verification = codes + ).validateClaims().await() + break // success + } catch (exception: AuthenticationException) { + if (!exception.isPasskeyVerificationRetryable || ++attempts >= maxAttempts) { + throw exception // terminal (or too many attempts) - restart registration + } + // Re-collect the failed identifiers and retry with the SAME credential. If the error does not + // list them, fall back to the identifiers of the original challenge. + authSession = exception.passkeyAuthSession ?: authSession + val failed = exception.passkeyVerificationRequired ?: challenge.verificationRequired.orEmpty() + codes = codes + failed.associateWith { identifier -> promptUserForOtp(identifier) } + } + } +} catch (e: CreateCredentialException) { + // The passkey could not be created or the user cancelled: show an error and let the user try again. +} catch (exception: AuthenticationException) { + // Verification failed for good or the retries ran out: show the error and restart the registration. +} +``` +
+ Using Java + +```java +UserData userData = new UserData("user@example.com", null, null, null, null, null, null, null, null); + +authenticationAPIClient.signupWithPasskey(userData, "{realm}", "{organization-id}", DeliveryMethod.TEXT) + .start(new Callback() { + @Override + public void onSuccess(PasskeyRegistrationChallenge result) { + // Collect OTP codes for result.getVerificationRequired() BEFORE creating the credential. + Map verification = collectOtpCodes(result.getVerificationRequired()); // app-defined + + CreateCredentialRequest request = + new CreatePublicKeyCredentialRequest(new Gson().toJson(result.getAuthParamsPublicKey())); + credentialManager.createCredentialAsync(getContext(), request, cancellationSignal, , + new CredentialManagerCallback() { + @Override + public void onResult(CreateCredentialResponse createCredentialResponse) { + PublicKeyCredentials credentials = new Gson().fromJson( + ((CreatePublicKeyCredentialResponse) createCredentialResponse).getRegistrationResponseJson(), + PublicKeyCredentials.class); + + authenticationAPIClient.signinWithPasskey(result.getAuthSession(), + credentials, "{realm}", "{organization-id}", verification) + .start(new Callback() { + @Override + public void onSuccess(Credentials result) {} + + @Override + public void onFailure(@NonNull AuthenticationException error) { + if (error.isPasskeyVerificationRetryable()) { + // Re-collect the codes for error.getPasskeyVerificationRequired(), or for + // result.getVerificationRequired() when it is null, and retry + // signinWithPasskey with the SAME credentials. Use + // error.getPasskeyAuthSession() when present, else result.getAuthSession(). + } else { + // Terminal — restart registration. + } + } + }); + } + @Override + public void onError(@NonNull CreateCredentialException e) {} + }); + } + + @Override + public void onFailure(@NonNull AuthenticationException error) {} +}); +``` +
+ To sign in a user with passkey ```kotlin //Using coroutines