From 9478b7a22de67432caec39a5bc9962e76af8f2ea Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 9 Oct 2026 15:30:04 +0530 Subject: [PATCH 1/6] feat: add passkey identifier verification support --- .../authentication/AuthenticationAPIClient.kt | 32 +++- .../authentication/AuthenticationException.kt | 47 +++++ .../authentication/PasskeyDeliveryMethod.kt | 19 ++ .../result/PasskeyRegistrationChallenge.kt | 13 +- .../AuthenticationAPIClientTest.kt | 180 ++++++++++++++++++ .../util/AuthenticationAPIMockServer.kt | 86 +++++++++ examples/passkeys.md | 125 ++++++++++++ 7 files changed, 495 insertions(+), 7 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/authentication/PasskeyDeliveryMethod.kt 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..bb77b962a 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -234,13 +234,18 @@ 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("email" to "123456")`. + * The keys must match the identifiers returned in [PasskeyRegistrationChallenge.verificationRequired]. + * Pass `null` or 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? = null, ): AuthenticationRequest { val params = ParameterBuilder.newBuilder().apply { setGrantType(ParameterBuilder.GRANT_TYPE_PASSKEY) @@ -249,11 +254,15 @@ 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.isNullOrEmpty()) { + request.addParameter(VERIFICATION_KEY, Gson().toJsonTree(verification)) + } + return request as AuthenticationRequest } @@ -281,19 +290,24 @@ 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("email" to "123456")`. + * The keys must match the identifiers returned in [PasskeyRegistrationChallenge.verificationRequired]. + * Pass `null` or 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? = null, ): AuthenticationRequest { val publicKeyCredentials = gson.fromJson( authResponse, PublicKeyCredentials::class.java ) - return signinWithPasskey(authSession, publicKeyCredentials, realm, organization) + return signinWithPasskey(authSession, publicKeyCredentials, realm, organization, verification) } @@ -323,14 +337,19 @@ 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 realm the connection to use.x₹ 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: PasskeyDeliveryMethod? = null ): Request { val url = auth0.getDomainUrl().toHttpUrl().newBuilder() .addPathSegment(PASSKEY_PATH) @@ -351,6 +370,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 +1062,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..c25c0e77e 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt @@ -200,6 +200,42 @@ 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` only when the error body contains an `auth_session`. + * + * When retryable, re-collect the OTP codes for [passkeyVerificationRequired] and call + * [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey] again with the + * same credential and auth session. When `false`, restart the registration. + * + * Use this property instead of the status code or [getDescription], which are identical for + * retryable and terminal errors by design. + */ + public val isPasskeyVerificationRetryable: Boolean + get() = getValue(AUTH_SESSION_KEY) != null + + /** + * The auth session returned in a retryable passkey verification error, to be reused on the + * retry so the same passkey credential and session are kept. `null` when the error is terminal. + * + * @see isPasskeyVerificationRetryable + */ + public val passkeyAuthSession: String? + get() = getValue(AUTH_SESSION_KEY) as? String + + /** + * The identifiers (e.g. `["email"]`) whose OTP codes failed and must be re-collected before + * retrying. Non-null only when [isPasskeyVerificationRetryable] is `true`. + * + * @see isPasskeyVerificationRetryable + */ + @Suppress("UNCHECKED_CAST") + public val passkeyVerificationRequired: List? + get() { + if (!isPasskeyVerificationRetryable) return null + return getValue(VERIFICATION_REQUIRED_KEY) as? List + } + /// 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 || @@ -258,6 +294,15 @@ public class AuthenticationException : Auth0Exception { public val isTooManyAttempts: Boolean get() = "too_many_attempts" == code + /** + * When the request was rate-limited by the server (HTTP 429). + * + * The passkey `/passkey/register` endpoint returns a plain-text body (not JSON) on 429, so the + * error code and description may not be populated — use this status-based flag to detect it. + */ + public val isTooManyRequests: Boolean + get() = statusCode == 429 + internal companion object { internal const val ERROR_VALUE_AUTHENTICATION_CANCELED = "a0.authentication_canceled" internal const val ERROR_KEY_URI_NULL = "a0.auth.authorize_uri" @@ -267,6 +312,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/PasskeyDeliveryMethod.kt b/auth0/src/main/java/com/auth0/android/authentication/PasskeyDeliveryMethod.kt new file mode 100644 index 000000000..f983f32ce --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/authentication/PasskeyDeliveryMethod.kt @@ -0,0 +1,19 @@ +package com.auth0.android.authentication + +/** + * Delivery channel for a phone-number OTP during passkey signup identifier verification. + * + * Maps to the `delivery_method` request parameter of `POST /passkey/register`. [TEXT] sends the + * one-time code via SMS (the server default); [VOICE] delivers it through a voice call. 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. + * + * @property value the wire value sent to the server. + */ +public enum class PasskeyDeliveryMethod(public val value: String) { + /** Deliver the one-time code via SMS. */ + TEXT("text"), + + /** Deliver the one-time code via a voice call. */ + VOICE("voice") +} 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..6d6f07636 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/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt index e9a07b654..e27a23f96 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -302,6 +302,186 @@ public class AuthenticationAPIClientTest { } + @Test + public fun shouldSignupWithPasskeyWithoutVerificationRequired() { + 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("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("email", "phone")) + } + + @Test + public fun shouldSignupWithPasskeyForwardingDeliveryMethod() { + mockAPI.willReturnSuccessfulPasskeyRegistration() + val client = AuthenticationAPIClient(auth0) + client.signupWithPasskey( + UserData(phoneNumber = "+1234567890"), + MY_CONNECTION, + deliveryMethod = PasskeyDeliveryMethod.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("email" to "123456") + ).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")) + } + + @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 shouldClassifyRetryablePasskeyVerificationError() { + mockAPI.willReturnPasskeyVerificationRetryableError("email") + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf("email" to "000000") + ).execute() + } + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(true)) + assertThat(exception.passkeyAuthSession, Matchers.`is`(SESSION_ID)) + assertThat(exception.passkeyVerificationRequired, Matchers.contains("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("email" to "000000") + ).execute() + } + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + assertThat(exception.passkeyAuthSession, Matchers.nullValue()) + assertThat(exception.passkeyVerificationRequired, Matchers.nullValue()) + } + + @Test + public fun shouldClassifyMissingCodePasskeyVerificationErrorAsRetryable() { + mockAPI.willReturnPasskeyVerificationMissingCodeError("email") + 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.passkeyVerificationRequired, Matchers.contains("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("email" to "123456") + ).execute() + } + assertThat(exception.statusCode, Matchers.`is`(403)) + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + } + + @Test + public fun shouldSurface429PlainTextAsRateLimitErrorOnPasskeyRegister() { + mockAPI.willReturnTooManyRequestsPlainText() + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signupWithPasskey( + UserData(email = "test@example.com"), + MY_CONNECTION + ).execute() + } + assertThat(exception.isTooManyRequests, Matchers.`is`(true)) + assertThat(exception.statusCode, Matchers.`is`(429)) + } + @Test public fun shouldLoginWithUserAndPasswordSync() { val jwt = JwtTestUtils.createTestJWT("HS256", mapOf("sub" to "auth0|123456")) 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..27f345575 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,92 @@ 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 willReturnPasskeyVerificationMissingCodeError( + vararg identifiers: String + ): AuthenticationAPIMockServer { + val verificationRequired = identifiers.joinToString(prefix = "[", postfix = "]") { "\"$it\"" } + val json = """{ + "error": "invalid_request", + "error_description": "A required verification code is missing", + "auth_session": "$SESSION_ID", + "verification_required": $verificationRequired + }""" + server.enqueue(responseWithJSON(json, 400)) + 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..3adcb5fed 100644 --- a/examples/passkeys.md +++ b/examples/passkeys.md @@ -98,6 +98,131 @@ try { ``` +### Passkey signup with identifier verification + +When the database connection has OTP identifier verification enabled, the `signupWithPasskey` +response includes a non-null `verificationRequired` list naming the identifiers (e.g. `email`, +`phone`) that must be verified before tokens can be issued. 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 + only the failed identifiers and call `signinWithPasskey` again **reusing the same `PublicKeyCredentials` + and auth session** — the biometric prompt will not reappear. + +An error is retryable **if and only if** the error carries an `auth_session` +(`isPasskeyVerificationRetryable`). Never branch on the HTTP status code or error description — they are +identical on the retryable and terminal paths by design. + +Optionally pass `deliveryMethod` (`PasskeyDeliveryMethod.TEXT` or `PasskeyDeliveryMethod.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 = PasskeyDeliveryMethod.TEXT // optional, phone OTP channel + ).await() + + // Collect OTP codes BEFORE creating the credential. + val verification: Map = challenge.verificationRequired + ?.associateWith { identifier -> promptUserForOtp(identifier) } // app-defined + ?: emptyMap() + + // 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 codes = verification + while (true) { + try { + val userCredential = authenticationApiClient.signinWithPasskey( + challenge.authSession, + authRequest, + "{realm}", + "{organization-id}", + verification = codes + ).validateClaims().await() + break // success + } catch (exception: AuthenticationException) { + if (exception.isPasskeyVerificationRetryable) { + // Re-collect only the failed identifiers and retry with the SAME credential. + val failed = exception.passkeyVerificationRequired ?: emptyList() + codes = failed.associateWith { identifier -> promptUserForOtp(identifier) } + } else { + throw exception // terminal — restart registration + } + } + } +} catch (e: CreateCredentialException) { +} catch (exception: AuthenticationException) { +} +``` +
+ Using Java + +```java +UserData userData = new UserData("user@example.com", null, null, null, null, null, null, null, null); + +authenticationAPIClient.signupWithPasskey(userData, "{realm}", "{organization-id}", PasskeyDeliveryMethod.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 error.getPasskeyVerificationRequired() and retry + // signinWithPasskey with the SAME credentials. + } 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 From 0750a5d471c0233f1e1b593a6bec61d7895e499e Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 14 Oct 2026 13:25:33 +0530 Subject: [PATCH 2/6] fix(passkey): address review feedback on identifier verification - Add PasskeyVerificationMethod and use it for verification codes and identifiers - Retryable when invalid_grant has auth_session, or invalid_request for a missing/empty code - Reject blank verification codes before the network call - verification defaults to emptyMap; verificationRequired defaults to an empty list - Reuse passwordless.DeliveryMethod instead of PasskeyDeliveryMethod - Treat HTTP 429 as isTooManyAttempts; remove isTooManyRequests - Add mfa_required test; update passkey docs Co-Authored-By: Claude --- .../authentication/AuthenticationAPIClient.kt | 46 ++++-- .../authentication/AuthenticationException.kt | 66 +++++--- .../authentication/PasskeyDeliveryMethod.kt | 19 --- .../passwordless/DeliveryMethod.kt | 5 +- .../android/request/internal/GsonProvider.kt | 5 + ...asskeyRegistrationChallengeDeserializer.kt | 52 ++++++ .../result/PasskeyRegistrationChallenge.kt | 6 +- .../result/PasskeyVerificationMethod.kt | 67 ++++++++ .../AuthenticationAPIClientTest.kt | 151 ++++++++++++++++-- ...eyRegistrationChallengeDeserializerTest.kt | 65 ++++++++ .../util/AuthenticationAPIMockServer.kt | 18 ++- examples/passkeys.md | 60 ++++--- 12 files changed, 458 insertions(+), 102 deletions(-) delete mode 100644 auth0/src/main/java/com/auth0/android/authentication/PasskeyDeliveryMethod.kt create mode 100644 auth0/src/main/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializer.kt create mode 100644 auth0/src/main/java/com/auth0/android/result/PasskeyVerificationMethod.kt create mode 100644 auth0/src/test/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializerTest.kt 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 bb77b962a..ae1f8b6f6 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 @@ -17,6 +18,8 @@ import com.auth0.android.request.JsonAdapter import com.auth0.android.request.ProfileRequest import com.auth0.android.request.PublicKeyCredentials import com.auth0.android.request.Request +import com.auth0.android.request.RequestOptions +import com.auth0.android.request.RequestValidator import com.auth0.android.request.SignUpRequest import com.auth0.android.request.UserData import com.auth0.android.request.internal.BaseAuthenticationRequest @@ -31,6 +34,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,9 +238,11 @@ 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("email" to "123456")`. - * The keys must match the identifiers returned in [PasskeyRegistrationChallenge.verificationRequired]. - * Pass `null` or an empty map when no identifier verification is required; the `verification` object is then omitted from the request. + * @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. A blank code fails the request with an + * [AuthenticationException] before any network call is made. * @return a request to configure and start that will yield [Credentials] */ @JvmOverloads @@ -245,7 +251,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe authResponse: PublicKeyCredentials, realm: String? = null, organization: String? = null, - verification: Map? = null, + verification: Map = emptyMap(), ): AuthenticationRequest { val params = ParameterBuilder.newBuilder().apply { setGrantType(ParameterBuilder.GRANT_TYPE_PASSKEY) @@ -259,8 +265,22 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe AUTH_RESPONSE_KEY, Gson().toJsonTree(authResponse) ) - if (!verification.isNullOrEmpty()) { - request.addParameter(VERIFICATION_KEY, Gson().toJsonTree(verification)) + if (verification.isNotEmpty()) { + request.addParameter( + VERIFICATION_KEY, + Gson().toJsonTree(verification.mapKeys { it.key.value }) + ) + request.addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { + val blank = verification.filterValues { it.isBlank() }.keys + if (blank.isNotEmpty()) { + throw AuthenticationException( + AuthenticationException.ERROR_KEY_VERIFICATION_CODE_EMPTY, + "The verification code for ${blank.joinToString { it.value }} must not be empty." + ) + } + } + }) } return request as AuthenticationRequest } @@ -290,9 +310,11 @@ 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("email" to "123456")`. - * The keys must match the identifiers returned in [PasskeyRegistrationChallenge.verificationRequired]. - * Pass `null` or an empty map when no identifier verification is required; the `verification` object is then omitted from the request. + * @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. A blank code fails the request with an + * [AuthenticationException] before any network call is made. * @return a request to configure and start that will yield [Credentials] */ @JvmOverloads @@ -301,7 +323,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe authResponse: String, realm: String? = null, organization: String? = null, - verification: Map? = null, + verification: Map = emptyMap(), ): AuthenticationRequest { val publicKeyCredentials = gson.fromJson( authResponse, @@ -337,7 +359,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * ``` * * @param userData user information for registration. - * @param realm the connection to use.x₹ If excluded, the application will use the default connection configured in the tenant + * @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 @@ -349,7 +371,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe userData: UserData, realm: String? = null, organization: String? = null, - deliveryMethod: PasskeyDeliveryMethod? = null + deliveryMethod: DeliveryMethod? = null ): Request { val url = auth0.getDomainUrl().toHttpUrl().newBuilder() .addPathSegment(PASSKEY_PATH) 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 c25c0e77e..5ca4ad05f 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 @@ -202,21 +203,26 @@ public class AuthenticationException : Auth0Exception { /** * Whether a failed passkey signup identifier verification can be retried without restarting - * the registration. `true` only when the error body contains an `auth_session`. + * the registration. `true` when: + * - the error is `invalid_grant` and contains an `auth_session` (a code was wrong and the + * session still has attempts left), or + * - the error is `invalid_request` because a verification code was missing or empty. * - * When retryable, re-collect the OTP codes for [passkeyVerificationRequired] and call + * When retryable, re-collect the OTP codes and call * [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey] again with the - * same credential and auth session. When `false`, restart the registration. + * same credential. Use [passkeyAuthSession] when it is present; otherwise reuse the auth session + * of the original challenge. When `false`, restart the registration. * - * Use this property instead of the status code or [getDescription], which are identical for - * retryable and terminal errors by design. + * @see passkeyAuthSession + * @see passkeyVerificationRequired */ public val isPasskeyVerificationRetryable: Boolean - get() = getValue(AUTH_SESSION_KEY) != null + get() = isPasskeyVerificationCodeInvalid || isPasskeyVerificationCodeMissing /** - * The auth session returned in a retryable passkey verification error, to be reused on the - * retry so the same passkey credential and session are kept. `null` when the error is terminal. + * The auth session returned with a failed passkey verification, if any. `null` does not mean + * the session is lost: a missing or empty code is reported without a session, and the auth + * session of the original challenge can still be used. * * @see isPasskeyVerificationRetryable */ @@ -224,16 +230,31 @@ public class AuthenticationException : Auth0Exception { get() = getValue(AUTH_SESSION_KEY) as? String /** - * The identifiers (e.g. `["email"]`) whose OTP codes failed and must be re-collected before - * retrying. Non-null only when [isPasskeyVerificationRetryable] is `true`. + * The identifiers whose OTP codes must be re-collected before retrying. Only set when the + * error is an `invalid_grant` with an `auth_session`; otherwise `null` and the identifiers of + * the original challenge apply. * * @see isPasskeyVerificationRetryable */ @Suppress("UNCHECKED_CAST") - public val passkeyVerificationRequired: List? + public val passkeyVerificationRequired: List? get() { - if (!isPasskeyVerificationRetryable) return null - return getValue(VERIFICATION_REQUIRED_KEY) as? List + if (!isPasskeyVerificationCodeInvalid) return null + return (getValue(VERIFICATION_REQUIRED_KEY) as? List<*>) + ?.filterIsInstance() + ?.map { PasskeyVerificationMethod.fromValue(it) } + } + + private val isPasskeyVerificationCodeInvalid: Boolean + get() = "invalid_grant" == code && passkeyAuthSession != null + + private val isPasskeyVerificationCodeMissing: Boolean + get() { + if ("invalid_request" != code) return false + val description = description ?: return false + return description.startsWith(MISSING_VERIFICATION_PREFIX) + || (description.startsWith(EMPTY_VERIFICATION_PREFIX) + && description.endsWith(EMPTY_VERIFICATION_SUFFIX)) } /// When the MFA Token used on the login request is malformed or has expired @@ -290,22 +311,20 @@ public class AuthenticationException : Auth0Exception { public val isIdTokenValidationError: Boolean get() = cause is TokenValidationException - /// When the user is blocked due to too many attempts to log in - public val isTooManyAttempts: Boolean - get() = "too_many_attempts" == code - /** - * When the request was rate-limited by the server (HTTP 429). + * When the user is blocked due to too many attempts to log in, or the request was rate-limited + * by the server (HTTP 429). * - * The passkey `/passkey/register` endpoint returns a plain-text body (not JSON) on 429, so the - * error code and description may not be populated — use this status-based flag to detect it. + * Some endpoints, like `/passkey/register`, return a plain-text body (not JSON) on 429, so the + * error code may not be populated — the status code is checked as well. */ - public val isTooManyRequests: Boolean - get() = statusCode == 429 + public val isTooManyAttempts: Boolean + get() = "too_many_attempts" == code || statusCode == 429 internal companion object { internal const val ERROR_VALUE_AUTHENTICATION_CANCELED = "a0.authentication_canceled" internal const val ERROR_KEY_URI_NULL = "a0.auth.authorize_uri" + internal const val ERROR_KEY_VERIFICATION_CODE_EMPTY = "a0.auth.verification_code_empty" internal const val ERROR_VALUE_AUTHORIZE_URI_INVALID = "Authorization URI is received as null from the intent" internal const val ERROR_KEY_CT_OPTIONS_NULL = "a0.auth.ct_options" @@ -314,6 +333,9 @@ public class AuthenticationException : Auth0Exception { 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 MISSING_VERIFICATION_PREFIX = "Missing required parameter: verification" + private const val EMPTY_VERIFICATION_PREFIX = "\"verification." + private const val EMPTY_VERIFICATION_SUFFIX = "is not allowed to be empty" 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/PasskeyDeliveryMethod.kt b/auth0/src/main/java/com/auth0/android/authentication/PasskeyDeliveryMethod.kt deleted file mode 100644 index f983f32ce..000000000 --- a/auth0/src/main/java/com/auth0/android/authentication/PasskeyDeliveryMethod.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.auth0.android.authentication - -/** - * Delivery channel for a phone-number OTP during passkey signup identifier verification. - * - * Maps to the `delivery_method` request parameter of `POST /passkey/register`. [TEXT] sends the - * one-time code via SMS (the server default); [VOICE] delivers it through a voice call. 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. - * - * @property value the wire value sent to the server. - */ -public enum class PasskeyDeliveryMethod(public val value: String) { - /** Deliver the one-time code via SMS. */ - TEXT("text"), - - /** Deliver the one-time code via a voice call. */ - VOICE("voice") -} 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/request/internal/GsonProvider.kt b/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.kt index a927a89dd..bc446b120 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.kt @@ -2,6 +2,7 @@ package com.auth0.android.request.internal import androidx.annotation.VisibleForTesting import com.auth0.android.result.Credentials +import com.auth0.android.result.PasskeyRegistrationChallenge import com.auth0.android.result.SSOCredentials import com.auth0.android.result.UserProfile import com.google.gson.Gson @@ -27,6 +28,10 @@ internal object GsonProvider { .registerTypeAdapter(UserProfile::class.java, UserProfileDeserializer()) .registerTypeAdapter(Credentials::class.java, CredentialsDeserializer()) .registerTypeAdapter(SSOCredentials::class.java, SSOCredentialsDeserializer()) + .registerTypeAdapter( + PasskeyRegistrationChallenge::class.java, + PasskeyRegistrationChallengeDeserializer() + ) .registerTypeAdapter(jwksType, JwksDeserializer()) .setDateFormat(DATE_FORMAT) .create() diff --git a/auth0/src/main/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializer.kt b/auth0/src/main/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializer.kt new file mode 100644 index 000000000..8a76c2824 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializer.kt @@ -0,0 +1,52 @@ +package com.auth0.android.request.internal + +import com.auth0.android.result.AuthnParamsPublicKey +import com.auth0.android.result.PasskeyRegistrationChallenge +import com.auth0.android.result.PasskeyVerificationMethod +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import com.google.gson.reflect.TypeToken +import java.lang.reflect.Type + +/** + * Gson skips Kotlin default values for fields missing from the JSON, which would leave + * `verificationRequired` as `null`. This builds the challenge through its constructor instead, so + * the field is an empty list when the server omits `verification_required`. + */ +internal class PasskeyRegistrationChallengeDeserializer : + JsonDeserializer { + + @Throws(JsonParseException::class) + override fun deserialize( + json: JsonElement, + typeOfT: Type, + context: JsonDeserializationContext + ): PasskeyRegistrationChallenge { + if (!json.isJsonObject) { + throw JsonParseException("passkey registration challenge json is not a valid json object") + } + val jsonObject = json.asJsonObject + val authSession = context.deserialize(jsonObject.get(AUTH_SESSION), String::class.java) + ?: throw JsonParseException("Missing the required property $AUTH_SESSION") + val authParamsPublicKey = context.deserialize( + jsonObject.get(AUTHN_PARAMS_PUBLIC_KEY), + AuthnParamsPublicKey::class.java + ) ?: throw JsonParseException("Missing the required property $AUTHN_PARAMS_PUBLIC_KEY") + val verificationRequired = context.deserialize?>( + jsonObject.get(VERIFICATION_REQUIRED), + VERIFICATION_LIST_TYPE + ) ?: emptyList() + + return PasskeyRegistrationChallenge(authSession, authParamsPublicKey, verificationRequired) + } + + private companion object { + private const val AUTH_SESSION = "auth_session" + private const val AUTHN_PARAMS_PUBLIC_KEY = "authn_params_public_key" + private const val VERIFICATION_REQUIRED = "verification_required" + private val VERIFICATION_LIST_TYPE: Type = + object : TypeToken>() {}.type + } +} 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 6d6f07636..669cff620 100644 --- a/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationChallenge.kt +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationChallenge.kt @@ -12,14 +12,14 @@ public data class PasskeyRegistrationChallenge @JvmOverloads constructor( @SerializedName("authn_params_public_key") 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 + * The identifiers (e.g. `[EMAIL]` or `[EMAIL, PHONE]`) that require OTP verification + * before a credential can be created and tokens issued. Empty 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 + val verificationRequired: List = emptyList() ) 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..d72280d61 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyVerificationMethod.kt @@ -0,0 +1,67 @@ +package com.auth0.android.result + +import com.google.gson.TypeAdapter +import com.google.gson.annotations.JsonAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter + +/** + * 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) + * ``` + * + * Identifier types this version of the SDK does not know are kept as-is, so + * `PasskeyVerificationMethod.fromValue("email") == PasskeyVerificationMethod.EMAIL`. + * + * @property value the value used by the Auth0 Authentication API, e.g. `"email"`. + */ +@JsonAdapter(PasskeyVerificationMethod.Adapter::class) +public class PasskeyVerificationMethod private constructor(public val value: String) { + + override fun equals(other: Any?): Boolean = + this === other || (other is PasskeyVerificationMethod && value == other.value) + + override fun hashCode(): Int = value.hashCode() + + override fun toString(): String = value + + public companion object { + /** The user's email address. */ + @JvmField + public val EMAIL: PasskeyVerificationMethod = PasskeyVerificationMethod("email") + + /** The user's phone number. */ + @JvmField + public val PHONE: PasskeyVerificationMethod = PasskeyVerificationMethod("phone") + + /** + * Creates a [PasskeyVerificationMethod] from the value used by the Auth0 Authentication API. + * Unrecognized values are preserved. + * + * @param value the raw value, e.g. `"email"`. + */ + @JvmStatic + public fun fromValue(value: String): PasskeyVerificationMethod = when (value) { + EMAIL.value -> EMAIL + PHONE.value -> PHONE + else -> PasskeyVerificationMethod(value) + } + } + + internal class Adapter : TypeAdapter() { + override fun write(out: JsonWriter, value: PasskeyVerificationMethod) { + out.value(value.value) + } + + override fun read(reader: JsonReader): PasskeyVerificationMethod = + fromValue(reader.nextString()) + } +} 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 e27a23f96..edfde02fe 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 @@ -303,7 +305,7 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldSignupWithPasskeyWithoutVerificationRequired() { + public fun shouldSignupWithPasskeyWithEmptyVerificationRequiredWhenAbsent() { mockAPI.willReturnSuccessfulPasskeyRegistration() val client = AuthenticationAPIClient(auth0) val registrationResponse = client.signupWithPasskey( @@ -311,7 +313,7 @@ public class AuthenticationAPIClientTest { MY_CONNECTION ).execute() mockAPI.takeRequest() - assertThat(registrationResponse.verificationRequired, Matchers.nullValue()) + assertThat(registrationResponse.verificationRequired, Matchers.empty()) } @Test @@ -323,7 +325,10 @@ public class AuthenticationAPIClientTest { MY_CONNECTION ).execute() mockAPI.takeRequest() - assertThat(registrationResponse.verificationRequired, Matchers.contains("email")) + assertThat( + registrationResponse.verificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL) + ) } @Test @@ -335,7 +340,34 @@ public class AuthenticationAPIClientTest { MY_CONNECTION ).execute() mockAPI.takeRequest() - assertThat(registrationResponse.verificationRequired, Matchers.contains("email", "phone")) + assertThat( + registrationResponse.verificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE) + ) + } + + @Test + public fun shouldKeepUnknownVerificationIdentifiers() { + mockAPI.willReturnSuccessfulPasskeyRegistrationWithVerificationRequired("username") + val client = AuthenticationAPIClient(auth0) + val registrationResponse = client.signupWithPasskey( + UserData(email = "test@example.com"), + MY_CONNECTION + ).execute() + mockAPI.takeRequest() + assertThat(registrationResponse.verificationRequired.map { it.value }, Matchers.contains("username")) + } + + @Test + public fun shouldCompareVerificationMethodsByValue() { + assertThat( + PasskeyVerificationMethod.fromValue("email"), + Matchers.`is`(PasskeyVerificationMethod.EMAIL) + ) + assertThat( + PasskeyVerificationMethod.fromValue("phone"), + Matchers.`is`(PasskeyVerificationMethod.PHONE) + ) } @Test @@ -345,7 +377,7 @@ public class AuthenticationAPIClientTest { client.signupWithPasskey( UserData(phoneNumber = "+1234567890"), MY_CONNECTION, - deliveryMethod = PasskeyDeliveryMethod.VOICE + deliveryMethod = DeliveryMethod.VOICE ).execute() val request = mockAPI.takeRequest() val body = bodyFromRequest(request) @@ -374,7 +406,10 @@ public class AuthenticationAPIClientTest { "auth-session", mock(), MY_CONNECTION, - verification = mapOf("email" to "123456") + verification = mapOf( + PasskeyVerificationMethod.EMAIL to "123456", + PasskeyVerificationMethod.PHONE to "654321" + ) ).start(callback) ShadowLooper.idleMainLooper() val request = mockAPI.takeRequest() @@ -383,6 +418,23 @@ public class AuthenticationAPIClientTest { @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 @@ -402,6 +454,26 @@ public class AuthenticationAPIClientTest { assertThat(body, Matchers.not(Matchers.hasKey("verification"))) } + @Test + public fun shouldFailSigninWithPasskeyWithBlankVerificationCodeBeforeCallingTheApi() { + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf( + PasskeyVerificationMethod.EMAIL to "123456", + PasskeyVerificationMethod.PHONE to " " + ) + ).execute() + } + assertThat(exception.getCode(), Matchers.`is`("a0.auth.verification_code_empty")) + assertThat(exception.getDescription(), Matchers.containsString("phone")) + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + assertThat(mockAPI.server.requestCount, Matchers.`is`(0)) + } + @Test public fun shouldClassifyRetryablePasskeyVerificationError() { mockAPI.willReturnPasskeyVerificationRetryableError("email") @@ -411,12 +483,15 @@ public class AuthenticationAPIClientTest { "auth-session", mock(), MY_CONNECTION, - verification = mapOf("email" to "000000") + 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("email")) + assertThat( + exception.passkeyVerificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL) + ) } @Test @@ -428,7 +503,7 @@ public class AuthenticationAPIClientTest { "auth-session", mock(), MY_CONNECTION, - verification = mapOf("email" to "000000") + verification = mapOf(PasskeyVerificationMethod.EMAIL to "000000") ).execute() } assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) @@ -438,7 +513,7 @@ public class AuthenticationAPIClientTest { @Test public fun shouldClassifyMissingCodePasskeyVerificationErrorAsRetryable() { - mockAPI.willReturnPasskeyVerificationMissingCodeError("email") + mockAPI.willReturnPasskeyVerificationMissingCodeError("Missing required parameter: verification.email") val client = AuthenticationAPIClient(auth0) val exception = assertThrows(AuthenticationException::class.java) { client.signinWithPasskey( @@ -449,7 +524,38 @@ public class AuthenticationAPIClientTest { } assertThat(exception.getCode(), Matchers.`is`("invalid_request")) assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(true)) - assertThat(exception.passkeyVerificationRequired, Matchers.contains("email")) + assertThat(exception.passkeyAuthSession, Matchers.nullValue()) + assertThat(exception.passkeyVerificationRequired, Matchers.nullValue()) + } + + @Test + public fun shouldClassifyEmptyCodePasskeyVerificationErrorAsRetryable() { + mockAPI.willReturnPasskeyVerificationMissingCodeError("\"verification.email\" is not allowed to be empty") + 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)) + } + + @Test + public fun shouldNotClassifyUnrelatedInvalidRequestAsRetryable() { + mockAPI.willReturnPasskeyVerificationMissingCodeError("Invalid realm") + 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)) } @Test @@ -461,7 +567,7 @@ public class AuthenticationAPIClientTest { "auth-session", mock(), MY_CONNECTION, - verification = mapOf("email" to "123456") + verification = mapOf(PasskeyVerificationMethod.EMAIL to "123456") ).execute() } assertThat(exception.statusCode, Matchers.`is`(403)) @@ -469,7 +575,24 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldSurface429PlainTextAsRateLimitErrorOnPasskeyRegister() { + 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 shouldSurface429PlainTextAsTooManyAttemptsOnPasskeyRegister() { mockAPI.willReturnTooManyRequestsPlainText() val client = AuthenticationAPIClient(auth0) val exception = assertThrows(AuthenticationException::class.java) { @@ -478,7 +601,7 @@ public class AuthenticationAPIClientTest { MY_CONNECTION ).execute() } - assertThat(exception.isTooManyRequests, Matchers.`is`(true)) + assertThat(exception.isTooManyAttempts, Matchers.`is`(true)) assertThat(exception.statusCode, Matchers.`is`(429)) } diff --git a/auth0/src/test/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializerTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializerTest.kt new file mode 100644 index 000000000..e85bf689f --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializerTest.kt @@ -0,0 +1,65 @@ +package com.auth0.android.request.internal + +import com.auth0.android.result.PasskeyRegistrationChallenge +import com.auth0.android.result.PasskeyVerificationMethod +import com.google.gson.JsonParseException +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +public class PasskeyRegistrationChallengeDeserializerTest { + + private val publicKey = """{ + "authenticatorSelection": {"residentKey": "required", "userVerification": "preferred"}, + "challenge": "challenge", + "pubKeyCredParams": [{"alg": -7, "type": "public-key"}], + "rp": {"id": "example.com", "name": "example.com"}, + "timeout": 60000, + "user": {"displayName": "name", "id": "id", "name": "name"} + }""" + + private fun parse(json: String): PasskeyRegistrationChallenge = + GsonProvider.gson.fromJson(json, PasskeyRegistrationChallenge::class.java) + + @Test + public fun shouldDefaultVerificationRequiredToEmptyList() { + val challenge = parse("""{"auth_session": "session", "authn_params_public_key": $publicKey}""") + assertThat(challenge.authSession, Matchers.`is`("session")) + assertThat(challenge.verificationRequired, Matchers.empty()) + } + + @Test + public fun shouldParseVerificationRequired() { + val challenge = parse( + """{"auth_session": "session", "authn_params_public_key": $publicKey, + "verification_required": ["email", "phone"]}""" + ) + assertThat( + challenge.verificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE) + ) + } + + @Test + public fun shouldFailWhenAuthSessionIsMissing() { + assertThrows(JsonParseException::class.java) { + parse("""{"authn_params_public_key": $publicKey}""") + } + } + + @Test + public fun shouldFailWhenPublicKeyParamsAreMissing() { + assertThrows(JsonParseException::class.java) { + parse("""{"auth_session": "session"}""") + } + } + + @Test + public fun shouldFailWhenJsonIsNotAnObject() { + assertThrows(JsonParseException::class.java) { parse("[]") } + } +} 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 27f345575..3f46dae62 100755 --- a/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt @@ -1,5 +1,6 @@ package com.auth0.android.util +import com.google.gson.Gson import okhttp3.mockwebserver.MockResponse import java.nio.file.Files import java.nio.file.Paths @@ -260,19 +261,26 @@ internal class AuthenticationAPIMockServer : APIMockServer() { } fun willReturnPasskeyVerificationMissingCodeError( - vararg identifiers: String + description: String = "Missing required parameter: verification.email" ): AuthenticationAPIMockServer { - val verificationRequired = identifiers.joinToString(prefix = "[", postfix = "]") { "\"$it\"" } val json = """{ "error": "invalid_request", - "error_description": "A required verification code is missing", - "auth_session": "$SESSION_ID", - "verification_required": $verificationRequired + "error_description": ${Gson().toJson(description)} }""" 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", diff --git a/examples/passkeys.md b/examples/passkeys.md index 3adcb5fed..05e0f3702 100644 --- a/examples/passkeys.md +++ b/examples/passkeys.md @@ -101,23 +101,30 @@ try { ### Passkey signup with identifier verification When the database connection has OTP identifier verification enabled, the `signupWithPasskey` -response includes a non-null `verificationRequired` list naming the identifiers (e.g. `email`, -`phone`) that must be verified before tokens can be issued. You must: +response includes a non-empty `verificationRequired` list naming the identifiers +(`PasskeyVerificationMethod.EMAIL`, `PasskeyVerificationMethod.PHONE`) that must be verified before tokens +can be issued. 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). +2. Pass the collected codes to `signinWithPasskey` via the `verification` map (identifier -> code). A blank + code fails the request with an `AuthenticationException` before any network call is made. 3. On a retryable error (`AuthenticationException.isPasskeyVerificationRetryable == true`), re-collect - only the failed identifiers and call `signinWithPasskey` again **reusing the same `PublicKeyCredentials` - and auth session** — the biometric prompt will not reappear. + the codes and call `signinWithPasskey` again **reusing the same `PublicKeyCredentials`** — the + biometric prompt will not reappear. -An error is retryable **if and only if** the error carries an `auth_session` -(`isPasskeyVerificationRetryable`). Never branch on the HTTP status code or error description — they are -identical on the retryable and terminal paths by design. +An error is retryable when it is either: -Optionally pass `deliveryMethod` (`PasskeyDeliveryMethod.TEXT` or `PasskeyDeliveryMethod.VOICE`) to +- an `invalid_grant` error that carries an `auth_session` (a code was wrong and attempts remain). The identifiers to + re-collect are in `passkeyVerificationRequired` and the session in `passkeyAuthSession`; or +- an `invalid_request` error because a code was missing or empty. This error has no `auth_session` and no identifier + list, so reuse the auth session and `verificationRequired` of the original challenge. + +Any other error is terminal and the registration must be restarted from `signupWithPasskey`. + +Optionally pass `deliveryMethod` (`DeliveryMethod.TEXT` or `DeliveryMethod.VOICE`) to `signupWithPasskey` to choose the phone OTP channel. ```kotlin @@ -129,13 +136,12 @@ try { userData, "{realm}", "{organization-id}", - deliveryMethod = PasskeyDeliveryMethod.TEXT // optional, phone OTP channel + deliveryMethod = DeliveryMethod.TEXT // optional, phone OTP channel ).await() // Collect OTP codes BEFORE creating the credential. - val verification: Map = challenge.verificationRequired - ?.associateWith { identifier -> promptUserForOtp(identifier) } // app-defined - ?: emptyMap() + var codes: Map = challenge.verificationRequired + .associateWith { identifier -> promptUserForOtp(identifier) } // app-defined // Now create the platform credential. val request = CreatePublicKeyCredentialRequest( @@ -147,11 +153,12 @@ try { PublicKeyCredentials::class.java ) - var codes = verification + var authSession = challenge.authSession + var attempts = 0 while (true) { try { val userCredential = authenticationApiClient.signinWithPasskey( - challenge.authSession, + authSession, authRequest, "{realm}", "{organization-id}", @@ -159,13 +166,14 @@ try { ).validateClaims().await() break // success } catch (exception: AuthenticationException) { - if (exception.isPasskeyVerificationRetryable) { - // Re-collect only the failed identifiers and retry with the SAME credential. - val failed = exception.passkeyVerificationRequired ?: emptyList() - codes = failed.associateWith { identifier -> promptUserForOtp(identifier) } - } else { - throw exception // terminal — restart registration + if (!exception.isPasskeyVerificationRetryable || ++attempts >= MAX_ATTEMPTS) { + throw exception // terminal (or too many attempts) - restart registration } + // Re-collect the failed identifiers and retry with the SAME credential. When the error does not + // list them (missing/empty code), fall back to the identifiers of the original challenge. + authSession = exception.passkeyAuthSession ?: authSession + val failed = exception.passkeyVerificationRequired ?: challenge.verificationRequired + codes = codes + failed.associateWith { identifier -> promptUserForOtp(identifier) } } } } catch (e: CreateCredentialException) { @@ -178,12 +186,12 @@ try { ```java UserData userData = new UserData("user@example.com", null, null, null, null, null, null, null, null); -authenticationAPIClient.signupWithPasskey(userData, "{realm}", "{organization-id}", PasskeyDeliveryMethod.TEXT) +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 + Map verification = collectOtpCodes(result.getVerificationRequired()); // app-defined CreateCredentialRequest request = new CreatePublicKeyCredentialRequest(new Gson().toJson(result.getAuthParamsPublicKey())); @@ -204,8 +212,10 @@ authenticationAPIClient.signupWithPasskey(userData, "{realm}", "{organization-id @Override public void onFailure(@NonNull AuthenticationException error) { if (error.isPasskeyVerificationRetryable()) { - // Re-collect error.getPasskeyVerificationRequired() and retry - // signinWithPasskey with the SAME credentials. + // 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. } From d62794cf12891b985d1126cedacda5d1cd6abc34 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 14 Oct 2026 13:54:43 +0530 Subject: [PATCH 3/6] docs(passkey): reword verification intro, declare retry limit Co-Authored-By: Claude --- examples/passkeys.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/passkeys.md b/examples/passkeys.md index 05e0f3702..fd226ddbe 100644 --- a/examples/passkeys.md +++ b/examples/passkeys.md @@ -100,7 +100,7 @@ try { ### Passkey signup with identifier verification -When the database connection has OTP identifier verification enabled, the `signupWithPasskey` +When the database connection has verification enabled for email and phone, the `signupWithPasskey` response includes a non-empty `verificationRequired` list naming the identifiers (`PasskeyVerificationMethod.EMAIL`, `PasskeyVerificationMethod.PHONE`) that must be verified before tokens can be issued. You must: @@ -154,6 +154,7 @@ try { ) var authSession = challenge.authSession + val maxAttempts = 3 // retry limit; adjust it to suit your app var attempts = 0 while (true) { try { @@ -166,7 +167,7 @@ try { ).validateClaims().await() break // success } catch (exception: AuthenticationException) { - if (!exception.isPasskeyVerificationRetryable || ++attempts >= MAX_ATTEMPTS) { + 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. When the error does not From 68470198301a7ce2817aabf9b35a1d2a2a16b084 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 14 Oct 2026 15:20:08 +0530 Subject: [PATCH 4/6] fix(passkey): simplify verification types and retry rule - Retryable when invalid_grant or invalid_request carries an auth_session - PasskeyVerificationMethod is a plain enum; verificationRequired is nullable - Map plain-text 429 to too_many_attempts; keep isTooManyAttempts code-only - Validate blank codes before serializing; reuse the client gson - Update passkey docs and tests Co-Authored-By: Claude --- .../authentication/AuthenticationAPIClient.kt | 23 +++---- .../authentication/AuthenticationException.kt | 61 +++++++---------- .../android/request/internal/GsonProvider.kt | 5 -- ...asskeyRegistrationChallengeDeserializer.kt | 52 --------------- .../result/PasskeyRegistrationChallenge.kt | 4 +- .../result/PasskeyVerificationMethod.kt | 57 +++------------- .../AuthenticationAPIClientTest.kt | 61 +++++++---------- .../AuthenticationExceptionTest.kt | 62 ++++++++++++++++++ ...eyRegistrationChallengeDeserializerTest.kt | 65 ------------------- .../util/AuthenticationAPIMockServer.kt | 19 ++++-- examples/passkeys.md | 26 ++++---- 11 files changed, 157 insertions(+), 278 deletions(-) delete mode 100644 auth0/src/main/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializer.kt delete mode 100644 auth0/src/test/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializerTest.kt 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 ae1f8b6f6..0d30a0584 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -266,21 +266,22 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe Gson().toJsonTree(authResponse) ) if (verification.isNotEmpty()) { - request.addParameter( - VERIFICATION_KEY, - Gson().toJsonTree(verification.mapKeys { it.key.value }) - ) - request.addValidator(object : RequestValidator { - override fun validate(options: RequestOptions) { - val blank = verification.filterValues { it.isBlank() }.keys - if (blank.isNotEmpty()) { + val blankCodes = verification.filterValues { it.isBlank() }.keys + if (blankCodes.isNotEmpty()) { + request.addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { throw AuthenticationException( AuthenticationException.ERROR_KEY_VERIFICATION_CODE_EMPTY, - "The verification code for ${blank.joinToString { it.value }} must not be empty." + "The verification code for ${blankCodes.joinToString { it.value }} must not be empty." ) } - } - }) + }) + } else { + request.addParameter( + VERIFICATION_KEY, + gson.toJsonTree(verification.mapKeys { it.key.value }) + ) + } } return request as AuthenticationRequest } 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 5ca4ad05f..a55a23448 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt @@ -39,7 +39,11 @@ public class AuthenticationException : Auth0Exception { } public constructor(payload: String?, statusCode: Int) : this(DEFAULT_MESSAGE) { - code = if (payload != null) NON_JSON_ERROR else EMPTY_BODY_ERROR + code = when { + statusCode == 429 -> TOO_MANY_ATTEMPTS_CODE + payload != null -> NON_JSON_ERROR + else -> EMPTY_BODY_ERROR + } description = payload ?: EMPTY_RESPONSE_BODY_DESCRIPTION this.statusCode = statusCode } @@ -203,26 +207,23 @@ public class AuthenticationException : Auth0Exception { /** * Whether a failed passkey signup identifier verification can be retried without restarting - * the registration. `true` when: - * - the error is `invalid_grant` and contains an `auth_session` (a code was wrong and the - * session still has attempts left), or - * - the error is `invalid_request` because a verification code was missing or empty. + * the registration. `true` when the error is an `invalid_grant` or `invalid_request` and + * contains an `auth_session`, meaning the session is still alive (for example a code was wrong + * or missing and attempts remain). * - * When retryable, re-collect the OTP codes and call + * When retryable, re-collect the OTP codes for [passkeyVerificationRequired] and call * [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey] again with the - * same credential. Use [passkeyAuthSession] when it is present; otherwise reuse the auth session - * of the original challenge. When `false`, restart the registration. + * same credential and [passkeyAuthSession]. When `false`, restart the registration. * * @see passkeyAuthSession * @see passkeyVerificationRequired */ public val isPasskeyVerificationRetryable: Boolean - get() = isPasskeyVerificationCodeInvalid || isPasskeyVerificationCodeMissing + get() = ("invalid_grant" == code || "invalid_request" == code) && passkeyAuthSession != null /** - * The auth session returned with a failed passkey verification, if any. `null` does not mean - * the session is lost: a missing or empty code is reported without a session, and the auth - * session of the original challenge can still be used. + * 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 */ @@ -230,31 +231,18 @@ public class AuthenticationException : Auth0Exception { get() = getValue(AUTH_SESSION_KEY) as? String /** - * The identifiers whose OTP codes must be re-collected before retrying. Only set when the - * error is an `invalid_grant` with an `auth_session`; otherwise `null` and the identifiers of - * the original challenge apply. + * The identifiers whose OTP codes must be re-collected before retrying. `null` unless + * [isPasskeyVerificationRetryable] is `true`. Identifiers this version of the SDK does not + * know are skipped. * * @see isPasskeyVerificationRetryable */ - @Suppress("UNCHECKED_CAST") public val passkeyVerificationRequired: List? get() { - if (!isPasskeyVerificationCodeInvalid) return null + if (!isPasskeyVerificationRetryable) return null return (getValue(VERIFICATION_REQUIRED_KEY) as? List<*>) ?.filterIsInstance() - ?.map { PasskeyVerificationMethod.fromValue(it) } - } - - private val isPasskeyVerificationCodeInvalid: Boolean - get() = "invalid_grant" == code && passkeyAuthSession != null - - private val isPasskeyVerificationCodeMissing: Boolean - get() { - if ("invalid_request" != code) return false - val description = description ?: return false - return description.startsWith(MISSING_VERIFICATION_PREFIX) - || (description.startsWith(EMPTY_VERIFICATION_PREFIX) - && description.endsWith(EMPTY_VERIFICATION_SUFFIX)) + ?.mapNotNull { value -> PasskeyVerificationMethod.values().firstOrNull { it.value == value } } } /// When the MFA Token used on the login request is malformed or has expired @@ -312,14 +300,11 @@ public class AuthenticationException : Auth0Exception { get() = cause is TokenValidationException /** - * When the user is blocked due to too many attempts to log in, or the request was rate-limited - * by the server (HTTP 429). - * - * Some endpoints, like `/passkey/register`, return a plain-text body (not JSON) on 429, so the - * error code may not be populated — the status code is checked as well. + * When the user is blocked due to too many attempts to log in, or the server rate-limited the + * request (HTTP 429) with a plain-text body. */ public val isTooManyAttempts: Boolean - get() = "too_many_attempts" == code || statusCode == 429 + get() = TOO_MANY_ATTEMPTS_CODE == code internal companion object { internal const val ERROR_VALUE_AUTHENTICATION_CANCELED = "a0.authentication_canceled" @@ -333,9 +318,7 @@ public class AuthenticationException : Auth0Exception { 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 MISSING_VERIFICATION_PREFIX = "Missing required parameter: verification" - private const val EMPTY_VERIFICATION_PREFIX = "\"verification." - private const val EMPTY_VERIFICATION_SUFFIX = "is not allowed to be empty" + private const val TOO_MANY_ATTEMPTS_CODE = "too_many_attempts" 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/request/internal/GsonProvider.kt b/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.kt index bc446b120..a927a89dd 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/GsonProvider.kt @@ -2,7 +2,6 @@ package com.auth0.android.request.internal import androidx.annotation.VisibleForTesting import com.auth0.android.result.Credentials -import com.auth0.android.result.PasskeyRegistrationChallenge import com.auth0.android.result.SSOCredentials import com.auth0.android.result.UserProfile import com.google.gson.Gson @@ -28,10 +27,6 @@ internal object GsonProvider { .registerTypeAdapter(UserProfile::class.java, UserProfileDeserializer()) .registerTypeAdapter(Credentials::class.java, CredentialsDeserializer()) .registerTypeAdapter(SSOCredentials::class.java, SSOCredentialsDeserializer()) - .registerTypeAdapter( - PasskeyRegistrationChallenge::class.java, - PasskeyRegistrationChallengeDeserializer() - ) .registerTypeAdapter(jwksType, JwksDeserializer()) .setDateFormat(DATE_FORMAT) .create() diff --git a/auth0/src/main/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializer.kt b/auth0/src/main/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializer.kt deleted file mode 100644 index 8a76c2824..000000000 --- a/auth0/src/main/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializer.kt +++ /dev/null @@ -1,52 +0,0 @@ -package com.auth0.android.request.internal - -import com.auth0.android.result.AuthnParamsPublicKey -import com.auth0.android.result.PasskeyRegistrationChallenge -import com.auth0.android.result.PasskeyVerificationMethod -import com.google.gson.JsonDeserializationContext -import com.google.gson.JsonDeserializer -import com.google.gson.JsonElement -import com.google.gson.JsonParseException -import com.google.gson.reflect.TypeToken -import java.lang.reflect.Type - -/** - * Gson skips Kotlin default values for fields missing from the JSON, which would leave - * `verificationRequired` as `null`. This builds the challenge through its constructor instead, so - * the field is an empty list when the server omits `verification_required`. - */ -internal class PasskeyRegistrationChallengeDeserializer : - JsonDeserializer { - - @Throws(JsonParseException::class) - override fun deserialize( - json: JsonElement, - typeOfT: Type, - context: JsonDeserializationContext - ): PasskeyRegistrationChallenge { - if (!json.isJsonObject) { - throw JsonParseException("passkey registration challenge json is not a valid json object") - } - val jsonObject = json.asJsonObject - val authSession = context.deserialize(jsonObject.get(AUTH_SESSION), String::class.java) - ?: throw JsonParseException("Missing the required property $AUTH_SESSION") - val authParamsPublicKey = context.deserialize( - jsonObject.get(AUTHN_PARAMS_PUBLIC_KEY), - AuthnParamsPublicKey::class.java - ) ?: throw JsonParseException("Missing the required property $AUTHN_PARAMS_PUBLIC_KEY") - val verificationRequired = context.deserialize?>( - jsonObject.get(VERIFICATION_REQUIRED), - VERIFICATION_LIST_TYPE - ) ?: emptyList() - - return PasskeyRegistrationChallenge(authSession, authParamsPublicKey, verificationRequired) - } - - private companion object { - private const val AUTH_SESSION = "auth_session" - private const val AUTHN_PARAMS_PUBLIC_KEY = "authn_params_public_key" - private const val VERIFICATION_REQUIRED = "verification_required" - private val VERIFICATION_LIST_TYPE: Type = - object : TypeToken>() {}.type - } -} 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 669cff620..ef84a1e20 100644 --- a/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationChallenge.kt +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationChallenge.kt @@ -13,13 +13,13 @@ public data class PasskeyRegistrationChallenge @JvmOverloads constructor( val authParamsPublicKey: AuthnParamsPublicKey, /** * The identifiers (e.g. `[EMAIL]` or `[EMAIL, PHONE]`) that require OTP verification - * before a credential can be created and tokens issued. Empty when the server did not + * 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 = emptyList() + 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 index d72280d61..ebe73d4eb 100644 --- a/auth0/src/main/java/com/auth0/android/result/PasskeyVerificationMethod.kt +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyVerificationMethod.kt @@ -1,9 +1,6 @@ package com.auth0.android.result -import com.google.gson.TypeAdapter -import com.google.gson.annotations.JsonAdapter -import com.google.gson.stream.JsonReader -import com.google.gson.stream.JsonWriter +import com.google.gson.annotations.SerializedName /** * An identifier whose ownership must be verified with a one-time code before a passkey signup @@ -18,50 +15,14 @@ import com.google.gson.stream.JsonWriter * val verification = mapOf(PasskeyVerificationMethod.EMAIL to emailCode) * ``` * - * Identifier types this version of the SDK does not know are kept as-is, so - * `PasskeyVerificationMethod.fromValue("email") == PasskeyVerificationMethod.EMAIL`. - * * @property value the value used by the Auth0 Authentication API, e.g. `"email"`. */ -@JsonAdapter(PasskeyVerificationMethod.Adapter::class) -public class PasskeyVerificationMethod private constructor(public val value: String) { - - override fun equals(other: Any?): Boolean = - this === other || (other is PasskeyVerificationMethod && value == other.value) - - override fun hashCode(): Int = value.hashCode() - - override fun toString(): String = value - - public companion object { - /** The user's email address. */ - @JvmField - public val EMAIL: PasskeyVerificationMethod = PasskeyVerificationMethod("email") - - /** The user's phone number. */ - @JvmField - public val PHONE: PasskeyVerificationMethod = PasskeyVerificationMethod("phone") - - /** - * Creates a [PasskeyVerificationMethod] from the value used by the Auth0 Authentication API. - * Unrecognized values are preserved. - * - * @param value the raw value, e.g. `"email"`. - */ - @JvmStatic - public fun fromValue(value: String): PasskeyVerificationMethod = when (value) { - EMAIL.value -> EMAIL - PHONE.value -> PHONE - else -> PasskeyVerificationMethod(value) - } - } - - internal class Adapter : TypeAdapter() { - override fun write(out: JsonWriter, value: PasskeyVerificationMethod) { - out.value(value.value) - } - - override fun read(reader: JsonReader): PasskeyVerificationMethod = - fromValue(reader.nextString()) - } +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 edfde02fe..1ec807617 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -305,7 +305,7 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldSignupWithPasskeyWithEmptyVerificationRequiredWhenAbsent() { + public fun shouldSignupWithPasskeyWithNullVerificationRequiredWhenAbsent() { mockAPI.willReturnSuccessfulPasskeyRegistration() val client = AuthenticationAPIClient(auth0) val registrationResponse = client.signupWithPasskey( @@ -313,7 +313,7 @@ public class AuthenticationAPIClientTest { MY_CONNECTION ).execute() mockAPI.takeRequest() - assertThat(registrationResponse.verificationRequired, Matchers.empty()) + assertThat(registrationResponse.verificationRequired, Matchers.nullValue()) } @Test @@ -346,30 +346,6 @@ public class AuthenticationAPIClientTest { ) } - @Test - public fun shouldKeepUnknownVerificationIdentifiers() { - mockAPI.willReturnSuccessfulPasskeyRegistrationWithVerificationRequired("username") - val client = AuthenticationAPIClient(auth0) - val registrationResponse = client.signupWithPasskey( - UserData(email = "test@example.com"), - MY_CONNECTION - ).execute() - mockAPI.takeRequest() - assertThat(registrationResponse.verificationRequired.map { it.value }, Matchers.contains("username")) - } - - @Test - public fun shouldCompareVerificationMethodsByValue() { - assertThat( - PasskeyVerificationMethod.fromValue("email"), - Matchers.`is`(PasskeyVerificationMethod.EMAIL) - ) - assertThat( - PasskeyVerificationMethod.fromValue("phone"), - Matchers.`is`(PasskeyVerificationMethod.PHONE) - ) - } - @Test public fun shouldSignupWithPasskeyForwardingDeliveryMethod() { mockAPI.willReturnSuccessfulPasskeyRegistration() @@ -512,8 +488,8 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldClassifyMissingCodePasskeyVerificationErrorAsRetryable() { - mockAPI.willReturnPasskeyVerificationMissingCodeError("Missing required parameter: verification.email") + public fun shouldClassifyInvalidRequestWithSessionAsRetryable() { + mockAPI.willReturnPasskeyVerificationInvalidRequestError("email") val client = AuthenticationAPIClient(auth0) val exception = assertThrows(AuthenticationException::class.java) { client.signinWithPasskey( @@ -524,13 +500,16 @@ public class AuthenticationAPIClientTest { } assertThat(exception.getCode(), Matchers.`is`("invalid_request")) assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(true)) - assertThat(exception.passkeyAuthSession, Matchers.nullValue()) - assertThat(exception.passkeyVerificationRequired, Matchers.nullValue()) + assertThat(exception.passkeyAuthSession, Matchers.`is`(SESSION_ID)) + assertThat( + exception.passkeyVerificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL) + ) } @Test - public fun shouldClassifyEmptyCodePasskeyVerificationErrorAsRetryable() { - mockAPI.willReturnPasskeyVerificationMissingCodeError("\"verification.email\" is not allowed to be empty") + public fun shouldNotClassifyInvalidRequestWithoutSessionAsRetryable() { + mockAPI.willReturnInvalidRequestWithoutSessionError() val client = AuthenticationAPIClient(auth0) val exception = assertThrows(AuthenticationException::class.java) { client.signinWithPasskey( @@ -540,22 +519,27 @@ public class AuthenticationAPIClientTest { ).execute() } assertThat(exception.getCode(), Matchers.`is`("invalid_request")) - assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(true)) + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + assertThat(exception.passkeyAuthSession, Matchers.nullValue()) + assertThat(exception.passkeyVerificationRequired, Matchers.nullValue()) } @Test - public fun shouldNotClassifyUnrelatedInvalidRequestAsRetryable() { - mockAPI.willReturnPasskeyVerificationMissingCodeError("Invalid realm") + public fun shouldSkipUnknownIdentifiersInPasskeyVerificationRequired() { + mockAPI.willReturnPasskeyVerificationRetryableError("email", "username") val client = AuthenticationAPIClient(auth0) val exception = assertThrows(AuthenticationException::class.java) { client.signinWithPasskey( "auth-session", mock(), - MY_CONNECTION + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "000000") ).execute() } - assertThat(exception.getCode(), Matchers.`is`("invalid_request")) - assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + assertThat( + exception.passkeyVerificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL) + ) } @Test @@ -602,6 +586,7 @@ public class AuthenticationAPIClientTest { ).execute() } assertThat(exception.isTooManyAttempts, Matchers.`is`(true)) + assertThat(exception.getCode(), Matchers.`is`("too_many_attempts")) assertThat(exception.statusCode, Matchers.`is`(429)) } 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..d9c541947 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,67 @@ public class AuthenticationExceptionTest { MatcherAssert.assertThat(ex.isTooManyAttempts, CoreMatchers.`is`(true)) } + @Test + public fun shouldHaveTooManyAttemptsForPlainTextRateLimit() { + val ex = AuthenticationException("Too Many Requests", 429) + MatcherAssert.assertThat(ex.isTooManyAttempts, CoreMatchers.`is`(true)) + MatcherAssert.assertThat(ex.getCode(), CoreMatchers.`is`("too_many_attempts")) + MatcherAssert.assertThat(ex.getDescription(), CoreMatchers.`is`("Too Many Requests")) + MatcherAssert.assertThat(ex.statusCode, CoreMatchers.`is`(429)) + } + + @Test + public fun shouldHaveTooManyAttemptsForEmptyRateLimitBody() { + val ex = AuthenticationException(null, 429) + MatcherAssert.assertThat(ex.isTooManyAttempts, CoreMatchers.`is`(true)) + } + + @Test + public fun shouldKeepPlainTextCodeForOtherStatuses() { + val ex = AuthenticationException("Bad Gateway", 502) + MatcherAssert.assertThat(ex.isTooManyAttempts, CoreMatchers.`is`(false)) + MatcherAssert.assertThat(ex.getCode(), CoreMatchers.`is`(Auth0Exception.NON_JSON_ERROR)) + } + + @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 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/request/internal/PasskeyRegistrationChallengeDeserializerTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializerTest.kt deleted file mode 100644 index e85bf689f..000000000 --- a/auth0/src/test/java/com/auth0/android/request/internal/PasskeyRegistrationChallengeDeserializerTest.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.auth0.android.request.internal - -import com.auth0.android.result.PasskeyRegistrationChallenge -import com.auth0.android.result.PasskeyVerificationMethod -import com.google.gson.JsonParseException -import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers -import org.junit.Assert.assertThrows -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -@RunWith(RobolectricTestRunner::class) -public class PasskeyRegistrationChallengeDeserializerTest { - - private val publicKey = """{ - "authenticatorSelection": {"residentKey": "required", "userVerification": "preferred"}, - "challenge": "challenge", - "pubKeyCredParams": [{"alg": -7, "type": "public-key"}], - "rp": {"id": "example.com", "name": "example.com"}, - "timeout": 60000, - "user": {"displayName": "name", "id": "id", "name": "name"} - }""" - - private fun parse(json: String): PasskeyRegistrationChallenge = - GsonProvider.gson.fromJson(json, PasskeyRegistrationChallenge::class.java) - - @Test - public fun shouldDefaultVerificationRequiredToEmptyList() { - val challenge = parse("""{"auth_session": "session", "authn_params_public_key": $publicKey}""") - assertThat(challenge.authSession, Matchers.`is`("session")) - assertThat(challenge.verificationRequired, Matchers.empty()) - } - - @Test - public fun shouldParseVerificationRequired() { - val challenge = parse( - """{"auth_session": "session", "authn_params_public_key": $publicKey, - "verification_required": ["email", "phone"]}""" - ) - assertThat( - challenge.verificationRequired, - Matchers.contains(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE) - ) - } - - @Test - public fun shouldFailWhenAuthSessionIsMissing() { - assertThrows(JsonParseException::class.java) { - parse("""{"authn_params_public_key": $publicKey}""") - } - } - - @Test - public fun shouldFailWhenPublicKeyParamsAreMissing() { - assertThrows(JsonParseException::class.java) { - parse("""{"auth_session": "session"}""") - } - } - - @Test - public fun shouldFailWhenJsonIsNotAnObject() { - assertThrows(JsonParseException::class.java) { parse("[]") } - } -} 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 3f46dae62..2c7915417 100755 --- a/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt @@ -1,6 +1,5 @@ package com.auth0.android.util -import com.google.gson.Gson import okhttp3.mockwebserver.MockResponse import java.nio.file.Files import java.nio.file.Paths @@ -260,12 +259,24 @@ internal class AuthenticationAPIMockServer : APIMockServer() { return this } - fun willReturnPasskeyVerificationMissingCodeError( - description: String = "Missing required parameter: verification.email" + fun willReturnPasskeyVerificationInvalidRequestError( + vararg identifiers: String ): AuthenticationAPIMockServer { + val verificationRequired = identifiers.joinToString(prefix = "[", postfix = "]") { "\"$it\"" } + val json = """{ + "error": "invalid_request", + "error_description": "Missing required parameter: verification.email", + "auth_session": "$SESSION_ID", + "verification_required": $verificationRequired + }""" + server.enqueue(responseWithJSON(json, 400)) + return this + } + + fun willReturnInvalidRequestWithoutSessionError(): AuthenticationAPIMockServer { val json = """{ "error": "invalid_request", - "error_description": ${Gson().toJson(description)} + "error_description": "Invalid realm" }""" server.enqueue(responseWithJSON(json, 400)) return this diff --git a/examples/passkeys.md b/examples/passkeys.md index fd226ddbe..2723c847f 100644 --- a/examples/passkeys.md +++ b/examples/passkeys.md @@ -101,9 +101,9 @@ try { ### Passkey signup with identifier verification When the database connection has verification enabled for email and phone, the `signupWithPasskey` -response includes a non-empty `verificationRequired` list naming the identifiers +response includes a `verificationRequired` list naming the identifiers (`PasskeyVerificationMethod.EMAIL`, `PasskeyVerificationMethod.PHONE`) that must be verified before tokens -can be issued. You must: +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 @@ -115,14 +115,10 @@ can be issued. You must: the codes and call `signinWithPasskey` again **reusing the same `PublicKeyCredentials`** — the biometric prompt will not reappear. -An error is retryable when it is either: - -- an `invalid_grant` error that carries an `auth_session` (a code was wrong and attempts remain). The identifiers to - re-collect are in `passkeyVerificationRequired` and the session in `passkeyAuthSession`; or -- an `invalid_request` error because a code was missing or empty. This error has no `auth_session` and no identifier - list, so reuse the auth session and `verificationRequired` of the original challenge. - -Any other error is terminal and the registration must be restarted from `signupWithPasskey`. +An error is retryable (`isPasskeyVerificationRetryable`) when it is an `invalid_grant` or `invalid_request` error +that carries an `auth_session`, meaning the session is still alive (for example a code was wrong or missing and +attempts remain). The identifiers to re-collect are in `passkeyVerificationRequired` and the session in +`passkeyAuthSession`. Any other error is terminal and the registration must be restarted from `signupWithPasskey`. Optionally pass `deliveryMethod` (`DeliveryMethod.TEXT` or `DeliveryMethod.VOICE`) to `signupWithPasskey` to choose the phone OTP channel. @@ -140,7 +136,7 @@ try { ).await() // Collect OTP codes BEFORE creating the credential. - var codes: Map = challenge.verificationRequired + var codes: Map = challenge.verificationRequired.orEmpty() .associateWith { identifier -> promptUserForOtp(identifier) } // app-defined // Now create the platform credential. @@ -170,15 +166,17 @@ try { 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. When the error does not - // list them (missing/empty code), fall back to the identifiers of the original challenge. + // 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 + 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. } ```
From 73836f4010cf9f79a067ff1652ed85d4c92bbcfc Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 15 Oct 2026 11:49:17 +0530 Subject: [PATCH 5/6] fix(passkey): revert 429 mapping to too_many_attempts Not every 429 means too_many_attempts. Keep isTooManyAttempts and the plain-text error code unchanged from main, and document checking statusCode == 429 for the signupWithPasskey rate limit. Co-Authored-By: Claude --- .../authentication/AuthenticationException.kt | 14 +++--------- .../AuthenticationAPIClientTest.kt | 6 ++--- .../AuthenticationExceptionTest.kt | 22 ------------------- examples/passkeys.md | 3 +++ 4 files changed, 9 insertions(+), 36 deletions(-) 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 a55a23448..683ed40de 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt @@ -39,11 +39,7 @@ public class AuthenticationException : Auth0Exception { } public constructor(payload: String?, statusCode: Int) : this(DEFAULT_MESSAGE) { - code = when { - statusCode == 429 -> TOO_MANY_ATTEMPTS_CODE - payload != null -> NON_JSON_ERROR - else -> EMPTY_BODY_ERROR - } + code = if (payload != null) NON_JSON_ERROR else EMPTY_BODY_ERROR description = payload ?: EMPTY_RESPONSE_BODY_DESCRIPTION this.statusCode = statusCode } @@ -299,12 +295,9 @@ public class AuthenticationException : Auth0Exception { public val isIdTokenValidationError: Boolean get() = cause is TokenValidationException - /** - * When the user is blocked due to too many attempts to log in, or the server rate-limited the - * request (HTTP 429) with a plain-text body. - */ + /// When the user is blocked due to too many attempts to log in public val isTooManyAttempts: Boolean - get() = TOO_MANY_ATTEMPTS_CODE == code + get() = "too_many_attempts" == code internal companion object { internal const val ERROR_VALUE_AUTHENTICATION_CANCELED = "a0.authentication_canceled" @@ -318,7 +311,6 @@ public class AuthenticationException : Auth0Exception { 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 TOO_MANY_ATTEMPTS_CODE = "too_many_attempts" 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/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt index 1ec807617..a280ed339 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -576,7 +576,7 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldSurface429PlainTextAsTooManyAttemptsOnPasskeyRegister() { + public fun shouldSurface429PlainTextRateLimitOnPasskeyRegister() { mockAPI.willReturnTooManyRequestsPlainText() val client = AuthenticationAPIClient(auth0) val exception = assertThrows(AuthenticationException::class.java) { @@ -585,9 +585,9 @@ public class AuthenticationAPIClientTest { MY_CONNECTION ).execute() } - assertThat(exception.isTooManyAttempts, Matchers.`is`(true)) - assertThat(exception.getCode(), Matchers.`is`("too_many_attempts")) assertThat(exception.statusCode, Matchers.`is`(429)) + assertThat(exception.getDescription(), Matchers.`is`("Too Many Requests")) + assertThat(exception.isTooManyAttempts, Matchers.`is`(false)) } @Test 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 d9c541947..fdc9241f3 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt @@ -533,28 +533,6 @@ public class AuthenticationExceptionTest { MatcherAssert.assertThat(ex.isTooManyAttempts, CoreMatchers.`is`(true)) } - @Test - public fun shouldHaveTooManyAttemptsForPlainTextRateLimit() { - val ex = AuthenticationException("Too Many Requests", 429) - MatcherAssert.assertThat(ex.isTooManyAttempts, CoreMatchers.`is`(true)) - MatcherAssert.assertThat(ex.getCode(), CoreMatchers.`is`("too_many_attempts")) - MatcherAssert.assertThat(ex.getDescription(), CoreMatchers.`is`("Too Many Requests")) - MatcherAssert.assertThat(ex.statusCode, CoreMatchers.`is`(429)) - } - - @Test - public fun shouldHaveTooManyAttemptsForEmptyRateLimitBody() { - val ex = AuthenticationException(null, 429) - MatcherAssert.assertThat(ex.isTooManyAttempts, CoreMatchers.`is`(true)) - } - - @Test - public fun shouldKeepPlainTextCodeForOtherStatuses() { - val ex = AuthenticationException("Bad Gateway", 502) - MatcherAssert.assertThat(ex.isTooManyAttempts, CoreMatchers.`is`(false)) - MatcherAssert.assertThat(ex.getCode(), CoreMatchers.`is`(Auth0Exception.NON_JSON_ERROR)) - } - @Test public fun shouldBeRetryablePasskeyVerificationForInvalidGrantWithSession() { values[ERROR_KEY] = "invalid_grant" diff --git a/examples/passkeys.md b/examples/passkeys.md index 2723c847f..7f641accf 100644 --- a/examples/passkeys.md +++ b/examples/passkeys.md @@ -123,6 +123,9 @@ attempts remain). The identifiers to re-collect are in `passkeyVerificationRequi Optionally pass `deliveryMethod` (`DeliveryMethod.TEXT` or `DeliveryMethod.VOICE`) to `signupWithPasskey` to choose the phone OTP channel. +`signupWithPasskey` is rate-limited. When the limit is hit, the request fails with an `AuthenticationException` +whose `statusCode` is `429`. + ```kotlin // Using Coroutines try { From 3a2c7756211cdcd55932c2f8186cc36906e2b7d4 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 15 Oct 2026 14:36:15 +0530 Subject: [PATCH 6/6] fix(passkey): drop client-side code check, clarify retry cases - Send verification codes as-is; the server reports missing/empty codes - passkeyVerificationRequired is only set for invalid_grant - Docs: describe the two retryable cases, drop the rate-limit note Co-Authored-By: Claude --- .../authentication/AuthenticationAPIClient.kt | 28 +++---------- .../authentication/AuthenticationException.kt | 23 +++++----- .../AuthenticationAPIClientTest.kt | 42 +++++++++---------- .../AuthenticationExceptionTest.kt | 10 +++++ .../util/AuthenticationAPIMockServer.kt | 8 +--- examples/passkeys.md | 19 +++++---- 6 files changed, 61 insertions(+), 69 deletions(-) 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 0d30a0584..9757b7d32 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -18,8 +18,6 @@ import com.auth0.android.request.JsonAdapter import com.auth0.android.request.ProfileRequest import com.auth0.android.request.PublicKeyCredentials import com.auth0.android.request.Request -import com.auth0.android.request.RequestOptions -import com.auth0.android.request.RequestValidator import com.auth0.android.request.SignUpRequest import com.auth0.android.request.UserData import com.auth0.android.request.internal.BaseAuthenticationRequest @@ -241,8 +239,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * @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. A blank code fails the request with an - * [AuthenticationException] before any network call is made. + * required; the `verification` object is then omitted from the request. * @return a request to configure and start that will yield [Credentials] */ @JvmOverloads @@ -266,22 +263,10 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe Gson().toJsonTree(authResponse) ) if (verification.isNotEmpty()) { - val blankCodes = verification.filterValues { it.isBlank() }.keys - if (blankCodes.isNotEmpty()) { - request.addValidator(object : RequestValidator { - override fun validate(options: RequestOptions) { - throw AuthenticationException( - AuthenticationException.ERROR_KEY_VERIFICATION_CODE_EMPTY, - "The verification code for ${blankCodes.joinToString { it.value }} must not be empty." - ) - } - }) - } else { - request.addParameter( - VERIFICATION_KEY, - gson.toJsonTree(verification.mapKeys { it.key.value }) - ) - } + request.addParameter( + VERIFICATION_KEY, + gson.toJsonTree(verification.mapKeys { it.key.value }) + ) } return request as AuthenticationRequest } @@ -314,8 +299,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * @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. A blank code fails the request with an - * [AuthenticationException] before any network call is made. + * required; the `verification` object is then omitted from the request. * @return a request to configure and start that will yield [Credentials] */ @JvmOverloads 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 683ed40de..3ceaae813 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt @@ -203,13 +203,14 @@ public class AuthenticationException : Auth0Exception { /** * Whether a failed passkey signup identifier verification can be retried without restarting - * the registration. `true` when the error is an `invalid_grant` or `invalid_request` and - * contains an `auth_session`, meaning the session is still alive (for example a code was wrong - * or missing and attempts remain). + * 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]. * - * When retryable, re-collect the OTP codes for [passkeyVerificationRequired] and call - * [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey] again with the - * same credential and [passkeyAuthSession]. When `false`, restart the registration. + * Then call [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey] again + * with the same credential and [passkeyAuthSession]. When `false`, restart the registration. * * @see passkeyAuthSession * @see passkeyVerificationRequired @@ -227,15 +228,16 @@ public class AuthenticationException : Auth0Exception { get() = getValue(AUTH_SESSION_KEY) as? String /** - * The identifiers whose OTP codes must be re-collected before retrying. `null` unless - * [isPasskeyVerificationRetryable] is `true`. Identifiers this version of the SDK does not - * know are skipped. + * 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) return null + if (!isPasskeyVerificationRetryable || "invalid_grant" != code) return null return (getValue(VERIFICATION_REQUIRED_KEY) as? List<*>) ?.filterIsInstance() ?.mapNotNull { value -> PasskeyVerificationMethod.values().firstOrNull { it.value == value } } @@ -302,7 +304,6 @@ public class AuthenticationException : Auth0Exception { internal companion object { internal const val ERROR_VALUE_AUTHENTICATION_CANCELED = "a0.authentication_canceled" internal const val ERROR_KEY_URI_NULL = "a0.auth.authorize_uri" - internal const val ERROR_KEY_VERIFICATION_CODE_EMPTY = "a0.auth.verification_code_empty" internal const val ERROR_VALUE_AUTHORIZE_URI_INVALID = "Authorization URI is received as null from the intent" internal const val ERROR_KEY_CT_OPTIONS_NULL = "a0.auth.ct_options" 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 a280ed339..911135e67 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -431,23 +431,26 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldFailSigninWithPasskeyWithBlankVerificationCodeBeforeCallingTheApi() { + public fun shouldSigninWithPasskeySendingBlankVerificationCodeToTheServer() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() val client = AuthenticationAPIClient(auth0) - val exception = assertThrows(AuthenticationException::class.java) { - client.signinWithPasskey( - "auth-session", - mock(), - MY_CONNECTION, - verification = mapOf( - PasskeyVerificationMethod.EMAIL to "123456", - PasskeyVerificationMethod.PHONE to " " - ) - ).execute() - } - assertThat(exception.getCode(), Matchers.`is`("a0.auth.verification_code_empty")) - assertThat(exception.getDescription(), Matchers.containsString("phone")) - assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) - assertThat(mockAPI.server.requestCount, Matchers.`is`(0)) + 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 @@ -489,7 +492,7 @@ public class AuthenticationAPIClientTest { @Test public fun shouldClassifyInvalidRequestWithSessionAsRetryable() { - mockAPI.willReturnPasskeyVerificationInvalidRequestError("email") + mockAPI.willReturnPasskeyVerificationInvalidRequestError() val client = AuthenticationAPIClient(auth0) val exception = assertThrows(AuthenticationException::class.java) { client.signinWithPasskey( @@ -501,10 +504,7 @@ public class AuthenticationAPIClientTest { assertThat(exception.getCode(), Matchers.`is`("invalid_request")) assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(true)) assertThat(exception.passkeyAuthSession, Matchers.`is`(SESSION_ID)) - assertThat( - exception.passkeyVerificationRequired, - Matchers.contains(PasskeyVerificationMethod.EMAIL) - ) + assertThat(exception.passkeyVerificationRequired, Matchers.nullValue()) } @Test 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 fdc9241f3..f9087700c 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt @@ -556,6 +556,16 @@ public class AuthenticationExceptionTest { 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" 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 2c7915417..42ed3b878 100755 --- a/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt @@ -259,15 +259,11 @@ internal class AuthenticationAPIMockServer : APIMockServer() { return this } - fun willReturnPasskeyVerificationInvalidRequestError( - vararg identifiers: String - ): AuthenticationAPIMockServer { - val verificationRequired = identifiers.joinToString(prefix = "[", postfix = "]") { "\"$it\"" } + fun willReturnPasskeyVerificationInvalidRequestError(): AuthenticationAPIMockServer { val json = """{ "error": "invalid_request", "error_description": "Missing required parameter: verification.email", - "auth_session": "$SESSION_ID", - "verification_required": $verificationRequired + "auth_session": "$SESSION_ID" }""" server.enqueue(responseWithJSON(json, 400)) return this diff --git a/examples/passkeys.md b/examples/passkeys.md index 7f641accf..3ed3a3b71 100644 --- a/examples/passkeys.md +++ b/examples/passkeys.md @@ -109,23 +109,24 @@ can be issued. It is `null` when no verification is required. You must: 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). A blank - code fails the request with an `AuthenticationException` before any network call is made. +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. -An error is retryable (`isPasskeyVerificationRetryable`) when it is an `invalid_grant` or `invalid_request` error -that carries an `auth_session`, meaning the session is still alive (for example a code was wrong or missing and -attempts remain). The identifiers to re-collect are in `passkeyVerificationRequired` and the session in -`passkeyAuthSession`. Any other error is terminal and the registration must be restarted from `signupWithPasskey`. +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. -`signupWithPasskey` is rate-limited. When the limit is hit, the request fails with an `AuthenticationException` -whose `statusCode` is `429`. - ```kotlin // Using Coroutines try {