diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0f1be80fd..ff8dd8e97 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' @@ -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@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: languages: ${{ matrix.language }} queries: +security-and-quality - name: Autobuild - uses: github/codeql-action/autobuild@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/autobuild@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 with: category: "/language:${{ matrix.language }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index a0df7dfa9..6a76b3a5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,28 @@ **Added** - feat(embedded): add /e/authorize email OTP flow [\#1075](https://github.com/auth0/Auth0.Android/pull/1075) ([pmathew92](https://github.com/pmathew92)) +## [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) + +**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) + +**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) @@ -13,6 +35,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) 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..9e37c4646 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,13 +238,25 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * @param authResponse the [PublicKeyCredentials] authentication response * @param realm the connection to use. If excluded, the application will use the default connection configured in the tenant * @param organization id of the organization to be associated with the user while signing in + * @param verification a map of identifier to the OTP code collected from the user, e.g. + * `mapOf(PasskeyVerificationMethod.EMAIL to "123456")`. The keys must match the identifiers returned in + * [PasskeyRegistrationChallenge.verificationRequired]. Defaults to an empty map when no identifier verification is + * required; the `verification` object is then omitted from the request. + * @param verificationRequired the identifiers that need a code, from + * [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. * @return a request to configure and start that will yield [Credentials] */ + @JvmOverloads public fun signinWithPasskey( authSession: String, authResponse: PublicKeyCredentials, realm: String? = null, organization: String? = null, + verification: Map = emptyMap(), + verificationRequired: List = emptyList(), ): AuthenticationRequest { val params = ParameterBuilder.newBuilder().apply { setGrantType(ParameterBuilder.GRANT_TYPE_PASSKEY) @@ -249,11 +265,25 @@ 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 + gson.toJsonTree(authResponse) + ) + if (verification.isNotEmpty()) { + request.addParameter( + VERIFICATION_KEY, + 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 } @@ -281,19 +311,38 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * @param authResponse the public key credential authentication response in JSON string format that follows the standard webauthn json format * @param realm the connection to use. If excluded, the application will use the default connection configured in the tenant * @param organization id of the organization to be associated with the user while signing in + * @param verification a map of identifier to the OTP code collected from the user, e.g. + * `mapOf(PasskeyVerificationMethod.EMAIL to "123456")`. The keys must match the identifiers returned in + * [PasskeyRegistrationChallenge.verificationRequired]. Defaults to an empty map when no identifier verification is + * required; the `verification` object is then omitted from the request. + * @param verificationRequired the identifiers that need a code, from + * [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. * @return a request to configure and start that will yield [Credentials] */ + @JvmOverloads public fun signinWithPasskey( authSession: String, authResponse: String, realm: String? = null, organization: String? = null, + verification: Map = emptyMap(), + verificationRequired: List = emptyList(), ): AuthenticationRequest { val publicKeyCredentials = gson.fromJson( authResponse, PublicKeyCredentials::class.java ) - return signinWithPasskey(authSession, publicKeyCredentials, realm, organization) + return signinWithPasskey( + authSession, + publicKeyCredentials, + realm, + organization, + verification, + verificationRequired + ) } @@ -325,12 +374,17 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe * @param userData user information for registration. * @param realm the connection to use. If excluded, the application will use the default connection configured in the tenant * @param organization id of the organization to be associated with the user while signing up + * @param deliveryMethod the channel used to deliver the phone OTP. Only applies when the connection requires phone + * identifier verification. If the requested channel is not enabled on the connection, the server responds with an + * `invalid_request` error. Pass `null` to use the connection default. * @return a request to configure and start that will yield [PasskeyRegistrationChallenge] */ + @JvmOverloads public fun signupWithPasskey( userData: UserData, realm: String? = null, - organization: String? = null + organization: String? = null, + deliveryMethod: DeliveryMethod? = null ): Request { val url = auth0.getDomainUrl().toHttpUrl().newBuilder() .addPathSegment(PASSKEY_PATH) @@ -351,6 +405,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 } @@ -1023,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 a blank + * 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.isBlank() -> "\"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" @@ -1042,6 +1119,11 @@ 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 ERROR_KEY = "error" + private const val ERROR_DESCRIPTION_KEY = "error_description" + private const val INVALID_REQUEST = "invalid_request" + 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..bc9ebfa47 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationException.kt @@ -8,6 +8,7 @@ import com.auth0.android.provider.TokenValidationException import com.auth0.android.result.MfaFactor import com.auth0.android.result.MfaRequiredErrorPayload import com.auth0.android.result.MfaRequirements +import com.auth0.android.result.PasskeyVerificationMethod public class AuthenticationException : Auth0Exception { private var code: String? = null @@ -200,6 +201,45 @@ public class AuthenticationException : Auth0Exception { public val isVerificationRequired: Boolean get() = "requires_verification" == code + /** + * Whether a failed passkey signup identifier verification can be retried without restarting + * the registration. `true` when the error is an `invalid_grant` that contains an + * `auth_session`, meaning a code was wrong and the session still has attempts left. + * + * When retryable, re-collect the codes for [passkeyVerificationRequired] and call + * [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey] again with the + * same credential and [passkeyAuthSession]. When `false`, restart the registration. + * + * @see passkeyAuthSession + * @see passkeyVerificationRequired + */ + public val isPasskeyVerificationRetryable: Boolean + get() = "invalid_grant" == code && passkeyAuthSession != null + + /** + * The auth session returned with a failed passkey verification, to be reused on the retry so + * the same passkey credential and session are kept. `null` when the error carries none. + * + * @see isPasskeyVerificationRetryable + */ + public val passkeyAuthSession: String? + get() = getValue(AUTH_SESSION_KEY) as? String + + /** + * The identifiers whose OTP code was wrong and must be re-collected before retrying. `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) return null + return (getValue(VERIFICATION_REQUIRED_KEY) as? List<*>) + ?.filterIsInstance() + ?.mapNotNull { value -> PasskeyVerificationMethod.values().firstOrNull { it.value == value } } + } + /// When the MFA Token used on the login request is malformed or has expired public val isMultifactorTokenInvalid: Boolean get() = "expired_token" == code && "mfa_token is expired" == description || @@ -267,6 +307,8 @@ public class AuthenticationException : Auth0Exception { internal const val ERROR_VALUE_CT_OPTIONS_INVALID = "Custom tab options are received as null from the intent" private const val ERROR_KEY = "error" + private const val AUTH_SESSION_KEY = "auth_session" + private const val VERIFICATION_REQUIRED_KEY = "verification_required" private const val CODE_KEY = "code" private const val DESCRIPTION_KEY = "description" private const val ERROR_DESCRIPTION_KEY = "error_description" diff --git a/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt b/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt index f4abbbb0e..8e396be5d 100644 --- a/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/passwordless/DeliveryMethod.kt @@ -3,8 +3,9 @@ package com.auth0.android.authentication.passwordless /** * Delivery method for a phone-number OTP challenge. * - * Maps to the `delivery_method` request parameter of `POST /otp/challenge`. [TEXT] sends the - * one-time code via SMS (the server default); [VOICE] delivers it through a voice call. + * Maps to the `delivery_method` request parameter of `POST /otp/challenge` and + * `POST /passkey/register`. [TEXT] sends the one-time code via SMS (the server default); + * [VOICE] delivers it through a voice call. * * @property value the wire value sent to the server. */ diff --git a/auth0/src/main/java/com/auth0/android/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/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/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/main/java/com/auth0/android/result/PasskeyRegistrationChallenge.kt b/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationChallenge.kt index e0b7d3c58..ef84a1e20 100644 --- a/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationChallenge.kt +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationChallenge.kt @@ -6,11 +6,20 @@ import com.google.gson.annotations.SerializedName /** * Represents a challenge when user tries to register via passkeys. */ -public data class PasskeyRegistrationChallenge( +public data class PasskeyRegistrationChallenge @JvmOverloads constructor( @SerializedName("auth_session") val authSession: String, @SerializedName("authn_params_public_key") - val authParamsPublicKey: AuthnParamsPublicKey + val authParamsPublicKey: AuthnParamsPublicKey, + /** + * The identifiers (e.g. `[EMAIL]` or `[EMAIL, PHONE]`) that require OTP verification + * before a credential can be created and tokens issued. `null` when the server did not + * request any identifier verification. The OTP codes collected for these identifiers must + * be passed to [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey] + * via the `verification` parameter. + */ + @SerializedName("verification_required") + val verificationRequired: List? = null ) public data class AuthnParamsPublicKey( diff --git a/auth0/src/main/java/com/auth0/android/result/PasskeyVerificationMethod.kt b/auth0/src/main/java/com/auth0/android/result/PasskeyVerificationMethod.kt new file mode 100644 index 000000000..ebe73d4eb --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyVerificationMethod.kt @@ -0,0 +1,28 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName + +/** + * An identifier whose ownership must be verified with a one-time code before a passkey signup + * can complete. + * + * Returned in [PasskeyRegistrationChallenge.verificationRequired] and + * [com.auth0.android.authentication.AuthenticationException.passkeyVerificationRequired]. Use it as + * the key when passing the collected codes in the `verification` map of + * [com.auth0.android.authentication.AuthenticationAPIClient.signinWithPasskey]. + * + * ```kotlin + * val verification = mapOf(PasskeyVerificationMethod.EMAIL to emailCode) + * ``` + * + * @property value the value used by the Auth0 Authentication API, e.g. `"email"`. + */ +public enum class PasskeyVerificationMethod(public val value: String) { + /** The user's email address. */ + @SerializedName("email") + EMAIL("email"), + + /** The user's phone number. */ + @SerializedName("phone") + PHONE("phone") +} diff --git a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt index e9a07b654..01a400313 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.res.Resources import com.auth0.android.Auth0 import com.auth0.android.authentication.ParameterBuilder.Companion.newBuilder +import com.auth0.android.authentication.passwordless.DeliveryMethod import com.auth0.android.authentication.request.ActorToken import com.auth0.android.dpop.DPoPException import com.auth0.android.dpop.DPoPKeyStore @@ -22,6 +23,7 @@ import com.auth0.android.request.internal.ThreadSwitcherShadow import com.auth0.android.result.Authentication import com.auth0.android.result.Credentials import com.auth0.android.result.DatabaseUser +import com.auth0.android.result.PasskeyVerificationMethod import com.auth0.android.result.SSOCredentials import com.auth0.android.result.UserProfile import com.auth0.android.util.Auth0UserAgent @@ -302,6 +304,463 @@ public class AuthenticationAPIClientTest { } + @Test + public fun shouldSignupWithPasskeyWithNullVerificationRequiredWhenAbsent() { + mockAPI.willReturnSuccessfulPasskeyRegistration() + val client = AuthenticationAPIClient(auth0) + val registrationResponse = client.signupWithPasskey( + UserData(email = "test@example.com"), + MY_CONNECTION + ).execute() + mockAPI.takeRequest() + assertThat(registrationResponse.verificationRequired, Matchers.nullValue()) + } + + @Test + public fun shouldSignupWithPasskeyReturningVerificationRequired() { + mockAPI.willReturnSuccessfulPasskeyRegistrationWithVerificationRequired("email") + val client = AuthenticationAPIClient(auth0) + val registrationResponse = client.signupWithPasskey( + UserData(email = "test@example.com"), + MY_CONNECTION + ).execute() + mockAPI.takeRequest() + assertThat( + registrationResponse.verificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL) + ) + } + + @Test + public fun shouldSignupWithPasskeyReturningMultipleVerificationRequired() { + mockAPI.willReturnSuccessfulPasskeyRegistrationWithVerificationRequired("email", "phone") + val client = AuthenticationAPIClient(auth0) + val registrationResponse = client.signupWithPasskey( + UserData(email = "test@example.com"), + MY_CONNECTION + ).execute() + mockAPI.takeRequest() + assertThat( + registrationResponse.verificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE) + ) + } + + @Test + public fun shouldSignupWithPasskeyForwardingDeliveryMethod() { + mockAPI.willReturnSuccessfulPasskeyRegistration() + val client = AuthenticationAPIClient(auth0) + client.signupWithPasskey( + UserData(phoneNumber = "+1234567890"), + MY_CONNECTION, + deliveryMethod = DeliveryMethod.VOICE + ).execute() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("delivery_method", "voice")) + } + + @Test + public fun shouldSignupWithPasskeyOmittingDeliveryMethodWhenNull() { + mockAPI.willReturnSuccessfulPasskeyRegistration() + val client = AuthenticationAPIClient(auth0) + client.signupWithPasskey( + UserData(email = "test@example.com"), + MY_CONNECTION + ).execute() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.not(Matchers.hasKey("delivery_method"))) + } + + @Test + public fun shouldSigninWithPasskeyForwardingVerification() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf( + PasskeyVerificationMethod.EMAIL to "123456", + PasskeyVerificationMethod.PHONE to "654321" + ) + ).start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasKey("verification")) + @Suppress("UNCHECKED_CAST") + val verification = body["verification"] as Map + assertThat(verification, Matchers.hasEntry("email", "123456")) + assertThat(verification, Matchers.hasEntry("phone", "654321")) + } + + @Test + public fun shouldSigninWithPasskeyOmittingVerificationByDefault() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION + ).start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.not(Matchers.hasKey("verification"))) + } + + @Test + public fun shouldSigninWithPasskeyOmittingEmptyVerification() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = emptyMap() + ).start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.not(Matchers.hasKey("verification"))) + } + + @Test + public fun shouldSigninWithPasskeySendingBlankVerificationCodeToTheServer() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf( + PasskeyVerificationMethod.EMAIL to "123456", + PasskeyVerificationMethod.PHONE to "" + ) + ).start(callback) + ShadowLooper.idleMainLooper() + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + @Suppress("UNCHECKED_CAST") + val verification = body["verification"] as Map + assertThat(verification, Matchers.hasEntry("email", "123456")) + assertThat(verification, Matchers.hasEntry("phone", "")) + } + + @Test + public fun shouldClassifyRetryablePasskeyVerificationError() { + mockAPI.willReturnPasskeyVerificationRetryableError("email") + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "000000") + ).execute() + } + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(true)) + assertThat(exception.passkeyAuthSession, Matchers.`is`(SESSION_ID)) + assertThat( + exception.passkeyVerificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL) + ) + } + + @Test + public fun shouldClassifyTerminalPasskeyVerificationError() { + mockAPI.willReturnPasskeyVerificationTerminalError() + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "000000") + ).execute() + } + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + assertThat(exception.passkeyAuthSession, Matchers.nullValue()) + assertThat(exception.passkeyVerificationRequired, Matchers.nullValue()) + } + + @Test + public fun shouldNotClassifyInvalidRequestWithSessionAsRetryable() { + mockAPI.willReturnPasskeyVerificationInvalidRequestError() + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION + ).execute() + } + assertThat(exception.getCode(), Matchers.`is`("invalid_request")) + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + assertThat(exception.passkeyAuthSession, Matchers.`is`(SESSION_ID)) + assertThat(exception.passkeyVerificationRequired, Matchers.nullValue()) + } + + @Test + public fun shouldNotClassifyInvalidRequestWithoutSessionAsRetryable() { + mockAPI.willReturnInvalidRequestWithoutSessionError() + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION + ).execute() + } + assertThat(exception.getCode(), Matchers.`is`("invalid_request")) + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + assertThat(exception.passkeyAuthSession, Matchers.nullValue()) + assertThat(exception.passkeyVerificationRequired, Matchers.nullValue()) + } + + @Test + public fun shouldSkipUnknownIdentifiersInPasskeyVerificationRequired() { + mockAPI.willReturnPasskeyVerificationRetryableError("email", "username") + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "000000") + ).execute() + } + assertThat( + exception.passkeyVerificationRequired, + Matchers.contains(PasskeyVerificationMethod.EMAIL) + ) + } + + @Test + public fun shouldClassifyConsumedSessionPasskeyVerificationErrorAsTerminal() { + mockAPI.willReturnPasskeyVerificationConsumedSessionError() + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "123456") + ).execute() + } + assertThat(exception.statusCode, Matchers.`is`(403)) + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + } + + @Test + public fun shouldNotClassifyMfaRequiredOnPasskeyAsVerificationRetryable() { + mockAPI.willReturnMfaRequiredError() + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "auth-session", + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "123456") + ).execute() + } + assertThat(exception.isMultifactorRequired, Matchers.`is`(true)) + assertThat(exception.isPasskeyVerificationRetryable, Matchers.`is`(false)) + assertThat(exception.mfaRequiredErrorPayload?.mfaToken, Matchers.`is`("MFA_TOKEN")) + } + + private fun assertClientVerificationError( + exception: AuthenticationException, + 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.nullValue()) + assertThat(mockAPI.server.requestCount, Matchers.`is`(0)) + } + + @Test + public fun shouldSigninWithPasskeyVerificationRequiredSendingRequiredCodes() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + "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()) + 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 shouldSigninWithPasskeyVerificationRequiredSendingCodesThatAreNotRequired() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + "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()) + @Suppress("UNCHECKED_CAST") + val verification = body["verification"] as Map + assertThat(verification, Matchers.hasEntry("email", "123456")) + assertThat(verification, Matchers.hasEntry("phone", "654321")) + } + + @Test + public fun shouldSigninWithPasskeyVerificationRequiredWithoutValidationWhenNoneIsRequired() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey( + "challenge-session", + mock(), + MY_CONNECTION, + verification = mapOf(PasskeyVerificationMethod.EMAIL to "123456"), + verificationRequired = listOf() + ).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")) + } + + @Test + public fun shouldFailSigninWithPasskeyVerificationRequiredWhenNoCodesAreGiven() { + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "challenge-session", + mock(), + MY_CONNECTION, + verificationRequired = listOf(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE) + ).execute() + } + assertClientVerificationError(exception, "Missing required parameter: verification.email") + } + + @Test + public fun shouldFailSigninWithPasskeyVerificationRequiredWhenARequiredCodeIsMissing() { + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "challenge-session", + mock(), + 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 shouldFailSigninWithPasskeyVerificationRequiredWhenACodeIsEmpty() { + val client = AuthenticationAPIClient(auth0) + 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 + 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 shouldFailSigninWithPasskeyVerificationRequiredWhenACodeIsWhitespace() { + val client = AuthenticationAPIClient(auth0) + 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 + public fun shouldFailSigninWithPasskeyVerificationRequiredReportingTheFirstFailedIdentifier() { + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signinWithPasskey( + "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") + } + + @Test + public fun shouldSurface429PlainTextRateLimitOnPasskeyRegister() { + mockAPI.willReturnTooManyRequestsPlainText() + val client = AuthenticationAPIClient(auth0) + val exception = assertThrows(AuthenticationException::class.java) { + client.signupWithPasskey( + UserData(email = "test@example.com"), + MY_CONNECTION + ).execute() + } + assertThat(exception.statusCode, Matchers.`is`(429)) + assertThat(exception.getDescription(), Matchers.`is`("Too Many Requests")) + assertThat(exception.isTooManyAttempts, Matchers.`is`(false)) + } + @Test public fun shouldLoginWithUserAndPasswordSync() { val jwt = JwtTestUtils.createTestJWT("HS256", mapOf("sub" to "auth0|123456")) diff --git a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt index caa89b3a2..8a0551374 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationExceptionTest.kt @@ -3,6 +3,7 @@ package com.auth0.android.authentication import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.request.internal.GsonProvider +import com.auth0.android.result.PasskeyVerificationMethod import com.google.gson.reflect.TypeToken import org.hamcrest.CoreMatchers import org.hamcrest.MatcherAssert @@ -532,6 +533,55 @@ public class AuthenticationExceptionTest { MatcherAssert.assertThat(ex.isTooManyAttempts, CoreMatchers.`is`(true)) } + @Test + public fun shouldBeRetryablePasskeyVerificationForInvalidGrantWithSession() { + values[ERROR_KEY] = "invalid_grant" + values["auth_session"] = "session" + values["verification_required"] = listOf("email", "phone") + val ex = AuthenticationException(values, 400) + MatcherAssert.assertThat(ex.isPasskeyVerificationRetryable, CoreMatchers.`is`(true)) + MatcherAssert.assertThat(ex.passkeyAuthSession, CoreMatchers.`is`("session")) + MatcherAssert.assertThat( + ex.passkeyVerificationRequired, + CoreMatchers.`is`(listOf(PasskeyVerificationMethod.EMAIL, PasskeyVerificationMethod.PHONE)) + ) + } + + @Test + public fun shouldNotBeRetryablePasskeyVerificationForInvalidRequestWithSession() { + values[ERROR_KEY] = "invalid_request" + values["auth_session"] = "session" + val ex = AuthenticationException(values, 400) + MatcherAssert.assertThat(ex.isPasskeyVerificationRetryable, CoreMatchers.`is`(false)) + 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`(false)) + 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/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(); 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 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", 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..8736b16a5 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,108 @@ internal class AuthenticationAPIMockServer : APIMockServer() { return this } + fun willReturnSuccessfulPasskeyRegistrationWithVerificationRequired( + vararg identifiers: String + ): AuthenticationAPIMockServer { + val verificationRequired = identifiers.joinToString(prefix = "[", postfix = "]") { "\"$it\"" } + val json = """{ + "authn_params_public_key":{ + "challenge": "$CHALLENGE", + "timeout": 900000, + "rp": { + "id": "auth0.passkey.com", + "name": "Passkey Test" + }, + "pubKeyCredParams": [ + { + "type": "public-key", + "alg": -7 + } + ], + "authenticatorSelection": { + "authenticatorAttachment": "platform", + "residentKey": "required" + }, + "user": { + "id": "53b995f8bce68d9fc900099c", + "name": "p", + "displayName": "d" + } + }, + "auth_session": "$SESSION_ID", + "verification_required": $verificationRequired + }""" + server.enqueue(responseWithJSON(json, 200)) + return this + } + + fun willReturnPasskeyVerificationRetryableError( + vararg identifiers: String + ): AuthenticationAPIMockServer { + val verificationRequired = identifiers.joinToString(prefix = "[", postfix = "]") { "\"$it\"" } + val json = """{ + "error": "invalid_grant", + "error_description": "Invalid or expired session", + "auth_session": "$SESSION_ID", + "verification_required": $verificationRequired + }""" + server.enqueue(responseWithJSON(json, 400)) + return this + } + + fun willReturnPasskeyVerificationTerminalError(): AuthenticationAPIMockServer { + val json = """{ + "error": "invalid_grant", + "error_description": "Invalid or expired session" + }""" + server.enqueue(responseWithJSON(json, 400)) + return this + } + + fun willReturnPasskeyVerificationInvalidRequestError(): AuthenticationAPIMockServer { + val json = """{ + "error": "invalid_request", + "error_description": "Missing required parameter: verification.email", + "auth_session": "$SESSION_ID", + "verification_required": ["email"] + }""" + server.enqueue(responseWithJSON(json, 400)) + return this + } + + fun willReturnInvalidRequestWithoutSessionError(): AuthenticationAPIMockServer { + val json = """{ + "error": "invalid_request", + "error_description": "Invalid realm" + }""" + server.enqueue(responseWithJSON(json, 400)) + return this + } + + fun willReturnMfaRequiredError(): AuthenticationAPIMockServer { + val json = """{ + "error": "mfa_required", + "error_description": "Multifactor authentication required", + "mfa_token": "MFA_TOKEN" + }""" + server.enqueue(responseWithJSON(json, 403)) + return this + } + + fun willReturnPasskeyVerificationConsumedSessionError(): AuthenticationAPIMockServer { + val json = """{ + "error": "invalid_grant", + "error_description": "Invalid or expired session" + }""" + server.enqueue(responseWithJSON(json, 403)) + return this + } + + fun willReturnTooManyRequestsPlainText(): AuthenticationAPIMockServer { + server.enqueue(responseWithPlainText("Too Many Requests", 429)) + return this + } + private fun responseEmpty(statusCode: Int): MockResponse { return MockResponse() .setResponseCode(statusCode) diff --git a/examples/passkeys.md b/examples/passkeys.md index 0ab11a1e6..18c5c3998 100644 --- a/examples/passkeys.md +++ b/examples/passkeys.md @@ -98,6 +98,144 @@ try { ``` +### Passkey signup with identifier verification + +When the database connection has verification enabled for email and phone, the `signupWithPasskey` +response includes a `verificationRequired` list naming the identifiers +(`PasskeyVerificationMethod.EMAIL`, `PasskeyVerificationMethod.PHONE`) that must be verified before tokens +can be issued. It is `null` when no verification is required. You must: + +1. Collect the OTP code for every identifier in `verificationRequired` **before** creating the + platform credential with `CredentialManager.createCredential(...)`. Creating the credential first + and then failing verification leaves an orphaned local passkey and shows a second biometric prompt + on retry. +2. Pass the collected codes to `signinWithPasskey` via the `verification` map (identifier -> code), and the + challenge's `verificationRequired` list via `verificationRequired`. The SDK checks that every identifier in + `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. + +A failed `signinWithPasskey` can be retried with the same credential when `isPasskeyVerificationRetryable` is +`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. + +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`. + +Optionally pass `deliveryMethod` (`DeliveryMethod.TEXT` or `DeliveryMethod.VOICE`) to +`signupWithPasskey` to choose the phone OTP channel. + +```kotlin +// Using Coroutines +try { + val userData = UserData(email = "user@example.com") + + val challenge = authenticationApiClient.signupWithPasskey( + userData, + "{realm}", + "{organization-id}", + deliveryMethod = DeliveryMethod.TEXT // optional, phone OTP channel + ).await() + + // Collect OTP codes BEFORE creating the credential. + var codes: Map = challenge.verificationRequired.orEmpty() + .associateWith { identifier -> promptUserForOtp(identifier) } // app-defined + + // Now create the platform credential. + val request = CreatePublicKeyCredentialRequest( + Gson().toJson(challenge.authParamsPublicKey) + ) + val result = credentialManager.createCredential(requireContext(), request) + val authRequest = Gson().fromJson( + (result as CreatePublicKeyCredentialResponse).registrationResponseJson, + PublicKeyCredentials::class.java + ) + + val maxAttempts = 3 // retry limit; adjust it to suit your app + var attempts = 0 + while (true) { + try { + val userCredential = authenticationApiClient.signinWithPasskey( + challenge.authSession, + authRequest, + "{realm}", + "{organization-id}", + 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 auth session and credential. + val failed = exception.passkeyVerificationRequired ?: challenge.verificationRequired.orEmpty() + codes = codes + failed.associateWith { identifier -> promptUserForOtp(identifier) } + } + } +} catch (e: CreateCredentialException) { + // The passkey could not be created or the user cancelled: show an error and let the user try again. +} catch (exception: AuthenticationException) { + // Verification failed for good or the retries ran out: show the error and restart the registration. +} +``` +
+ Using Java + +```java +UserData userData = new UserData("user@example.com", null, null, null, null, null, null, null, null); + +authenticationAPIClient.signupWithPasskey(userData, "{realm}", "{organization-id}", DeliveryMethod.TEXT) + .start(new Callback() { + @Override + public void onSuccess(PasskeyRegistrationChallenge result) { + // Collect OTP codes for result.getVerificationRequired() BEFORE creating the credential. + Map verification = collectOtpCodes(result.getVerificationRequired()); // app-defined + + CreateCredentialRequest request = + new CreatePublicKeyCredentialRequest(new Gson().toJson(result.getAuthParamsPublicKey())); + credentialManager.createCredentialAsync(getContext(), request, cancellationSignal, , + new CredentialManagerCallback() { + @Override + public void onResult(CreateCredentialResponse createCredentialResponse) { + PublicKeyCredentials credentials = new Gson().fromJson( + ((CreatePublicKeyCredentialResponse) createCredentialResponse).getRegistrationResponseJson(), + PublicKeyCredentials.class); + + 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) {} + + @Override + public void onFailure(@NonNull AuthenticationException error) { + if (error.isPasskeyVerificationRetryable()) { + // Re-collect the codes for error.getPasskeyVerificationRequired() and retry + // signinWithPasskey with the SAME auth session and 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