From 9478b7a22de67432caec39a5bc9962e76af8f2ea Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Fri, 9 Oct 2026 15:30:04 +0530 Subject: [PATCH 01/19] 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 7043867fd9e95fbd167cc22d985a1a525d93afba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 00:20:03 +0000 Subject: [PATCH 02/19] chore(deps): bump the codeql group with 3 updates Bumps the codeql group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/autobuild](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action). Updates `github/codeql-action/init` from 4.38.2 to 4.38.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d) Updates `github/codeql-action/autobuild` from 4.38.2 to 4.38.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d) Updates `github/codeql-action/analyze` from 4.38.2 to 4.38.3 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d) --- updated-dependencies: - dependency-name: github/codeql-action/init dependency-version: 4.38.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: codeql - dependency-name: github/codeql-action/autobuild dependency-version: 4.38.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: codeql - dependency-name: github/codeql-action/analyze dependency-version: 4.38.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: codeql ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0f1be80fd..344642fc6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -45,15 +45,15 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/autobuild@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 with: category: "/language:${{ matrix.language }}" From 68df2f5e2132c5035bc05bc66f94b8cc04023692 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 12 Aug 2026 17:09:09 +0530 Subject: [PATCH 03/19] fix(storage): authorize MGF1 digest to prevent INCOMPATIBLE_DEVICE on Keystore2 Port of #1048 from release/3.x. Dropped the API < 23 KeyPairGeneratorSpec branch (not present on main) and migrated the new test from PowerMockito to Mockito. (cherry picked from commit fcb50eec41c395e045607c104a6e9e5711e2cdc4) --- .../authentication/storage/CryptoUtil.java | 59 ++++++++++++++++--- .../storage/CryptoUtilTest.java | 31 +++++++++- 2 files changed, 79 insertions(+), 11 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java b/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java index 5e42756e1..3214487ff 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/CryptoUtil.java @@ -179,7 +179,7 @@ KeyStore.PrivateKeyEntry getRSAKeyEntry() throws CryptoException, IncompatibleDe end.add(Calendar.YEAR, 25); X500Principal principal = new X500Principal("CN=Auth0.Android,O=Auth0"); - AlgorithmParameterSpec spec = new KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT) + KeyGenParameterSpec.Builder specBuilder = new KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_DECRYPT | KeyProperties.PURPOSE_ENCRYPT) .setCertificateSubject(principal) .setCertificateSerialNumber(BigInteger.ONE) .setCertificateNotBefore(start.getTime()) @@ -187,8 +187,25 @@ KeyStore.PrivateKeyEntry getRSAKeyEntry() throws CryptoException, IncompatibleDe .setKeySize(RSA_KEY_SIZE) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) .setDigests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256) - .setBlockModes(KeyProperties.BLOCK_MODE_ECB) - .build(); + .setBlockModes(KeyProperties.BLOCK_MODE_ECB); + + /* + * OAEP uses two independent digests: the OAEP digest (authorized above) and the MGF1 + * digest. Our OAEP_SPEC uses MGF1/SHA-1. The setMgf1Digests API only exists on + * API 35+ (VANILLA_ICE_CREAM); below that KeyMint defaults the MGF1 digest + * (historically to SHA-1), so leaving it unset worked. On newer Keystore2 hardware + * the authorized MGF1 digest set is strictly enforced and its default is + * vendor-dependent (some default to SHA-256 only), which rejects our MGF1/SHA-1 + * cipher with INCOMPATIBLE_MGF_DIGEST ("Incompatible padding mode"). Where the API is + * available, authorize SHA-1 (used by the current cipher) and SHA-256 explicitly so + * the key no longer depends on the vendor default and remains compatible if the + * cipher moves to MGF1/SHA-256 in the future. See OAEP_SPEC. + */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM) { + specBuilder.setMgf1Digests(KeyProperties.DIGEST_SHA1, KeyProperties.DIGEST_SHA256); + } + + AlgorithmParameterSpec spec = specBuilder.build(); KeyPairGenerator generator = KeyPairGenerator.getInstance(ALGORITHM_RSA, ANDROID_KEY_STORE); generator.initialize(spec); @@ -323,18 +340,42 @@ byte[] RSADecrypt(byte[] encryptedInput) throws IncompatibleDeviceException, Cry Cipher cipher = Cipher.getInstance(RSA_TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, privateKey, OAEP_SPEC); return cipher.doFinal(encryptedInput); - } catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException - | InvalidAlgorithmParameterException e) { + } catch (InvalidKeyException | InvalidAlgorithmParameterException e) { + /* + * - InvalidKeyException: + * Thrown if the stored key is inappropriate for initializing this cipher. On newer + * Keystore2 hardware (API 35+) an existing RSA key whose authorized MGF1 digest set + * does not include SHA-1 rejects our MGF1/SHA-1 cipher here, surfacing as + * "Keystore operation failed" caused by INCOMPATIBLE_MGF_DIGEST ("Incompatible + * padding mode"). Such a key was generated by an older SDK before the MGF1 digest + * was authorized explicitly (see getRSAKeyEntry). + * - InvalidAlgorithmParameterException: + * Thrown if the OAEP parameters are invalid or unsupported for this key. + * + * This is NOT a device-level incompatibility -- the key can be deleted and regenerated + * with the MGF1 digest authorized. Delete the RSA key (and the AES key it protected, + * which is now unrecoverable) and wrap as CryptoException so the caller falls through to + * key regeneration instead of permanently blocking the user with INCOMPATIBLE_DEVICE. + * If the device genuinely cannot generate a compatible key, the subsequent + * getRSAKeyEntry() will throw IncompatibleDeviceException, preserving the terminal path. + * + * Read more in https://developer.android.com/reference/javax/crypto/Cipher + */ + Log.e(TAG, "RSA key rejected the cipher parameters (likely MGF1 digest mismatch on Keystore2). Recreating key.", e); + deleteRSAKeys(); + deleteAESKeys(); + throw new CryptoException( + "The RSA key's authorized parameters are incompatible with the current cipher. The keys have been recreated; please retry.", e); + } catch (NoSuchAlgorithmException | NoSuchPaddingException e) { /* * - NoSuchPaddingException: * Thrown if PKCS1Padding is not available. Was introduced in API 1. * - NoSuchAlgorithmException: * Thrown if the transformation is null, empty or invalid, or if no security provider * implements it. Was introduced in API 1. - * - InvalidKeyException: - * Thrown if the given key is inappropriate for initializing this cipher. - * - InvalidAlgorithmParameterException: - * Thrown if the OAEP parameters are invalid or unsupported. + * + * These indicate the algorithm/padding is not available on the device at all -- a real + * device-level incompatibility. * * Read more in https://developer.android.com/reference/javax/crypto/Cipher */ diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java b/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java index e6b1c3412..02a1baaeb 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/CryptoUtilTest.java @@ -705,8 +705,8 @@ public void shouldRSADecryptData() throws Exception { } @Test - public void shouldThrowOnInvalidKeyExceptionWhenTryingToRSADecrypt() { - Assert.assertThrows("The device is not compatible with the CryptoUtil class", IncompatibleDeviceException.class, () -> { + public void shouldRecreateKeysAndThrowCryptoExceptionOnInvalidKeyExceptionWhenTryingToRSADecrypt() throws Exception { + Assert.assertThrows("The RSA key's authorized parameters are incompatible with the current cipher. The keys have been recreated; please retry.", CryptoException.class, () -> { byte[] sampleBytes = new byte[0]; PrivateKey privateKey = Mockito.mock(PrivateKey.class); KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); @@ -717,6 +717,33 @@ public void shouldThrowOnInvalidKeyExceptionWhenTryingToRSADecrypt() { cryptoUtil.RSADecrypt(sampleBytes); }); + + Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); + Mockito.verify(keyStore).deleteEntry(OLD_KEY_ALIAS); + Mockito.verify(storage).remove(KEY_ALIAS); + Mockito.verify(storage).remove(KEY_ALIAS + "_iv"); + Mockito.verify(storage).remove(OLD_KEY_ALIAS); + Mockito.verify(storage).remove(OLD_KEY_ALIAS + "_iv"); + } + + @Test + public void shouldRecreateKeysAndThrowCryptoExceptionOnInvalidAlgorithmParameterExceptionWhenTryingToRSADecrypt() throws Exception { + Assert.assertThrows("The RSA key's authorized parameters are incompatible with the current cipher. The keys have been recreated; please retry.", CryptoException.class, () -> { + byte[] sampleBytes = new byte[0]; + PrivateKey privateKey = Mockito.mock(PrivateKey.class); + KeyStore.PrivateKeyEntry privateKeyEntry = Mockito.mock(KeyStore.PrivateKeyEntry.class); + doReturn(privateKey).when(privateKeyEntry).getPrivateKey(); + doReturn(privateKeyEntry).when(cryptoUtil).getRSAKeyEntry(); + Mockito.when(Cipher.getInstance(RSA_TRANSFORMATION)).thenReturn(rsaOaepCipher); + doThrow(new InvalidAlgorithmParameterException()).when(rsaOaepCipher).init(eq(Cipher.DECRYPT_MODE), eq(privateKey), any(AlgorithmParameterSpec.class)); + + cryptoUtil.RSADecrypt(sampleBytes); + }); + + Mockito.verify(keyStore).deleteEntry(KEY_ALIAS); + Mockito.verify(keyStore).deleteEntry(OLD_KEY_ALIAS); + Mockito.verify(storage).remove(KEY_ALIAS); + Mockito.verify(storage).remove(OLD_KEY_ALIAS); } @Test From 17b542fd28dc26678496db22a8d5a61db55609da Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 14 Oct 2026 09:17:27 +0530 Subject: [PATCH 04/19] docs(changelog): retain 3.21.1-beta.0 and 3.21.1 release history --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 823f094bd..fd8c6186a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## [3.21.1](https://github.com/auth0/Auth0.Android/tree/3.21.1) (2026-10-13) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.21.0...3.21.1) + +**Fixed** +- fix(storage): authorize MGF1 digest to prevent INCOMPATIBLE_DEVICE on Keystore2 [\#1048](https://github.com/auth0/Auth0.Android/pull/1048) ([utkrishtsahu](https://github.com/utkrishtsahu)) + ## [4.1.0](https://github.com/auth0/Auth0.Android/tree/4.1.0) (2026-08-27) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.0.1...4.1.0) @@ -7,6 +13,12 @@ - feat: enable ephemeral browsing for Auth Tab [\#1059](https://github.com/auth0/Auth0.Android/pull/1059) ([pmathew92](https://github.com/pmathew92)) - feat(authentication): add organization parameter to resetPassword [\#1038](https://github.com/auth0/Auth0.Android/pull/1038) ([NandanPrabhu](https://github.com/NandanPrabhu)) +## [3.21.1-beta.0](https://github.com/auth0/Auth0.Android/tree/3.21.1-beta.0) (2026-08-18) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.21.0...3.21.1-beta.0) + +**Fixed** +- fix(storage): authorize MGF1 digest to prevent INCOMPATIBLE_DEVICE on Keystore2 [\#1048](https://github.com/auth0/Auth0.Android/pull/1048) ([utkrishtsahu](https://github.com/utkrishtsahu)) + ## [4.0.1](https://github.com/auth0/Auth0.Android/tree/4.0.1) (2026-07-24) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.0.0...4.0.1) From 0750a5d471c0233f1e1b593a6bec61d7895e499e Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 14 Oct 2026 13:25:33 +0530 Subject: [PATCH 05/19] 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 06/19] 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 07/19] 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 08/19] 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 09/19] 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 { From df60ab4f1c74c5b1a21c8c42c2230d269e2829d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:32:07 +0530 Subject: [PATCH 10/19] chore(deps): bump actions/setup-java from 6.0.1 to 6.1.0 in the setup-java group (#1097) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 344642fc6..941d114be 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -36,7 +36,7 @@ jobs: run: exit 0 # Skip unnecessary test runs for dependabot and merge queues. Artifically flag as successful, as this is a required check for branch protection. - name: Set up Java - uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0 with: distribution: 'temurin' java-version: '17' From 3165d27ea8eaf37216f90c6867ce11b58525f9d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:05:39 +0530 Subject: [PATCH 11/19] chore(deps): bump the codeql group with 3 updates (#1096) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Prince Mathew <17837162+pmathew92@users.noreply.github.com> --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 941d114be..ff8dd8e97 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -45,15 +45,15 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + uses: github/codeql-action/autobuild@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: category: "/language:${{ matrix.language }}" From acf40688e7f7febbabda802ea179b69e82a2fc4c Mon Sep 17 00:00:00 2001 From: Prince Mathew <17837162+pmathew92@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:19:56 +0530 Subject: [PATCH 12/19] fix: stop reporting an unrecognized biometric as a final failure (#1100) Co-authored-by: Claude --- CHANGELOG.md | 6 + .../storage/LocalAuthenticationManager.kt | 5 - .../storage/LocalAuthenticationManagerTest.kt | 46 +++++++- .../storage/SecureCredentialsManagerTest.kt | 111 ++++++++++++++++++ 4 files changed, 161 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8c6186a..3ebe491ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## [3.21.2](https://github.com/auth0/Auth0.Android/tree/3.21.2) (2026-10-21) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.21.1...3.21.2) + +**Fixed** +- fix: stop reporting an unrecognized biometric as a final failure [\#1098](https://github.com/auth0/Auth0.Android/pull/1098) ([pmathew92](https://github.com/pmathew92)) + ## [3.21.1](https://github.com/auth0/Auth0.Android/tree/3.21.1) (2026-10-13) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.21.0...3.21.1) diff --git a/auth0/src/main/java/com/auth0/android/authentication/storage/LocalAuthenticationManager.kt b/auth0/src/main/java/com/auth0/android/authentication/storage/LocalAuthenticationManager.kt index 3b7e81965..d73d1ef69 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/storage/LocalAuthenticationManager.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/storage/LocalAuthenticationManager.kt @@ -76,11 +76,6 @@ internal class LocalAuthenticationManager( biometricPrompt.authenticate(biometricPromptInfo) } - override fun onAuthenticationFailed() { - super.onAuthenticationFailed() - resultCallback.onFailure(CredentialsManagerException.BIOMETRICS_INVALID_USER) - } - override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) resultCallback.onSuccess(true) diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/LocalAuthenticationManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/LocalAuthenticationManagerTest.kt index 91f1ea557..63ec726a9 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/LocalAuthenticationManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/LocalAuthenticationManagerTest.kt @@ -7,7 +7,9 @@ import com.auth0.android.callback.Callback import org.mockito.kotlin.KArgumentCaptor import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.never import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.hamcrest.core.Is @@ -360,7 +362,9 @@ public class LocalAuthenticationManagerTest { } @Test - public fun testInvalidAuthenticationScenario() { + public fun testUnrecognizedBiometricDoesNotDeliverAResult() { + Mockito.`when`(biometricManager.canAuthenticate(any())) + .thenReturn(BiometricManager.BIOMETRIC_SUCCESS) val localAuthenticationManager = LocalAuthenticationManager( fragmentActivity, getAuthenticationOptions(), @@ -369,10 +373,48 @@ public class LocalAuthenticationManagerTest { ) localAuthenticationManager.authenticate() + localAuthenticationManager.onAuthenticationFailed() + verifyNoMoreInteractions(callback) + } + + @Test + public fun testUnrecognizedBiometricFollowedBySuccessDeliversOnlySuccess() { Mockito.`when`(biometricManager.canAuthenticate(any())) .thenReturn(BiometricManager.BIOMETRIC_SUCCESS) + val localAuthenticationManager = LocalAuthenticationManager( + fragmentActivity, + getAuthenticationOptions(), + biometricManager, + callback + ) + + localAuthenticationManager.authenticate() localAuthenticationManager.onAuthenticationFailed() - verify(callback).onFailure(CredentialsManagerException.BIOMETRICS_INVALID_USER) + localAuthenticationManager.onAuthenticationSucceeded( + Mockito.mock(BiometricPrompt.AuthenticationResult::class.java) + ) + verify(callback).onSuccess(true) + verify(callback, never()).onFailure(any()) + } + + @Test + public fun testUnrecognizedBiometricFollowedByErrorDeliversOnlyThatError() { + Mockito.`when`(biometricManager.canAuthenticate(any())) + .thenReturn(BiometricManager.BIOMETRIC_SUCCESS) + val localAuthenticationManager = LocalAuthenticationManager( + fragmentActivity, + getAuthenticationOptions(), + biometricManager, + callback + ) + + localAuthenticationManager.authenticate() + localAuthenticationManager.onAuthenticationFailed() + localAuthenticationManager.onAuthenticationError( + BiometricPrompt.ERROR_LOCKOUT, "Too many attempts" + ) + verify(callback).onFailure(CredentialsManagerException.BIOMETRIC_ERROR_LOCKOUT) + verifyNoMoreInteractions(callback) } @Test diff --git a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt index f67f87297..25fb702cd 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/storage/SecureCredentialsManagerTest.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.app.KeyguardManager import android.content.Context import android.util.Base64 +import androidx.biometric.BiometricPrompt import androidx.fragment.app.FragmentActivity import com.auth0.android.NetworkErrorException import com.auth0.android.authentication.AuthenticationAPIClient @@ -1196,6 +1197,91 @@ public class SecureCredentialsManagerTest { ) } + @Test + @ExperimentalCoroutinesApi + public fun shouldAwaitCredentialsOnceWhenUnrecognizedBiometricIsFollowedBySuccess(): Unit = + runTest { + simulatePromptWithUnrecognizedBiometric { prompt -> + prompt.onAuthenticationSucceeded(mock()) + } + verifyNoMoreInteractions(client) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) + insertTestCredentials(true, true, true, expiresAt, "scope") + val retrievedCredentials = manager.awaitCredentials() + MatcherAssert.assertThat(retrievedCredentials.accessToken, Is.`is`("accessToken")) + MatcherAssert.assertThat(retrievedCredentials.idToken, Is.`is`("idToken")) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldThrowOnlyTheFinalErrorOnAwaitCredentialsWhenUnrecognizedBiometricIsFollowedByError(): Unit = + runTest { + simulatePromptWithUnrecognizedBiometric { prompt -> + prompt.onAuthenticationError(BiometricPrompt.ERROR_LOCKOUT, "Too many attempts") + } + verifyNoMoreInteractions(client) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) + insertTestCredentials(true, true, true, expiresAt, "scope") + val exception = assertThrows(CredentialsManagerException::class.java) { + runBlocking { manager.awaitCredentials() } + } + MatcherAssert.assertThat( + exception, + Is.`is`(CredentialsManagerException.BIOMETRIC_ERROR_LOCKOUT) + ) + } + + @Test + @ExperimentalCoroutinesApi + public fun shouldAwaitApiCredentialsOnceWhenUnrecognizedBiometricIsFollowedBySuccess(): Unit = + runTest { + simulatePromptWithUnrecognizedBiometric { prompt -> + prompt.onAuthenticationSucceeded(mock()) + } + verifyNoMoreInteractions(client) + insertTestApiCredentials( + "audience1", true, Date(CredentialsMock.ONE_HOUR_AHEAD_MS), "scope" + ) + val retrievedCredentials = manager.awaitApiCredentials("audience1") + Assert.assertEquals("accessToken", retrievedCredentials.accessToken) + } + + @Test + public fun shouldDeliverOnlySuccessAndStartBiometricSessionWhenUnrecognizedBiometricIsFollowedBySuccess() { + simulatePromptWithUnrecognizedBiometric { prompt -> + prompt.onAuthenticationSucceeded(mock()) + } + val sessionOptions = LocalAuthenticationOptions.Builder() + .setTitle("title") + .setAuthenticationLevel(AuthenticationLevel.STRONG) + .setNegativeButtonText("negativeButtonText") + .setPolicy(BiometricPolicy.Session(300)) + .build() + val sessionManager = Mockito.spy( + SecureCredentialsManager( + client, + storage, + crypto, + jwtDecoder, + serialExecutor, + weakFragmentActivity, + sessionOptions, + factory + ) + ) + Mockito.doReturn(CredentialsMock.CURRENT_TIME_MS).`when`(sessionManager).currentTimeInMillis + verifyNoMoreInteractions(client) + val expiresAt = Date(CredentialsMock.CURRENT_TIME_MS + ONE_HOUR_SECONDS * 1000) + insertTestCredentials(true, true, true, expiresAt, "scope") + + sessionManager.getCredentials(callback) + + verify(callback, times(1)).onSuccess(credentialsCaptor.capture()) + verify(callback, never()).onFailure(any()) + MatcherAssert.assertThat(credentialsCaptor.firstValue.accessToken, Is.`is`("accessToken")) + MatcherAssert.assertThat(sessionManager.isBiometricSessionValid(), Is.`is`(true)) + } + @Test public fun shouldGetNonExpiredCredentialsFromStorageWhenOnlyIdTokenIsAvailable() { Mockito.`when`(localAuthenticationManager.authenticate()).then { @@ -4583,6 +4669,31 @@ public class SecureCredentialsManagerTest { private const val KEY_ALIAS = "com.auth0.key" } + /** + * Makes [factory] return a real [LocalAuthenticationManager] whose prompt reports an + * unrecognized biometric before delivering [finalResult], mirroring how BiometricPrompt + * keeps the prompt open after a failed attempt. + */ + private fun simulatePromptWithUnrecognizedBiometric( + finalResult: (LocalAuthenticationManager) -> Unit + ) { + Mockito.`when`(factory.create(any(), any(), any())).thenAnswer { invocation -> + val prompt = Mockito.spy( + LocalAuthenticationManager( + activity = invocation.arguments[0] as FragmentActivity, + authenticationOptions = invocation.arguments[1] as LocalAuthenticationOptions, + biometricManager = mock(), + resultCallback = invocation.arguments[2] as Callback + ) + ) + Mockito.doAnswer { + prompt.onAuthenticationFailed() + finalResult(prompt) + }.`when`(prompt).authenticate() + return@thenAnswer prompt + } + } + private fun getAuthenticationOptions( title: String = "title", description: String = "description", From 337136660bf49e4c1dfb5bf282f3129a1ee3624a Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 21 Oct 2026 12:30:25 +0530 Subject: [PATCH 13/19] feat(passkey): validate verification codes against signup challenge - Add signinWithPasskey(challenge, ...) that checks codes against verificationRequired before sending, and only sends the required ones - Fail locally with an invalid_request shaped like the server error for an empty map, a missing identifier, or an empty or whitespace-only code - Only invalid_grant with auth_session is retryable for now - Update KDoc, tests and passkey docs Co-Authored-By: Claude --- .../authentication/AuthenticationAPIClient.kt | 98 ++++++++++ .../authentication/AuthenticationException.kt | 23 +-- .../AuthenticationAPIClientTest.kt | 178 +++++++++++++++++- .../AuthenticationExceptionTest.kt | 6 +- .../util/AuthenticationAPIMockServer.kt | 3 +- examples/passkeys.md | 34 ++-- 6 files changed, 305 insertions(+), 37 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 9757b7d32..7257f04f0 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -18,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 @@ -318,6 +320,96 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe } + /** + * Sign-in a new user after a passkey signup, completing identifier verification when the + * signup challenge requires it. + * This should be called after [signupWithPasskey] returned the [challenge] and the client generated the public key + * credential from it. The default scope used is 'openid profile email'. + * + * When [PasskeyRegistrationChallenge.verificationRequired] lists identifiers, [verification] must contain a + * non-blank code for each of them; codes for other identifiers are not sent. Otherwise the request fails before any + * network call with an `invalid_request` [AuthenticationException] whose description names the first identifier + * without a valid code. When no verification is required, no `verification` object is sent. + * + * Requires the client to have the **Passkey** Grant Type enabled. See [Client Grant Types](https://auth0.com/docs/clients/client-grant-types) + * to learn how to enable it. + * + * Example usage: + * + * ``` + * client.signinWithPasskey(challenge, authResponse, "{realm}", "{organization}", + * mapOf(PasskeyVerificationMethod.EMAIL to "{code}")) + * .validateClaims() //mandatory + * .start(object: Callback { + * override fun onFailure(error: AuthenticationException) { } + * override fun onSuccess(result: Credentials) { } + * }) + * ``` + * + * @param challenge the [PasskeyRegistrationChallenge] returned by [signupWithPasskey]. + * @param authResponse the [PublicKeyCredentials] created from the challenge + * @param realm the connection to use. If excluded, the application will use the default connection configured in the tenant + * @param organization id of the organization to be associated with the user while signing in + * @param verification a map of identifier to the OTP code collected from the user, e.g. + * `mapOf(PasskeyVerificationMethod.EMAIL to "123456")`. + * @return a request to configure and start that will yield [Credentials] + */ + @JvmOverloads + public fun signinWithPasskey( + challenge: PasskeyRegistrationChallenge, + authResponse: PublicKeyCredentials, + realm: String? = null, + organization: String? = null, + verification: Map = emptyMap(), + ): AuthenticationRequest { + val required = challenge.verificationRequired.orEmpty() + val codes = verification.filterKeys { it in required } + val request = signinWithPasskey(challenge.authSession, authResponse, realm, organization, codes) + if (required.isNotEmpty()) { + request.addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { + passkeyVerificationError(challenge.authSession, required, codes)?.let { throw it } + } + }) + } + return request + } + + /** + * Returns the error the server would report when [codes] don't cover the [required] identifiers, or `null` when + * they do. The error carries the [authSession] and the identifiers to collect codes for, in the same shape as the + * server's error. + */ + private fun passkeyVerificationError( + authSession: String, + required: List, + codes: Map + ): AuthenticationException? { + val failed: List + val description: String + if (codes.isEmpty()) { + failed = required + description = MISSING_VERIFICATION_DESCRIPTION + } else { + failed = required.filter { codes[it].isNullOrBlank() } + val first = failed.firstOrNull() ?: return null + description = if (codes[first] == null) { + "Missing required parameter: verification.${first.value}" + } else { + "\"verification.${first.value}\" is not allowed to be empty" + } + } + return AuthenticationException( + mapOf( + ERROR_KEY to INVALID_REQUEST, + ERROR_DESCRIPTION_KEY to description, + AUTH_SESSION_KEY to authSession, + VERIFICATION_REQUIRED_KEY to failed.map { it.value } + ) + ) + } + + /** * Sign-up a user and returns a challenge for private and public key generation. * The default scope used is 'openid profile email'. @@ -1070,6 +1162,12 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe 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 VERIFICATION_REQUIRED_KEY = "verification_required" + private const val ERROR_KEY = "error" + private const val ERROR_DESCRIPTION_KEY = "error_description" + private const val INVALID_REQUEST = "invalid_request" + private const val MISSING_VERIFICATION_DESCRIPTION = + "Missing required parameter: verification must have atleast one identifier otp code" private const val DELIVERY_METHOD_KEY = "delivery_method" private const val USER_PROFILE_KEY = "user_profile" private const val SIGN_UP_PATH = "signup" 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 3ceaae813..4ddcdba3d 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt @@ -203,20 +203,18 @@ public class AuthenticationException : Auth0Exception { /** * Whether a failed passkey signup identifier verification can be retried without restarting - * the registration. `true` when the error contains an `auth_session` and is either: - * - an `invalid_request`, because the `verification` codes were missing or empty. Re-collect - * the codes for the challenge's `verificationRequired`; or - * - an `invalid_grant`, because a code was wrong. Re-collect the codes for - * [passkeyVerificationRequired]. + * the registration. `true` when the error is an `invalid_grant` that contains an + * `auth_session`, meaning a code was wrong and the session still has attempts left. * - * Then call [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey] again - * with the same credential and [passkeyAuthSession]. When `false`, restart the registration. + * When retryable, re-collect the codes for [passkeyVerificationRequired] and call + * [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey] again with the + * same credential and challenge. When `false`, restart the registration. * * @see passkeyAuthSession * @see passkeyVerificationRequired */ public val isPasskeyVerificationRetryable: Boolean - get() = ("invalid_grant" == code || "invalid_request" == code) && passkeyAuthSession != null + get() = "invalid_grant" == code && passkeyAuthSession != null /** * The auth session returned with a failed passkey verification, to be reused on the retry so @@ -228,16 +226,15 @@ public class AuthenticationException : Auth0Exception { get() = getValue(AUTH_SESSION_KEY) as? String /** - * The identifiers whose OTP code was wrong and must be re-collected before retrying. Only set - * for a retryable `invalid_grant`; `null` otherwise, including for a retryable - * `invalid_request`, where the challenge's `verificationRequired` applies. Identifiers this - * version of the SDK does not know are skipped. + * The identifiers whose OTP code was wrong and must be re-collected before retrying. `null` + * unless [isPasskeyVerificationRetryable] is `true`. Identifiers this version of the SDK does + * not know are skipped. * * @see isPasskeyVerificationRetryable */ public val passkeyVerificationRequired: List? get() { - if (!isPasskeyVerificationRetryable || "invalid_grant" != code) return null + if (!isPasskeyVerificationRetryable) return null return (getValue(VERIFICATION_REQUIRED_KEY) as? List<*>) ?.filterIsInstance() ?.mapNotNull { value -> PasskeyVerificationMethod.values().firstOrNull { it.value == value } } 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 911135e67..cbef7643d 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -23,7 +23,13 @@ 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.AuthenticatorSelection +import com.auth0.android.result.AuthnParamsPublicKey +import com.auth0.android.result.PasskeyRegistrationChallenge +import com.auth0.android.result.PasskeyUser import com.auth0.android.result.PasskeyVerificationMethod +import com.auth0.android.result.PubKeyCredParam +import com.auth0.android.result.RelyingParty import com.auth0.android.result.SSOCredentials import com.auth0.android.result.UserProfile import com.auth0.android.util.Auth0UserAgent @@ -491,7 +497,7 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldClassifyInvalidRequestWithSessionAsRetryable() { + public fun shouldNotClassifyInvalidRequestWithSessionAsRetryable() { mockAPI.willReturnPasskeyVerificationInvalidRequestError() val client = AuthenticationAPIClient(auth0) val exception = assertThrows(AuthenticationException::class.java) { @@ -502,7 +508,7 @@ 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.`is`(SESSION_ID)) assertThat(exception.passkeyVerificationRequired, Matchers.nullValue()) } @@ -575,6 +581,174 @@ public class AuthenticationAPIClientTest { assertThat(exception.mfaRequiredErrorPayload?.mfaToken, Matchers.`is`("MFA_TOKEN")) } + private fun signupChallenge( + vararg required: PasskeyVerificationMethod + ): PasskeyRegistrationChallenge = PasskeyRegistrationChallenge( + "challenge-session", + AuthnParamsPublicKey( + AuthenticatorSelection("required", "preferred"), + "challenge", + listOf(PubKeyCredParam(-7, "public-key")), + RelyingParty("example.com", "example.com"), + 60000, + PasskeyUser("name", "id", "name") + ), + if (required.isEmpty()) null else required.toList() + ) + + private fun assertClientVerificationError( + exception: AuthenticationException, + description: String, + vararg failed: PasskeyVerificationMethod + ) { + assertThat(exception.getCode(), Matchers.`is`("invalid_request")) + assertThat(exception.getDescription(), Matchers.`is`(description)) + assertThat(exception.statusCode, Matchers.`is`(0)) + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + assertThat(exception.passkeyAuthSession, Matchers.`is`("challenge-session")) + assertThat( + exception.getValue("verification_required"), + Matchers.`is`(failed.map { it.value }) + ) + assertThat(mockAPI.server.requestCount, Matchers.`is`(0)) + } + + @Test + public fun shouldSigninWithPasskeyChallengeSendingRequiredCodes() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + signupChallenge(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE), + mock(), + MY_CONNECTION, + verification = mapOf( + PasskeyVerificationMethod.EMAIL to "123456", + PasskeyVerificationMethod.PHONE to "654321" + ) + ).start(callback) + ShadowLooper.idleMainLooper() + val body = bodyFromRequest(mockAPI.takeRequest()) + assertThat(body, Matchers.hasEntry("auth_session", "challenge-session")) + @Suppress("UNCHECKED_CAST") + val verification = body["verification"] as Map + assertThat(verification, Matchers.hasEntry("email", "123456")) + assertThat(verification, Matchers.hasEntry("phone", "654321")) + } + + @Test + public fun shouldSigninWithPasskeyChallengeDroppingCodesThatAreNotRequired() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + signupChallenge(PasskeyVerificationMethod.EMAIL), + mock(), + MY_CONNECTION, + verification = mapOf( + PasskeyVerificationMethod.EMAIL to "123456", + PasskeyVerificationMethod.PHONE to "654321" + ) + ).start(callback) + ShadowLooper.idleMainLooper() + val body = bodyFromRequest(mockAPI.takeRequest()) + @Suppress("UNCHECKED_CAST") + val verification = body["verification"] as Map + assertThat(verification, Matchers.hasEntry("email", "123456")) + assertThat(verification, Matchers.not(Matchers.hasKey("phone"))) + } + + @Test + public fun shouldSigninWithPasskeyChallengeWithoutVerificationWhenNoneIsRequired() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + signupChallenge(), + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "123456") + ).start(callback) + ShadowLooper.idleMainLooper() + val body = bodyFromRequest(mockAPI.takeRequest()) + assertThat(body, Matchers.hasEntry("auth_session", "challenge-session")) + assertThat(body, Matchers.not(Matchers.hasKey("verification"))) + } + + @Test + public fun shouldFailSigninWithPasskeyChallengeWhenNoCodesAreGiven() { + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + signupChallenge(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE), + mock(), + MY_CONNECTION + ).execute() + } + assertClientVerificationError( + exception, + "Missing required parameter: verification must have atleast one identifier otp code", + PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE + ) + } + + @Test + public fun shouldFailSigninWithPasskeyChallengeWhenARequiredCodeIsMissing() { + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + signupChallenge(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE), + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "3545") + ).execute() + } + assertClientVerificationError( + exception, + "Missing required parameter: verification.phone", + PasskeyVerificationMethod.PHONE + ) + } + + @Test + public fun shouldFailSigninWithPasskeyChallengeWhenACodeIsWhitespace() { + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + signupChallenge(PasskeyVerificationMethod.EMAIL), + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to " ") + ).execute() + } + assertClientVerificationError( + exception, + "\"verification.email\" is not allowed to be empty", + PasskeyVerificationMethod.EMAIL + ) + } + + @Test + public fun shouldFailSigninWithPasskeyChallengeReportingTheFirstFailedIdentifier() { + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + signupChallenge(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE), + mock(), + MY_CONNECTION, + verification = mapOf( + PasskeyVerificationMethod.EMAIL to "", + PasskeyVerificationMethod.PHONE to "" + ) + ).execute() + } + assertClientVerificationError( + exception, + "\"verification.email\" is not allowed to be empty", + PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE + ) + } + @Test public fun shouldSurface429PlainTextRateLimitOnPasskeyRegister() { mockAPI.willReturnTooManyRequestsPlainText() 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 f9087700c..8a0551374 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt @@ -548,11 +548,11 @@ public class AuthenticationExceptionTest { } @Test - public fun shouldBeRetryablePasskeyVerificationForInvalidRequestWithSession() { + public fun shouldNotBeRetryablePasskeyVerificationForInvalidRequestWithSession() { values[ERROR_KEY] = "invalid_request" values["auth_session"] = "session" val ex = AuthenticationException(values, 400) - MatcherAssert.assertThat(ex.isPasskeyVerificationRetryable, CoreMatchers.`is`(true)) + MatcherAssert.assertThat(ex.isPasskeyVerificationRetryable, CoreMatchers.`is`(false)) MatcherAssert.assertThat(ex.passkeyVerificationRequired, CoreMatchers.nullValue()) } @@ -562,7 +562,7 @@ public class AuthenticationExceptionTest { values["auth_session"] = "session" values["verification_required"] = listOf("email") val ex = AuthenticationException(values, 400) - MatcherAssert.assertThat(ex.isPasskeyVerificationRetryable, CoreMatchers.`is`(true)) + MatcherAssert.assertThat(ex.isPasskeyVerificationRetryable, CoreMatchers.`is`(false)) MatcherAssert.assertThat(ex.passkeyVerificationRequired, CoreMatchers.nullValue()) } 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 42ed3b878..8736b16a5 100755 --- a/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt @@ -263,7 +263,8 @@ internal class AuthenticationAPIMockServer : APIMockServer() { val json = """{ "error": "invalid_request", "error_description": "Missing required parameter: verification.email", - "auth_session": "$SESSION_ID" + "auth_session": "$SESSION_ID", + "verification_required": ["email"] }""" server.enqueue(responseWithJSON(json, 400)) return this diff --git a/examples/passkeys.md b/examples/passkeys.md index 3ed3a3b71..68eb2d7ee 100644 --- a/examples/passkeys.md +++ b/examples/passkeys.md @@ -109,18 +109,21 @@ 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). +2. Pass the signup challenge and the collected codes to `signinWithPasskey(challenge, ...)` via the + `verification` map (identifier -> code). The SDK checks that every identifier in `verificationRequired` + has a non-blank code before sending the request, and only sends the codes for those identifiers. 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. + the codes and call `signinWithPasskey` again **reusing the same challenge and `PublicKeyCredentials`** — + the biometric prompt will not reappear. A failed `signinWithPasskey` can be retried with the same credential when `isPasskeyVerificationRetryable` is -`true`. There are two cases: +`true`. This is the case when 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. -- **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. +If a code is missing or empty (the `verification` map is empty, or the email or phone code is blank), the SDK fails +the request before sending it, with an `invalid_request` error and a `statusCode` of `0`. This error is not +retryable. Any other error is terminal, so restart the registration from `signupWithPasskey`. @@ -153,13 +156,12 @@ try { PublicKeyCredentials::class.java ) - var authSession = challenge.authSession val maxAttempts = 3 // retry limit; adjust it to suit your app var attempts = 0 while (true) { try { val userCredential = authenticationApiClient.signinWithPasskey( - authSession, + challenge, authRequest, "{realm}", "{organization-id}", @@ -170,9 +172,7 @@ 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. If the error does not - // list them, fall back to the identifiers of the original challenge. - authSession = exception.passkeyAuthSession ?: authSession + // Re-collect the failed identifiers and retry with the SAME challenge and credential. val failed = exception.passkeyVerificationRequired ?: challenge.verificationRequired.orEmpty() codes = codes + failed.associateWith { identifier -> promptUserForOtp(identifier) } } @@ -206,7 +206,7 @@ authenticationAPIClient.signupWithPasskey(userData, "{realm}", "{organization-id ((CreatePublicKeyCredentialResponse) createCredentialResponse).getRegistrationResponseJson(), PublicKeyCredentials.class); - authenticationAPIClient.signinWithPasskey(result.getAuthSession(), + authenticationAPIClient.signinWithPasskey(result, credentials, "{realm}", "{organization-id}", verification) .start(new Callback() { @Override @@ -215,10 +215,8 @@ authenticationAPIClient.signupWithPasskey(userData, "{realm}", "{organization-id @Override public void onFailure(@NonNull AuthenticationException error) { if (error.isPasskeyVerificationRetryable()) { - // Re-collect the codes for error.getPasskeyVerificationRequired(), or for - // result.getVerificationRequired() when it is null, and retry - // signinWithPasskey with the SAME credentials. Use - // error.getPasskeyAuthSession() when present, else result.getAuthSession(). + // Re-collect the codes for error.getPasskeyVerificationRequired() and retry + // signinWithPasskey with the SAME challenge (result) and credentials. } else { // Terminal — restart registration. } From d9a98438bf62ab1b78d1fe0327ff6b1bf8f04afa Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Wed, 21 Oct 2026 12:44:59 +0530 Subject: [PATCH 14/19] fix(passkey): align verification check with Auth0.swift - Report the first required identifier without a code, including for an empty map - Only reject empty codes; send whitespace codes and non-required codes as given - Local error carries only error and error_description Co-Authored-By: Claude --- .../authentication/AuthenticationAPIClient.kt | 52 +++++---------- .../AuthenticationAPIClientTest.kt | 65 +++++++++---------- examples/passkeys.md | 7 +- 3 files changed, 53 insertions(+), 71 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 7257f04f0..62a968b32 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -327,9 +327,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * credential from it. The default scope used is 'openid profile email'. * * When [PasskeyRegistrationChallenge.verificationRequired] lists identifiers, [verification] must contain a - * non-blank code for each of them; codes for other identifiers are not sent. Otherwise the request fails before any - * network call with an `invalid_request` [AuthenticationException] whose description names the first identifier - * without a valid code. When no verification is required, no `verification` object is sent. + * non-empty code for each of them. Otherwise the request fails before any network call with an `invalid_request` + * [AuthenticationException] whose description names the first identifier without a code. Codes for other + * identifiers are sent to the server as given, and are not validated. * * Requires the client to have the **Passkey** Grant Type enabled. See [Client Grant Types](https://auth0.com/docs/clients/client-grant-types) * to learn how to enable it. @@ -363,12 +363,11 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe verification: Map = emptyMap(), ): AuthenticationRequest { val required = challenge.verificationRequired.orEmpty() - val codes = verification.filterKeys { it in required } - val request = signinWithPasskey(challenge.authSession, authResponse, realm, organization, codes) + val request = signinWithPasskey(challenge.authSession, authResponse, realm, organization, verification) if (required.isNotEmpty()) { request.addValidator(object : RequestValidator { override fun validate(options: RequestOptions) { - passkeyVerificationError(challenge.authSession, required, codes)?.let { throw it } + passkeyVerificationError(required, verification)?.let { throw it } } }) } @@ -376,37 +375,25 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe } /** - * Returns the error the server would report when [codes] don't cover the [required] identifiers, or `null` when - * they do. The error carries the [authSession] and the identifiers to collect codes for, in the same shape as the - * server's error. + * Returns the error the server would report for the first identifier in [required] that has no code, or an empty + * code, in [verification]; `null` when all of them have a code. */ private fun passkeyVerificationError( - authSession: String, required: List, - codes: Map + verification: Map ): AuthenticationException? { - val failed: List - val description: String - if (codes.isEmpty()) { - failed = required - description = MISSING_VERIFICATION_DESCRIPTION - } else { - failed = required.filter { codes[it].isNullOrBlank() } - val first = failed.firstOrNull() ?: return null - description = if (codes[first] == null) { - "Missing required parameter: verification.${first.value}" - } else { - "\"verification.${first.value}\" is not allowed to be empty" + for (identifier in required) { + val code = verification[identifier] + val description = when { + code == null -> "Missing required parameter: verification.${identifier.value}" + code.isEmpty() -> "\"verification.${identifier.value}\" is not allowed to be empty" + else -> continue } - } - return AuthenticationException( - mapOf( - ERROR_KEY to INVALID_REQUEST, - ERROR_DESCRIPTION_KEY to description, - AUTH_SESSION_KEY to authSession, - VERIFICATION_REQUIRED_KEY to failed.map { it.value } + return AuthenticationException( + mapOf(ERROR_KEY to INVALID_REQUEST, ERROR_DESCRIPTION_KEY to description) ) - ) + } + return null } @@ -1162,12 +1149,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe 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 VERIFICATION_REQUIRED_KEY = "verification_required" private const val ERROR_KEY = "error" private const val ERROR_DESCRIPTION_KEY = "error_description" private const val INVALID_REQUEST = "invalid_request" - private const val MISSING_VERIFICATION_DESCRIPTION = - "Missing required parameter: verification must have atleast one identifier otp code" private const val DELIVERY_METHOD_KEY = "delivery_method" private const val USER_PROFILE_KEY = "user_profile" private const val SIGN_UP_PATH = "signup" 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 cbef7643d..c6ce72933 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -598,18 +598,13 @@ public class AuthenticationAPIClientTest { private fun assertClientVerificationError( exception: AuthenticationException, - description: String, - vararg failed: PasskeyVerificationMethod + description: String ) { assertThat(exception.getCode(), Matchers.`is`("invalid_request")) assertThat(exception.getDescription(), Matchers.`is`(description)) assertThat(exception.statusCode, Matchers.`is`(0)) assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) - assertThat(exception.passkeyAuthSession, Matchers.`is`("challenge-session")) - assertThat( - exception.getValue("verification_required"), - Matchers.`is`(failed.map { it.value }) - ) + assertThat(exception.passkeyAuthSession, Matchers.nullValue()) assertThat(mockAPI.server.requestCount, Matchers.`is`(0)) } @@ -637,7 +632,7 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldSigninWithPasskeyChallengeDroppingCodesThatAreNotRequired() { + public fun shouldSigninWithPasskeyChallengeSendingCodesThatAreNotRequired() { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() val client = AuthenticationAPIClient(auth0) @@ -655,11 +650,11 @@ public class AuthenticationAPIClientTest { @Suppress("UNCHECKED_CAST") val verification = body["verification"] as Map assertThat(verification, Matchers.hasEntry("email", "123456")) - assertThat(verification, Matchers.not(Matchers.hasKey("phone"))) + assertThat(verification, Matchers.hasEntry("phone", "654321")) } @Test - public fun shouldSigninWithPasskeyChallengeWithoutVerificationWhenNoneIsRequired() { + public fun shouldSigninWithPasskeyChallengeWithoutValidationWhenNoneIsRequired() { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() val client = AuthenticationAPIClient(auth0) @@ -672,7 +667,9 @@ public class AuthenticationAPIClientTest { ShadowLooper.idleMainLooper() val body = bodyFromRequest(mockAPI.takeRequest()) assertThat(body, Matchers.hasEntry("auth_session", "challenge-session")) - assertThat(body, Matchers.not(Matchers.hasKey("verification"))) + @Suppress("UNCHECKED_CAST") + val verification = body["verification"] as Map + assertThat(verification, Matchers.hasEntry("email", "123456")) } @Test @@ -685,11 +682,7 @@ public class AuthenticationAPIClientTest { MY_CONNECTION ).execute() } - assertClientVerificationError( - exception, - "Missing required parameter: verification must have atleast one identifier otp code", - PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE - ) + assertClientVerificationError(exception, "Missing required parameter: verification.email") } @Test @@ -703,29 +696,39 @@ public class AuthenticationAPIClientTest { verification = mapOf(PasskeyVerificationMethod.EMAIL to "3545") ).execute() } - assertClientVerificationError( - exception, - "Missing required parameter: verification.phone", - PasskeyVerificationMethod.PHONE - ) + assertClientVerificationError(exception, "Missing required parameter: verification.phone") } @Test - public fun shouldFailSigninWithPasskeyChallengeWhenACodeIsWhitespace() { + public fun shouldFailSigninWithPasskeyChallengeWhenACodeIsEmpty() { val client = AuthenticationAPIClient(auth0) val exception = assertThrows(AuthenticationException::class.java) { client.signinWithPasskey( signupChallenge(PasskeyVerificationMethod.EMAIL), mock(), MY_CONNECTION, - verification = mapOf(PasskeyVerificationMethod.EMAIL to " ") + verification = mapOf(PasskeyVerificationMethod.EMAIL to "") ).execute() } - assertClientVerificationError( - exception, - "\"verification.email\" is not allowed to be empty", - PasskeyVerificationMethod.EMAIL - ) + assertClientVerificationError(exception, "\"verification.email\" is not allowed to be empty") + } + + @Test + public fun shouldSigninWithPasskeyChallengeSendingWhitespaceCodeToTheServer() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + signupChallenge(PasskeyVerificationMethod.EMAIL), + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to " ") + ).start(callback) + ShadowLooper.idleMainLooper() + val body = bodyFromRequest(mockAPI.takeRequest()) + @Suppress("UNCHECKED_CAST") + val verification = body["verification"] as Map + assertThat(verification, Matchers.hasEntry("email", " ")) } @Test @@ -742,11 +745,7 @@ public class AuthenticationAPIClientTest { ) ).execute() } - assertClientVerificationError( - exception, - "\"verification.email\" is not allowed to be empty", - PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE - ) + assertClientVerificationError(exception, "\"verification.email\" is not allowed to be empty") } @Test diff --git a/examples/passkeys.md b/examples/passkeys.md index 68eb2d7ee..48055b1d6 100644 --- a/examples/passkeys.md +++ b/examples/passkeys.md @@ -111,7 +111,7 @@ can be issued. It is `null` when no verification is required. You must: on retry. 2. Pass the signup challenge and the collected codes to `signinWithPasskey(challenge, ...)` via the `verification` map (identifier -> code). The SDK checks that every identifier in `verificationRequired` - has a non-blank code before sending the request, and only sends the codes for those identifiers. + has a non-empty code before sending the request. 3. On a retryable error (`AuthenticationException.isPasskeyVerificationRetryable == true`), re-collect the codes and call `signinWithPasskey` again **reusing the same challenge and `PublicKeyCredentials`** — the biometric prompt will not reappear. @@ -121,9 +121,8 @@ A failed `signinWithPasskey` can be retried with the same credential when `isPas `passkeyVerificationRequired` lists the identifiers whose code was wrong. Collect new codes for those identifiers and retry. -If a code is missing or empty (the `verification` map is empty, or the email or phone code is blank), the SDK fails -the request before sending it, with an `invalid_request` error and a `statusCode` of `0`. This error is not -retryable. +If the code for an identifier in `verificationRequired` is missing or empty, the SDK fails the request before +sending it, with an `invalid_request` error and a `statusCode` of `0`. This error is not retryable. Any other error is terminal, so restart the registration from `signupWithPasskey`. From 30ea41106c97384d6101b68f8518b0733fd51f75 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 22 Oct 2026 11:02:24 +0530 Subject: [PATCH 15/19] refactor(passkey): pass verificationRequired instead of the challenge - Remove signinWithPasskey(challenge, ...) - Add verificationRequired (default empty) to the signinWithPasskey overloads; the codes are checked against it before sending - Update tests and passkey docs Co-Authored-By: Claude --- .../authentication/AuthenticationAPIClient.kt | 82 ++++++----------- .../authentication/AuthenticationException.kt | 2 +- .../AuthenticationAPIClientTest.kt | 92 ++++++++++--------- examples/passkeys.md | 23 +++-- 4 files changed, 88 insertions(+), 111 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 62a968b32..60ba5b2d6 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -242,6 +242,11 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * `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. + * @param verificationRequired the identifiers that need a code, from + * [PasskeyRegistrationChallenge.verificationRequired]. When not empty, [verification] must contain a non-empty + * code for each of them; otherwise the request fails before any network call with an `invalid_request` + * [AuthenticationException] whose description names the first identifier without a code. It is not sent to the + * server. Defaults to an empty list, in which case nothing is validated. * @return a request to configure and start that will yield [Credentials] */ @JvmOverloads @@ -251,6 +256,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe realm: String? = null, organization: String? = null, verification: Map = emptyMap(), + verificationRequired: List = emptyList(), ): AuthenticationRequest { val params = ParameterBuilder.newBuilder().apply { setGrantType(ParameterBuilder.GRANT_TYPE_PASSKEY) @@ -270,6 +276,13 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe gson.toJsonTree(verification.mapKeys { it.key.value }) ) } + if (verificationRequired.isNotEmpty()) { + request.addValidator(object : RequestValidator { + override fun validate(options: RequestOptions) { + passkeyVerificationError(verificationRequired, verification)?.let { throw it } + } + }) + } return request as AuthenticationRequest } @@ -302,6 +315,11 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * `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. + * @param verificationRequired the identifiers that need a code, from + * [PasskeyRegistrationChallenge.verificationRequired]. When not empty, [verification] must contain a non-empty + * code for each of them; otherwise the request fails before any network call with an `invalid_request` + * [AuthenticationException] whose description names the first identifier without a code. It is not sent to the + * server. Defaults to an empty list, in which case nothing is validated. * @return a request to configure and start that will yield [Credentials] */ @JvmOverloads @@ -311,69 +329,23 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe realm: String? = null, organization: String? = null, verification: Map = emptyMap(), + verificationRequired: List = emptyList(), ): AuthenticationRequest { val publicKeyCredentials = gson.fromJson( authResponse, PublicKeyCredentials::class.java ) - return signinWithPasskey(authSession, publicKeyCredentials, realm, organization, verification) + return signinWithPasskey( + authSession, + publicKeyCredentials, + realm, + organization, + verification, + verificationRequired + ) } - /** - * Sign-in a new user after a passkey signup, completing identifier verification when the - * signup challenge requires it. - * This should be called after [signupWithPasskey] returned the [challenge] and the client generated the public key - * credential from it. The default scope used is 'openid profile email'. - * - * When [PasskeyRegistrationChallenge.verificationRequired] lists identifiers, [verification] must contain a - * non-empty code for each of them. Otherwise the request fails before any network call with an `invalid_request` - * [AuthenticationException] whose description names the first identifier without a code. Codes for other - * identifiers are sent to the server as given, and are not validated. - * - * Requires the client to have the **Passkey** Grant Type enabled. See [Client Grant Types](https://auth0.com/docs/clients/client-grant-types) - * to learn how to enable it. - * - * Example usage: - * - * ``` - * client.signinWithPasskey(challenge, authResponse, "{realm}", "{organization}", - * mapOf(PasskeyVerificationMethod.EMAIL to "{code}")) - * .validateClaims() //mandatory - * .start(object: Callback { - * override fun onFailure(error: AuthenticationException) { } - * override fun onSuccess(result: Credentials) { } - * }) - * ``` - * - * @param challenge the [PasskeyRegistrationChallenge] returned by [signupWithPasskey]. - * @param authResponse the [PublicKeyCredentials] created from the challenge - * @param realm the connection to use. If excluded, the application will use the default connection configured in the tenant - * @param organization id of the organization to be associated with the user while signing in - * @param verification a map of identifier to the OTP code collected from the user, e.g. - * `mapOf(PasskeyVerificationMethod.EMAIL to "123456")`. - * @return a request to configure and start that will yield [Credentials] - */ - @JvmOverloads - public fun signinWithPasskey( - challenge: PasskeyRegistrationChallenge, - authResponse: PublicKeyCredentials, - realm: String? = null, - organization: String? = null, - verification: Map = emptyMap(), - ): AuthenticationRequest { - val required = challenge.verificationRequired.orEmpty() - val request = signinWithPasskey(challenge.authSession, authResponse, realm, organization, verification) - if (required.isNotEmpty()) { - request.addValidator(object : RequestValidator { - override fun validate(options: RequestOptions) { - passkeyVerificationError(required, verification)?.let { throw it } - } - }) - } - return request - } - /** * Returns the error the server would report for the first identifier in [required] that has no code, or an empty * code, in [verification]; `null` when all of them have a code. 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 4ddcdba3d..bc9ebfa47 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt @@ -208,7 +208,7 @@ public class AuthenticationException : Auth0Exception { * * When retryable, re-collect the codes for [passkeyVerificationRequired] and call * [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey] again with the - * same credential and challenge. When `false`, restart the registration. + * same credential and [passkeyAuthSession]. When `false`, restart the registration. * * @see passkeyAuthSession * @see passkeyVerificationRequired 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 c6ce72933..5a0bed72b 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -23,13 +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.AuthenticatorSelection -import com.auth0.android.result.AuthnParamsPublicKey -import com.auth0.android.result.PasskeyRegistrationChallenge -import com.auth0.android.result.PasskeyUser import com.auth0.android.result.PasskeyVerificationMethod -import com.auth0.android.result.PubKeyCredParam -import com.auth0.android.result.RelyingParty import com.auth0.android.result.SSOCredentials import com.auth0.android.result.UserProfile import com.auth0.android.util.Auth0UserAgent @@ -581,21 +575,6 @@ public class AuthenticationAPIClientTest { assertThat(exception.mfaRequiredErrorPayload?.mfaToken, Matchers.`is`("MFA_TOKEN")) } - private fun signupChallenge( - vararg required: PasskeyVerificationMethod - ): PasskeyRegistrationChallenge = PasskeyRegistrationChallenge( - "challenge-session", - AuthnParamsPublicKey( - AuthenticatorSelection("required", "preferred"), - "challenge", - listOf(PubKeyCredParam(-7, "public-key")), - RelyingParty("example.com", "example.com"), - 60000, - PasskeyUser("name", "id", "name") - ), - if (required.isEmpty()) null else required.toList() - ) - private fun assertClientVerificationError( exception: AuthenticationException, description: String @@ -609,18 +588,19 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldSigninWithPasskeyChallengeSendingRequiredCodes() { + public fun shouldSigninWithPasskeyVerificationRequiredSendingRequiredCodes() { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() val client = AuthenticationAPIClient(auth0) client.signinWithPasskey( - signupChallenge(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE), + "challenge-session", mock(), MY_CONNECTION, verification = mapOf( PasskeyVerificationMethod.EMAIL to "123456", PasskeyVerificationMethod.PHONE to "654321" - ) + ), + verificationRequired = listOf(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE) ).start(callback) ShadowLooper.idleMainLooper() val body = bodyFromRequest(mockAPI.takeRequest()) @@ -632,18 +612,19 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldSigninWithPasskeyChallengeSendingCodesThatAreNotRequired() { + public fun shouldSigninWithPasskeyVerificationRequiredSendingCodesThatAreNotRequired() { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() val client = AuthenticationAPIClient(auth0) client.signinWithPasskey( - signupChallenge(PasskeyVerificationMethod.EMAIL), + "challenge-session", mock(), MY_CONNECTION, verification = mapOf( PasskeyVerificationMethod.EMAIL to "123456", PasskeyVerificationMethod.PHONE to "654321" - ) + ), + verificationRequired = listOf(PasskeyVerificationMethod.EMAIL) ).start(callback) ShadowLooper.idleMainLooper() val body = bodyFromRequest(mockAPI.takeRequest()) @@ -654,15 +635,16 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldSigninWithPasskeyChallengeWithoutValidationWhenNoneIsRequired() { + public fun shouldSigninWithPasskeyVerificationRequiredWithoutValidationWhenNoneIsRequired() { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() val client = AuthenticationAPIClient(auth0) client.signinWithPasskey( - signupChallenge(), + "challenge-session", mock(), MY_CONNECTION, - verification = mapOf(PasskeyVerificationMethod.EMAIL to "123456") + verification = mapOf(PasskeyVerificationMethod.EMAIL to "123456"), + verificationRequired = listOf() ).start(callback) ShadowLooper.idleMainLooper() val body = bodyFromRequest(mockAPI.takeRequest()) @@ -673,56 +655,75 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldFailSigninWithPasskeyChallengeWhenNoCodesAreGiven() { + public fun shouldFailSigninWithPasskeyVerificationRequiredWhenNoCodesAreGiven() { val client = AuthenticationAPIClient(auth0) val exception = assertThrows(AuthenticationException::class.java) { client.signinWithPasskey( - signupChallenge(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE), + "challenge-session", mock(), - MY_CONNECTION + MY_CONNECTION, + verificationRequired = listOf(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE) ).execute() } assertClientVerificationError(exception, "Missing required parameter: verification.email") } @Test - public fun shouldFailSigninWithPasskeyChallengeWhenARequiredCodeIsMissing() { + public fun shouldFailSigninWithPasskeyVerificationRequiredWhenARequiredCodeIsMissing() { val client = AuthenticationAPIClient(auth0) val exception = assertThrows(AuthenticationException::class.java) { client.signinWithPasskey( - signupChallenge(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE), + "challenge-session", mock(), MY_CONNECTION, - verification = mapOf(PasskeyVerificationMethod.EMAIL to "3545") + verification = mapOf(PasskeyVerificationMethod.EMAIL to "3545"), + verificationRequired = listOf(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE) ).execute() } assertClientVerificationError(exception, "Missing required parameter: verification.phone") } @Test - public fun shouldFailSigninWithPasskeyChallengeWhenACodeIsEmpty() { + public fun shouldFailSigninWithPasskeyVerificationRequiredWhenACodeIsEmpty() { val client = AuthenticationAPIClient(auth0) val exception = assertThrows(AuthenticationException::class.java) { client.signinWithPasskey( - signupChallenge(PasskeyVerificationMethod.EMAIL), + "challenge-session", mock(), MY_CONNECTION, - verification = mapOf(PasskeyVerificationMethod.EMAIL to "") + verification = mapOf(PasskeyVerificationMethod.EMAIL to ""), + verificationRequired = listOf(PasskeyVerificationMethod.EMAIL) ).execute() } assertClientVerificationError(exception, "\"verification.email\" is not allowed to be empty") } @Test - public fun shouldSigninWithPasskeyChallengeSendingWhitespaceCodeToTheServer() { + public fun shouldFailSigninWithPasskeyStringResponseWhenARequiredCodeIsMissing() { + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "challenge-session", + "{}", + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "3545"), + verificationRequired = listOf(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE) + ).execute() + } + assertClientVerificationError(exception, "Missing required parameter: verification.phone") + } + + @Test + public fun shouldSigninWithPasskeyVerificationRequiredSendingWhitespaceCodeToTheServer() { mockAPI.willReturnSuccessfulLogin() val callback = MockAuthenticationCallback() val client = AuthenticationAPIClient(auth0) client.signinWithPasskey( - signupChallenge(PasskeyVerificationMethod.EMAIL), + "challenge-session", mock(), MY_CONNECTION, - verification = mapOf(PasskeyVerificationMethod.EMAIL to " ") + verification = mapOf(PasskeyVerificationMethod.EMAIL to " "), + verificationRequired = listOf(PasskeyVerificationMethod.EMAIL) ).start(callback) ShadowLooper.idleMainLooper() val body = bodyFromRequest(mockAPI.takeRequest()) @@ -732,17 +733,18 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldFailSigninWithPasskeyChallengeReportingTheFirstFailedIdentifier() { + public fun shouldFailSigninWithPasskeyVerificationRequiredReportingTheFirstFailedIdentifier() { val client = AuthenticationAPIClient(auth0) val exception = assertThrows(AuthenticationException::class.java) { client.signinWithPasskey( - signupChallenge(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE), + "challenge-session", mock(), MY_CONNECTION, verification = mapOf( PasskeyVerificationMethod.EMAIL to "", PasskeyVerificationMethod.PHONE to "" - ) + ), + verificationRequired = listOf(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE) ).execute() } assertClientVerificationError(exception, "\"verification.email\" is not allowed to be empty") diff --git a/examples/passkeys.md b/examples/passkeys.md index 48055b1d6..8cce0c609 100644 --- a/examples/passkeys.md +++ b/examples/passkeys.md @@ -109,11 +109,11 @@ 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 signup challenge and the collected codes to `signinWithPasskey(challenge, ...)` via the - `verification` map (identifier -> code). The SDK checks that every identifier in `verificationRequired` - has a non-empty code before sending the request. +2. Pass the collected codes to `signinWithPasskey` via the `verification` map (identifier -> code), and the + challenge's `verificationRequired` list via `verificationRequired`. The SDK checks that every identifier in + `verificationRequired` has a non-empty code before sending the request. 3. On a retryable error (`AuthenticationException.isPasskeyVerificationRetryable == true`), re-collect - the codes and call `signinWithPasskey` again **reusing the same challenge and `PublicKeyCredentials`** — + the codes and call `signinWithPasskey` again **reusing the same auth session and `PublicKeyCredentials`** — the biometric prompt will not reappear. A failed `signinWithPasskey` can be retried with the same credential when `isPasskeyVerificationRetryable` is @@ -160,18 +160,19 @@ try { while (true) { try { val userCredential = authenticationApiClient.signinWithPasskey( - challenge, + challenge.authSession, authRequest, "{realm}", "{organization-id}", - verification = codes + verification = codes, + verificationRequired = challenge.verificationRequired.orEmpty() ).validateClaims().await() break // success } catch (exception: AuthenticationException) { if (!exception.isPasskeyVerificationRetryable || ++attempts >= maxAttempts) { throw exception // terminal (or too many attempts) - restart registration } - // Re-collect the failed identifiers and retry with the SAME challenge and credential. + // Re-collect the failed identifiers and retry with the SAME auth session and credential. val failed = exception.passkeyVerificationRequired ?: challenge.verificationRequired.orEmpty() codes = codes + failed.associateWith { identifier -> promptUserForOtp(identifier) } } @@ -205,8 +206,10 @@ authenticationAPIClient.signupWithPasskey(userData, "{realm}", "{organization-id ((CreatePublicKeyCredentialResponse) createCredentialResponse).getRegistrationResponseJson(), PublicKeyCredentials.class); - authenticationAPIClient.signinWithPasskey(result, - credentials, "{realm}", "{organization-id}", verification) + List required = result.getVerificationRequired() != null + ? result.getVerificationRequired() : Collections.emptyList(); + authenticationAPIClient.signinWithPasskey(result.getAuthSession(), + credentials, "{realm}", "{organization-id}", verification, required) .start(new Callback() { @Override public void onSuccess(Credentials result) {} @@ -215,7 +218,7 @@ authenticationAPIClient.signupWithPasskey(userData, "{realm}", "{organization-id public void onFailure(@NonNull AuthenticationException error) { if (error.isPasskeyVerificationRetryable()) { // Re-collect the codes for error.getPasskeyVerificationRequired() and retry - // signinWithPasskey with the SAME challenge (result) and credentials. + // signinWithPasskey with the SAME auth session and credentials. } else { // Terminal — restart registration. } From 83316ef578ba1ea64b05bdb401e26d93cb24d23c Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 22 Oct 2026 11:10:20 +0530 Subject: [PATCH 16/19] refactor(passkey): reuse gson and move private helper to the bottom Co-Authored-By: Claude --- .../authentication/AuthenticationAPIClient.kt | 47 +++++++++---------- 1 file changed, 23 insertions(+), 24 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 60ba5b2d6..df2f72dc6 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -268,7 +268,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe val request = loginWithToken(params) .addParameter( AUTH_RESPONSE_KEY, - Gson().toJsonTree(authResponse) + gson.toJsonTree(authResponse) ) if (verification.isNotEmpty()) { request.addParameter( @@ -346,29 +346,6 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe } - /** - * Returns the error the server would report for the first identifier in [required] that has no code, or an empty - * code, in [verification]; `null` when all of them have a code. - */ - private fun passkeyVerificationError( - required: List, - verification: Map - ): AuthenticationException? { - for (identifier in required) { - val code = verification[identifier] - val description = when { - code == null -> "Missing required parameter: verification.${identifier.value}" - code.isEmpty() -> "\"verification.${identifier.value}\" is not allowed to be empty" - else -> continue - } - return AuthenticationException( - mapOf(ERROR_KEY to INVALID_REQUEST, ERROR_DESCRIPTION_KEY to description) - ) - } - return null - } - - /** * Sign-up a user and returns a challenge for private and public key generation. * The default scope used is 'openid profile email'. @@ -1101,6 +1078,28 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe return factory.get(url.toString(), userProfileAdapter, dPoP) } + /** + * Returns the error the server would report for the first identifier in [required] that has no code, or an empty + * code, in [verification]; `null` when all of them have a code. + */ + private fun passkeyVerificationError( + required: List, + verification: Map + ): AuthenticationException? { + for (identifier in required) { + val code = verification[identifier] + val description = when { + code == null -> "Missing required parameter: verification.${identifier.value}" + code.isEmpty() -> "\"verification.${identifier.value}\" is not allowed to be empty" + else -> continue + } + return AuthenticationException( + mapOf(ERROR_KEY to INVALID_REQUEST, ERROR_DESCRIPTION_KEY to description) + ) + } + return null + } + internal companion object { private const val SMS_CONNECTION = "sms" private const val EMAIL_CONNECTION = "email" From 7dd83afd21a29f2824559c3b161a8282153cb4a9 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 22 Oct 2026 12:28:38 +0530 Subject: [PATCH 17/19] fix(passkey): treat whitespace-only verification codes as empty Co-Authored-By: Claude --- .../authentication/AuthenticationAPIClient.kt | 8 +++--- .../AuthenticationAPIClientTest.kt | 26 ++++++++----------- examples/passkeys.md | 4 +-- 3 files changed, 17 insertions(+), 21 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 df2f72dc6..9e37c4646 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -243,7 +243,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * [PasskeyRegistrationChallenge.verificationRequired]. Defaults to an empty map when no identifier verification is * required; the `verification` object is then omitted from the request. * @param verificationRequired the identifiers that need a code, from - * [PasskeyRegistrationChallenge.verificationRequired]. When not empty, [verification] must contain a non-empty + * [PasskeyRegistrationChallenge.verificationRequired]. When not empty, [verification] must contain a non-blank * code for each of them; otherwise the request fails before any network call with an `invalid_request` * [AuthenticationException] whose description names the first identifier without a code. It is not sent to the * server. Defaults to an empty list, in which case nothing is validated. @@ -316,7 +316,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * [PasskeyRegistrationChallenge.verificationRequired]. Defaults to an empty map when no identifier verification is * required; the `verification` object is then omitted from the request. * @param verificationRequired the identifiers that need a code, from - * [PasskeyRegistrationChallenge.verificationRequired]. When not empty, [verification] must contain a non-empty + * [PasskeyRegistrationChallenge.verificationRequired]. When not empty, [verification] must contain a non-blank * code for each of them; otherwise the request fails before any network call with an `invalid_request` * [AuthenticationException] whose description names the first identifier without a code. It is not sent to the * server. Defaults to an empty list, in which case nothing is validated. @@ -1079,7 +1079,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe } /** - * Returns the error the server would report for the first identifier in [required] that has no code, or an empty + * Returns the error the server would report for the first identifier in [required] that has no code, or a blank * code, in [verification]; `null` when all of them have a code. */ private fun passkeyVerificationError( @@ -1090,7 +1090,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe val code = verification[identifier] val description = when { code == null -> "Missing required parameter: verification.${identifier.value}" - code.isEmpty() -> "\"verification.${identifier.value}\" is not allowed to be empty" + code.isBlank() -> "\"verification.${identifier.value}\" is not allowed to be empty" else -> continue } return AuthenticationException( 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 5a0bed72b..01a400313 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -714,22 +714,18 @@ public class AuthenticationAPIClientTest { } @Test - public fun shouldSigninWithPasskeyVerificationRequiredSendingWhitespaceCodeToTheServer() { - mockAPI.willReturnSuccessfulLogin() - val callback = MockAuthenticationCallback() + public fun shouldFailSigninWithPasskeyVerificationRequiredWhenACodeIsWhitespace() { val client = AuthenticationAPIClient(auth0) - client.signinWithPasskey( - "challenge-session", - mock(), - MY_CONNECTION, - verification = mapOf(PasskeyVerificationMethod.EMAIL to " "), - verificationRequired = listOf(PasskeyVerificationMethod.EMAIL) - ).start(callback) - ShadowLooper.idleMainLooper() - val body = bodyFromRequest(mockAPI.takeRequest()) - @Suppress("UNCHECKED_CAST") - val verification = body["verification"] as Map - assertThat(verification, Matchers.hasEntry("email", " ")) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "challenge-session", + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to " "), + verificationRequired = listOf(PasskeyVerificationMethod.EMAIL) + ).execute() + } + assertClientVerificationError(exception, "\"verification.email\" is not allowed to be empty") } @Test diff --git a/examples/passkeys.md b/examples/passkeys.md index 8cce0c609..18c5c3998 100644 --- a/examples/passkeys.md +++ b/examples/passkeys.md @@ -111,7 +111,7 @@ can be issued. It is `null` when no verification is required. You must: on retry. 2. Pass the collected codes to `signinWithPasskey` via the `verification` map (identifier -> code), and the challenge's `verificationRequired` list via `verificationRequired`. The SDK checks that every identifier in - `verificationRequired` has a non-empty code before sending the request. + `verificationRequired` has a non-blank code before sending the request. 3. On a retryable error (`AuthenticationException.isPasskeyVerificationRetryable == true`), re-collect the codes and call `signinWithPasskey` again **reusing the same auth session and `PublicKeyCredentials`** — the biometric prompt will not reappear. @@ -121,7 +121,7 @@ A failed `signinWithPasskey` can be retried with the same credential when `isPas `passkeyVerificationRequired` lists the identifiers whose code was wrong. Collect new codes for those identifiers and retry. -If the code for an identifier in `verificationRequired` is missing or empty, the SDK fails the request before +If the code for an identifier in `verificationRequired` is missing, empty or only whitespace, the SDK fails the request before sending it, with an `invalid_request` error and a `statusCode` of `0`. This error is not retryable. Any other error is terminal, so restart the registration from `signupWithPasskey`. From 418ebe7e9042aec44a1af299ecdf4e8fd19783b5 Mon Sep 17 00:00:00 2001 From: utkrishtS Date: Thu, 22 Oct 2026 18:58:05 +0530 Subject: [PATCH 18/19] Release 4.2.0 --- .version | 2 +- CHANGELOG.md | 10 ++++++++++ README.md | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.version b/.version index 99eba4de9..ef8d7569d 100644 --- a/.version +++ b/.version @@ -1 +1 @@ -4.1.0 \ No newline at end of file +4.2.0 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ebe491ed..e18ec113d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Change Log +## [4.2.0](https://github.com/auth0/Auth0.Android/tree/4.2.0) (2026-10-22) +[Full Changelog](https://github.com/auth0/Auth0.Android/compare/4.1.0...4.2.0) + +**Added** +- feat: add passkey identifier verification support [\#1087](https://github.com/auth0/Auth0.Android/pull/1087) ([utkrishtsahu](https://github.com/utkrishtsahu)) + +**Fixed** +- fix: stop reporting an unrecognized biometric as a final failure [\#1100](https://github.com/auth0/Auth0.Android/pull/1100) ([pmathew92](https://github.com/pmathew92)) +- fix(storage): authorize MGF1 digest to prevent INCOMPATIBLE_DEVICE on Keystore2 [\#1092](https://github.com/auth0/Auth0.Android/pull/1092) ([utkrishtsahu](https://github.com/utkrishtsahu)) + ## [3.21.2](https://github.com/auth0/Auth0.Android/tree/3.21.2) (2026-10-21) [Full Changelog](https://github.com/auth0/Auth0.Android/compare/3.21.1...3.21.2) diff --git a/README.md b/README.md index e6fc96e82..4bc684f88 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ To install Auth0.Android with [Gradle](https://gradle.org/), simply add the foll ```gradle dependencies { - implementation 'com.auth0.android:auth0:4.1.0' + implementation 'com.auth0.android:auth0:4.2.0' } ``` From d28b1eaaeab93091d36bc55053ae678b8d89c3e5 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri Date: Thu, 22 Oct 2026 18:48:09 +0300 Subject: [PATCH 19/19] fix: keep scope case when appending the openid scope (#1102) --- .../com/auth0/android/request/internal/OidcUtils.kt | 5 +---- .../android/authentication/ParameterBuilderTest.java | 12 ++++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/request/internal/OidcUtils.kt b/auth0/src/main/java/com/auth0/android/request/internal/OidcUtils.kt index bcb22f678..c7abfce9b 100644 --- a/auth0/src/main/java/com/auth0/android/request/internal/OidcUtils.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/OidcUtils.kt @@ -1,7 +1,5 @@ package com.auth0.android.request.internal -import java.util.* - /** * Small utility class to handle OpenID Connect conformance */ @@ -19,8 +17,7 @@ internal object OidcUtils { */ fun includeRequiredScope(scope: String): String { val existingScopes = scope.split(" ") - .map { it.toLowerCase(Locale.ROOT) } - return if (!existingScopes.contains(REQUIRED_SCOPE)) { + return if (existingScopes.none { it == REQUIRED_SCOPE }) { (existingScopes + REQUIRED_SCOPE).joinToString(separator = " ").trim() } else { scope diff --git a/auth0/src/test/java/com/auth0/android/authentication/ParameterBuilderTest.java b/auth0/src/test/java/com/auth0/android/authentication/ParameterBuilderTest.java index 9f771166c..d36da9438 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/ParameterBuilderTest.java +++ b/auth0/src/test/java/com/auth0/android/authentication/ParameterBuilderTest.java @@ -68,6 +68,18 @@ public void shouldSetRefreshToken() { assertThat(parameters, hasEntry("refresh_token", DEVICE)); } + @Test + public void shouldKeepScopeCaseWhenAddingRequiredScope() { + Map parameters = builder.setScope("profile read:Messages").asDictionary(); + assertThat(parameters, hasEntry("scope", "profile read:Messages openid")); + } + + @Test + public void shouldAddRequiredScopeWhenOnlyAnotherCaseIsPresent() { + Map parameters = builder.setScope("OpenID profile").asDictionary(); + assertThat(parameters, hasEntry("scope", "OpenID profile openid")); + } + @Test public void shouldSetScopeWithOfflineAccess() { Map parameters = builder.setScope(ParameterBuilder.SCOPE_OFFLINE_ACCESS).asDictionary();