From 04ae2ef373c6287a5f03442a2b0a2dcee3326ff2 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 30 Oct 2024 17:48:05 +0530 Subject: [PATCH 1/4] Adding passkey samples in the example.md file --- EXAMPLES.md | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 1ef2da82..f8cd6517 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -22,6 +22,7 @@ - [Usage](#usage) - [Requiring Authentication](#requiring-authentication) - [Handling Credentials Manager exceptions](#handling-credentials-manager-exceptions) + - [Passkeys](#passkeys) - [Bot Protection](#bot-protection) - [Management API](#management-api) - [Link users](#link-users) @@ -131,7 +132,7 @@ WebAuthProvider.logout(account) .start(this, logoutCallback) ``` -## Trusted Web Activity (Experimental) +## Trusted Web Activity > **Warning** > Trusted Web Activity support in Auth0.Android is still experimental and can change in the future. > @@ -607,6 +608,75 @@ when(credentialsManagerException) { } ``` +## Passkeys +User should have a custom domain configured and passkey grant-type enabled in the Auth0 dashboard to use passkeys. + +To sign up a user with passkey + +```kotlin +PasskeyAuthProvider.signUp(account) + .setEmail("user email") + .setUserName("user name") + .setPhoneNumber("phone number") + .setRealm("optional connection name") + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { } + + override fun onSuccess(credentials: Credentials) { } + }) +``` +
+ Using Java + +```java +PasskeyAuthProvider authProvider = new PasskeyAuthProvider(); +authProvider.signUp(account) + .setEmail("user email") + .setUserName("user name") + .setPhoneNumber("phone number") + .setRealm("optional connection name") + .start(new Callback() { + @Override + public void onFailure(@NonNull AuthenticationException exception) { } + + @Override + public void onSuccess(@Nullable Credentials credentials) { } + }); +``` +
+ +To sign in a user with passkey +```kotlin +PasskeyAuthProvider.signin(account) + .setRealm("Optional connection name") + .start(object: Callback { + override fun onFailure(exception: AuthenticationException) { } + + override fun onSuccess(credentials: Credentials) { } + }) +``` +
+ Using Java + +```java +PasskeyAuthProvider authProvider = new PasskeyAuthProvider(); +authProvider.signin(account) + .setRealm("optional connection name") + .start(new Callback() { + @Override + public void onFailure(@NonNull AuthenticationException exception) { } + + @Override + public void onSuccess(@Nullable Credentials credentials) { } + }); +``` +
+ +**Points to be Noted**: +Passkeys are supported only on devices that run Android 9 (API level 28) or higher. +To use passkeys ,user needs to add support for Digital Asset Links. + + ## Bot Protection If you are using the [Bot Protection](https://auth0.com/docs/anomaly-detection/bot-protection) feature and performing database login/signup via the Authentication API, you need to handle the `AuthenticationException#isVerificationRequired()` error. It indicates that the request was flagged as suspicious and an additional verification step is necessary to log the user in. That verification step is web-based, so you need to use Universal Login to complete it. From e5176fc099ebefa16d46c0306feb4934c45fbbdb Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 30 Oct 2024 19:30:15 +0530 Subject: [PATCH 2/4] Reverting passkey merge revert This reverts commit 0e06b8c27679262e7c746e974feba87aba51ee56. --- EXAMPLES.md | 3 +- auth0/build.gradle | 12 +- .../authentication/AuthenticationAPIClient.kt | 139 ++++++- .../authentication/ParameterBuilder.kt | 1 + .../android/provider/PasskeyAuthProvider.kt | 224 +++++++++++ .../auth0/android/provider/PasskeyManager.kt | 239 ++++++++++++ .../auth0/android/provider/WebAuthProvider.kt | 8 +- .../request/PublicKeyCredentialRequest.kt | 45 +++ .../java/com/auth0/android/request/Request.kt | 12 + .../android/request/UserMetadataRequest.kt | 13 + .../internal/BaseAuthenticationRequest.kt | 23 +- .../android/request/internal/BaseRequest.kt | 2 +- .../result/PasskeyChallengeResponse.kt | 22 ++ .../result/PasskeyRegistrationResponse.kt | 56 +++ .../AuthenticationAPIClientTest.kt | 97 ++++- .../request/AuthenticationRequestMock.java | 7 + .../authentication/request/RequestMock.java | 6 + .../android/provider/PasskeyManagerTest.kt | 362 ++++++++++++++++++ .../util/AuthenticationAPIMockServer.kt | 51 +++ proguard/proguard-jetpack.pro | 6 + sample/build.gradle | 4 +- .../com/auth0/sample/DatabaseLoginFragment.kt | 55 +++ .../res/layout/fragment_database_login.xml | 39 ++ sample/src/main/res/values/strings.xml | 4 +- 24 files changed, 1386 insertions(+), 44 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/provider/PasskeyAuthProvider.kt create mode 100644 auth0/src/main/java/com/auth0/android/provider/PasskeyManager.kt create mode 100644 auth0/src/main/java/com/auth0/android/request/PublicKeyCredentialRequest.kt create mode 100644 auth0/src/main/java/com/auth0/android/request/UserMetadataRequest.kt create mode 100644 auth0/src/main/java/com/auth0/android/result/PasskeyChallengeResponse.kt create mode 100644 auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationResponse.kt create mode 100644 auth0/src/test/java/com/auth0/android/provider/PasskeyManagerTest.kt create mode 100644 proguard/proguard-jetpack.pro diff --git a/EXAMPLES.md b/EXAMPLES.md index 78c59514..1ef2da82 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1242,4 +1242,5 @@ You might encounter errors similar to `PKIX path building failed: sun.security.p The rules should be applied automatically if your application is using `minifyEnabled = true`. If you want to include them manually check the [proguard directory](proguard). By default you should at least use the following files: * `proguard-okio.pro` -* `proguard-gson.pro` \ No newline at end of file +* `proguard-gson.pro` +* `proguard-jetpack.pro` \ No newline at end of file diff --git a/auth0/build.gradle b/auth0/build.gradle index 22897333..454560ae 100644 --- a/auth0/build.gradle +++ b/auth0/build.gradle @@ -34,18 +34,18 @@ version = getVersionFromFile() logger.lifecycle("Using version ${version} for ${name}") android { - compileSdkVersion 31 + compileSdkVersion 34 defaultConfig { minSdkVersion 21 - targetSdkVersion 31 + targetSdkVersion 34 versionCode 1 versionName project.version buildConfigField "String", "LIBRARY_NAME", "\"$project.rootProject.name\"" buildConfigField "String", "VERSION_NAME", "\"${project.version}\"" - consumerProguardFiles '../proguard/proguard-gson.pro', '../proguard/proguard-okio.pro' + consumerProguardFiles '../proguard/proguard-gson.pro', '../proguard/proguard-okio.pro', '../proguard/proguard-jetpack.pro' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } lintOptions { @@ -77,13 +77,14 @@ ext { powermockVersion = '2.0.9' coroutinesVersion = '1.6.2' biometricLibraryVersion = '1.1.0' + credentialManagerVersion = "1.3.0" } dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" implementation 'androidx.core:core-ktx:1.6.0' - implementation 'androidx.appcompat:appcompat:1.3.0' + implementation 'androidx.appcompat:appcompat:1.6.0' implementation 'androidx.browser:browser:1.4.0' implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" implementation "com.squareup.okhttp3:okhttp:$okhttpVersion" @@ -110,6 +111,9 @@ dependencies { testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" testImplementation "androidx.biometric:biometric:$biometricLibraryVersion" + + implementation "androidx.credentials:credentials-play-services-auth:$credentialManagerVersion" + implementation "androidx.credentials:credentials:$credentialManagerVersion" } apply from: rootProject.file('gradle/jacoco.gradle') 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 ecac1791..bf72e201 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -12,6 +12,8 @@ import com.auth0.android.request.internal.ResponseUtils.isNetworkError import com.auth0.android.result.Challenge import com.auth0.android.result.Credentials import com.auth0.android.result.DatabaseUser +import com.auth0.android.result.PasskeyChallengeResponse +import com.auth0.android.result.PasskeyRegistrationResponse import com.auth0.android.result.UserProfile import com.google.gson.Gson import okhttp3.HttpUrl.Companion.toHttpUrl @@ -151,6 +153,102 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe return loginWithToken(parameters) } + + /** + * Log in a user using passkeys. + * This should be called after the client has received the Passkey challenge and Auth-session from the server . + * 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. + * + * @param authSession the auth session received from the server as part of the public challenge request. + * @param authResponse the public key credential response to be sent to the server + * @param parameters additional parameters to be sent as part of the request + * @return a request to configure and start that will yield [Credentials] + */ + internal fun signinWithPasskey( + authSession: String, + authResponse: PublicKeyCredentialResponse, + parameters: Map + ): AuthenticationRequest { + val params = ParameterBuilder.newBuilder().apply { + setGrantType(ParameterBuilder.GRANT_TYPE_PASSKEY) + set(AUTH_SESSION_KEY, authSession) + addAll(parameters) + }.asDictionary() + + return loginWithToken(params) + .addParameter( + AUTH_RESPONSE_KEY, + Gson().toJsonTree(authResponse) + ) as AuthenticationRequest + } + + + /** + * Register a user and returns a challenge. + * 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. + * + * @param userMetadata user information of the client + * @param parameters additional parameter to be sent as part of the request + * @return a request to configure and start that will yield [PasskeyRegistrationResponse] + */ + internal fun signupWithPasskey( + userMetadata: UserMetadataRequest, + parameters: Map, + ): Request { + val user = Gson().toJsonTree(userMetadata) + val url = auth0.getDomainUrl().toHttpUrl().newBuilder() + .addPathSegment(PASSKEY_PATH) + .addPathSegment(REGISTER_PATH) + .build() + + val params = ParameterBuilder.newBuilder().apply { + setClientId(clientId) + parameters[ParameterBuilder.REALM_KEY]?.let { + setRealm(it) + } + }.asDictionary() + + val passkeyRegistrationAdapter: JsonAdapter = GsonAdapter( + PasskeyRegistrationResponse::class.java, gson + ) + val post = factory.post(url.toString(), passkeyRegistrationAdapter) + .addParameters(params) as BaseRequest + post.addParameter(USER_PROFILE_KEY, user) + return post + } + + + /** + * Request for a challenge to initiate a passkey login flow + * 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. + * + * @param realm An optional connection name + * @return a request to configure and start that will yield [PasskeyChallengeResponse] + */ + internal fun passkeyChallenge( + realm: String? + ): Request { + val url = auth0.getDomainUrl().toHttpUrl().newBuilder() + .addPathSegment(PASSKEY_PATH) + .addPathSegment(CHALLENGE_PATH) + .build() + + val parameters = ParameterBuilder.newBuilder().apply { + setClientId(clientId) + realm?.let { setRealm(it) } + }.asDictionary() + + val passkeyChallengeAdapter: JsonAdapter = GsonAdapter( + PasskeyChallengeResponse::class.java, gson + ) + + return factory.post(url.toString(), passkeyChallengeAdapter) + .addParameters(parameters) + } + /** * Log in a user using an Out Of Band authentication code after they have received the 'mfa_required' error. * The MFA token tells the server the username or email, password, and realm values sent on the first request. @@ -695,8 +793,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe val parameters = ParameterBuilder.newBuilder() .setClientId(clientId) .setGrantType(ParameterBuilder.GRANT_TYPE_AUTHORIZATION_CODE) - .set(OAUTH_CODE_KEY, authorizationCode) - .set(REDIRECT_URI_KEY, redirectUri) + .set(OAUTH_CODE_KEY, authorizationCode).set(REDIRECT_URI_KEY, redirectUri) .set("code_verifier", codeVerifier) .asDictionary() val url = auth0.getDomainUrl().toHttpUrl().newBuilder() @@ -736,26 +833,26 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe .addPathSegment(OAUTH_PATH) .addPathSegment(TOKEN_PATH) .build() - val requestParameters = ParameterBuilder.newBuilder() - .setClientId(clientId) - .addAll(parameters) - .asDictionary() + val requestParameters = + ParameterBuilder.newBuilder() + .setClientId(clientId) + .addAll(parameters) + .asDictionary() val credentialsAdapter: JsonAdapter = GsonAdapter( Credentials::class.java, gson ) val request = BaseAuthenticationRequest( - factory.post(url.toString(), credentialsAdapter), - clientId, - baseURL + factory.post(url.toString(), credentialsAdapter), clientId, baseURL ) request.addParameters(requestParameters) return request } private fun profileRequest(): Request { - val url = auth0.getDomainUrl().toHttpUrl().newBuilder() - .addPathSegment(USER_INFO_PATH) - .build() + val url = + auth0.getDomainUrl().toHttpUrl().newBuilder() + .addPathSegment(USER_INFO_PATH) + .build() val userProfileAdapter: JsonAdapter = GsonAdapter( UserProfile::class.java, gson ) @@ -782,6 +879,9 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private const val SUBJECT_TOKEN_KEY = "subject_token" private const val SUBJECT_TOKEN_TYPE_KEY = "subject_token_type" 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 USER_PROFILE_KEY = "user_profile" private const val SIGN_UP_PATH = "signup" private const val DB_CONNECTIONS_PATH = "dbconnections" private const val CHANGE_PASSWORD_PATH = "change_password" @@ -793,6 +893,8 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe private const val REVOKE_PATH = "revoke" private const val MFA_PATH = "mfa" private const val CHALLENGE_PATH = "challenge" + private const val PASSKEY_PATH = "passkey" + private const val REGISTER_PATH = "register" private const val HEADER_AUTHORIZATION = "Authorization" private const val WELL_KNOWN_PATH = ".well-known" private const val JWKS_FILE_PATH = "jwks.json" @@ -800,17 +902,14 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe val mapAdapter = forMap(GsonProvider.gson) return object : ErrorAdapter { override fun fromRawResponse( - statusCode: Int, - bodyText: String, - headers: Map> + statusCode: Int, bodyText: String, headers: Map> ): AuthenticationException { return AuthenticationException(bodyText, statusCode) } @Throws(IOException::class) override fun fromJsonResponse( - statusCode: Int, - reader: Reader + statusCode: Int, reader: Reader ): AuthenticationException { val values = mapAdapter.fromJson(reader) return AuthenticationException(values, statusCode) @@ -819,13 +918,11 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe override fun fromException(cause: Throwable): AuthenticationException { if (isNetworkError(cause)) { return AuthenticationException( - "Failed to execute the network request", - NetworkErrorException(cause) + "Failed to execute the network request", NetworkErrorException(cause) ) } return AuthenticationException( - "Something went wrong", - Auth0Exception("Something went wrong", cause) + "Something went wrong", Auth0Exception("Something went wrong", cause) ) } } diff --git a/auth0/src/main/java/com/auth0/android/authentication/ParameterBuilder.kt b/auth0/src/main/java/com/auth0/android/authentication/ParameterBuilder.kt index 3364e111..511334f5 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/ParameterBuilder.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/ParameterBuilder.kt @@ -159,6 +159,7 @@ public class ParameterBuilder private constructor(parameters: Map = mutableMapOf() + + /** + * Specify the scope for this request. + * + * @param scope to request + * @return the current builder instance + */ + public fun setScope(scope: String): SignInBuilder = apply { + parameters[ParameterBuilder.SCOPE_KEY] = scope + } + + /** + * Specify the custom audience for this request. + * + * @param audience to use in this request + * @return the current builder instance + */ + public fun setAudience(audience: String): SignInBuilder = apply { + parameters[ParameterBuilder.AUDIENCE_KEY] = audience + } + + /** + * Specify the realm for this request + * + * @param realm to use in this request + * @return the current builder instance + */ + public fun setRealm(realm: String): SignInBuilder = apply { + parameters[ParameterBuilder.REALM_KEY] = realm + } + + /** + * Request user authentication using passkey. The result will be received in the callback. + * + * @param context context to run the authentication + * @param callback to receive the result + * @param executor optional executor to run the public key credential response creation + */ + public fun start( + context: Context, + callback: Callback, + executor: Executor = Executors.newSingleThreadExecutor() + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + Log.w(TAG, "Requires Android 9 or higher to use passkey authentication ") + val ex = AuthenticationException( + "Requires Android 9 or higher" + ) + callback.onFailure(ex) + return + } + val passkeyManager = + PasskeyManager( + AuthenticationAPIClient(auth0), + CredentialManager.create(context) + ) + passkeyManager.signin(context, parameters, callback, executor) + } + } + + + public class SignupBuilder internal constructor(private val auth0: Auth0) { + private var username: String? = null + private var email: String? = null + private var name: String? = null + private var phoneNumber: String? = null + + private val parameters: MutableMap = mutableMapOf() + + /** + * Specify the realm for this request + * + * @param realm to use in this request + * @return the current builder instance + */ + public fun setRealm(realm: String): SignupBuilder = apply { + parameters[ParameterBuilder.REALM_KEY] = realm + } + + /** + * Specify the email for the user. + * Email can be optional,required or forbidden depending on the attribute configuration for the database + * + * @param email to be set + * @return the current builder instance + */ + public fun setEmail(email: String): SignupBuilder = apply { + this.email = email + } + + /** + * Specify the username for the user. + * Username can be optional,required or forbidden depending on the attribute configuration for the database + * + * @param username to be set + * @return the current builder instance + */ + public fun setUserName(username: String): SignupBuilder = apply { + this.username = username + } + + /** + * Specify the name for the user. + * Name can be optional,required or forbidden depending on the attribute configuration for the database + * + * @param name to be set + * @return the current builder instance + */ + public fun setName(name: String): SignupBuilder = apply { + this.name = name + } + + /** + * Specify the phone number for the user + * Phone number can be optional,required or forbidden depending on the attribute configuration for the database + * + * @param number to be set + * @return the current builder instance + */ + public fun setPhoneNumber(number: String): SignupBuilder = apply { + this.phoneNumber = number + } + + /** + * Specify the scope for this request. + * + * @param scope to request + * @return the current builder instance + */ + public fun setScope(scope: String): SignupBuilder = apply { + parameters[ParameterBuilder.SCOPE_KEY] = scope + } + + /** + * Specify the custom audience for this request. + * + * @param audience to use in this request + * @return the current builder instance + */ + public fun setAudience(audience: String): SignupBuilder = apply { + parameters[ParameterBuilder.AUDIENCE_KEY] = audience + } + + /** + * Request user signup and authentication using passkey. The result will be received in the callback. + * + * @param context context to run the authentication + * @param callback to receive the result + * @param executor optional executor to run the public key credential response creation + */ + public fun start( + context: Context, + callback: Callback, + executor: Executor = Executors.newSingleThreadExecutor() + ) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + Log.w(TAG, "Requires Android 9 or higher to use passkey authentication ") + val ex = AuthenticationException( + "Requires Android 9 or higher" + ) + callback.onFailure(ex) + return + } + val passkeyManager = + PasskeyManager( + AuthenticationAPIClient(auth0), + CredentialManager.create(context) + ) + val userMetadata = UserMetadataRequest(email, phoneNumber, username, name) + passkeyManager.signup( + context, userMetadata, parameters, callback, executor + ) + } + } +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/provider/PasskeyManager.kt b/auth0/src/main/java/com/auth0/android/provider/PasskeyManager.kt new file mode 100644 index 00000000..1b68fd6b --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/provider/PasskeyManager.kt @@ -0,0 +1,239 @@ +package com.auth0.android.provider + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.os.CancellationSignal +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.credentials.CreateCredentialResponse +import androidx.credentials.CreatePublicKeyCredentialRequest +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.CredentialManagerCallback +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.GetPublicKeyCredentialOption +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialCancellationException +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.CreateCredentialInterruptedException +import androidx.credentials.exceptions.CreateCredentialProviderConfigurationException +import androidx.credentials.exceptions.GetCredentialCancellationException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialInterruptedException +import androidx.credentials.exceptions.GetCredentialUnsupportedException +import androidx.credentials.exceptions.NoCredentialException +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.authentication.ParameterBuilder +import com.auth0.android.callback.Callback +import com.auth0.android.request.PublicKeyCredentialResponse +import com.auth0.android.request.UserMetadataRequest +import com.auth0.android.result.Credentials +import com.auth0.android.result.PasskeyChallengeResponse +import com.auth0.android.result.PasskeyRegistrationResponse +import com.google.gson.Gson +import java.util.concurrent.Executor +import java.util.concurrent.Executors + + +internal class PasskeyManager( + private val authenticationAPIClient: AuthenticationAPIClient, + private val credentialManager: CredentialManager +) { + + private val TAG = PasskeyManager::class.simpleName + + @RequiresApi(api = Build.VERSION_CODES.P) + @SuppressLint("PublicKeyCredential") + fun signup( + context: Context, + userMetadata: UserMetadataRequest, + parameters: Map, + callback: Callback, + executor: Executor = Executors.newSingleThreadExecutor() + ) { + + authenticationAPIClient.signupWithPasskey(userMetadata, parameters) + .start(object : Callback { + override fun onSuccess(result: PasskeyRegistrationResponse) { + val pasKeyRegistrationResponse = result + val request = CreatePublicKeyCredentialRequest( + Gson().toJson( + pasKeyRegistrationResponse.authParamsPublicKey + ) + ) + var response: CreatePublicKeyCredentialResponse? + + credentialManager.createCredentialAsync(context, + request, + CancellationSignal(), + executor, + object : + CredentialManagerCallback { + + override fun onError(e: CreateCredentialException) { + Log.w(TAG, "Error while creating passkey") + callback.onFailure(handleCreationFailure(e)) + } + + override fun onResult(result: CreateCredentialResponse) { + + response = result as CreatePublicKeyCredentialResponse + val authRequest = Gson().fromJson( + response?.registrationResponseJson, + PublicKeyCredentialResponse::class.java + ) + authenticationAPIClient.signinWithPasskey( + pasKeyRegistrationResponse.authSession, authRequest, parameters + ) + .validateClaims() + .start(callback) + } + }) + + } + + override fun onFailure(error: AuthenticationException) { + callback.onFailure(error) + } + }) + + } + + + @RequiresApi(api = Build.VERSION_CODES.P) + fun signin( + context: Context, + parameters: Map, + callback: Callback, + executor: Executor = Executors.newSingleThreadExecutor() + ) { + authenticationAPIClient.passkeyChallenge(parameters[ParameterBuilder.REALM_KEY]) + .start(object : Callback { + override fun onSuccess(result: PasskeyChallengeResponse) { + val passkeyChallengeResponse = result + val request = + GetPublicKeyCredentialOption(Gson().toJson(passkeyChallengeResponse.authParamsPublicKey)) + val getCredRequest = GetCredentialRequest( + listOf(request) + ) + credentialManager.getCredentialAsync(context, + getCredRequest, + CancellationSignal(), + executor, + object : + CredentialManagerCallback { + override fun onError(e: GetCredentialException) { + Log.w(TAG, "Error while fetching public key credential") + callback.onFailure(handleGetCredentialFailure(e)) + } + + override fun onResult(result: GetCredentialResponse) { + when (val credential = result.credential) { + is PublicKeyCredential -> { + val authRequest = Gson().fromJson( + credential.authenticationResponseJson, + PublicKeyCredentialResponse::class.java + ) + authenticationAPIClient.signinWithPasskey( + passkeyChallengeResponse.authSession, + authRequest, + parameters + ) + .validateClaims() + .start(callback) + } + + else -> { + Log.w( + TAG, + "Received unrecognized credential type ${credential.type}.This shouldn't happen" + ) + callback.onFailure(AuthenticationException("Received unrecognized credential type ${credential.type}")) + } + } + } + }) + + } + + override fun onFailure(error: AuthenticationException) { + callback.onFailure(error) + } + }) + + } + + private fun handleCreationFailure(exception: CreateCredentialException): AuthenticationException { + return when (exception) { + + is CreateCredentialCancellationException -> { + AuthenticationException( + AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, + "The user cancelled passkey authentication operation." + ) + } + + is CreateCredentialInterruptedException -> { + AuthenticationException( + "Passkey authentication was interrupted. Please retry the call." + ) + } + + is CreateCredentialProviderConfigurationException -> { + AuthenticationException( + "Provider configuration dependency is missing. Ensure credentials-play-services-auth dependency is added." + ) + } + + else -> { + Log.w(TAG, "Unexpected exception type ${exception::class.java.name}") + AuthenticationException( + "An error occurred when trying to authenticate with passkey" + ) + } + } + } + + private fun handleGetCredentialFailure(exception: GetCredentialException): AuthenticationException { + + return when (exception) { + is GetCredentialCancellationException -> { + AuthenticationException( + AuthenticationException.ERROR_VALUE_AUTHENTICATION_CANCELED, + "The user cancelled passkey authentication operation." + ) + } + + is GetCredentialInterruptedException -> { + AuthenticationException( + "Passkey authentication was interrupted. Please retry the call." + ) + } + + is GetCredentialUnsupportedException -> { + AuthenticationException( + "Credential manager is unsupported. Please update the device." + ) + } + + + is NoCredentialException -> { + AuthenticationException( + "No viable credential is available for the user" + ) + } + + + else -> { + Log.w(TAG, "Unexpected exception type ${exception::class.java.name}") + AuthenticationException( + "An error occurred when trying to authenticate with passkey" + ) + } + } + } + +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt index 9224d971..83ecbc6e 100644 --- a/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt +++ b/auth0/src/main/java/com/auth0/android/provider/WebAuthProvider.kt @@ -8,15 +8,15 @@ import androidx.annotation.VisibleForTesting import com.auth0.android.Auth0 import com.auth0.android.annotation.ExperimentalAuth0Api import com.auth0.android.authentication.AuthenticationException -import com.auth0.android.authentication.storage.CredentialsManagerException import com.auth0.android.callback.Callback import com.auth0.android.result.Credentials -import kotlinx.coroutines.* -import java.util.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.util.Locale import kotlin.coroutines.CoroutineContext import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException -import kotlin.jvm.Throws /** * OAuth2 Web Authentication Provider. diff --git a/auth0/src/main/java/com/auth0/android/request/PublicKeyCredentialRequest.kt b/auth0/src/main/java/com/auth0/android/request/PublicKeyCredentialRequest.kt new file mode 100644 index 00000000..ddedf864 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/request/PublicKeyCredentialRequest.kt @@ -0,0 +1,45 @@ +package com.auth0.android.request + + +import com.google.gson.annotations.SerializedName + +internal data class PublicKeyCredentialResponse( + @SerializedName("authenticatorAttachment") + val authenticatorAttachment: String, + @SerializedName("clientExtensionResults") + val clientExtensionResults: ClientExtensionResults, + @SerializedName("id") + val id: String, + @SerializedName("rawId") + val rawId: String, + @SerializedName("response") + val response: Response, + @SerializedName("type") + val type: String +) + + +public data class Response( + @SerializedName("attestationObject") + val attestationObject: String, + @SerializedName("authenticatorData") + val authenticatorData: String, + @SerializedName("clientDataJSON") + val clientDataJSON: String, + @SerializedName("transports") + val transports: List, + @SerializedName("signature") + val signature:String, + @SerializedName("userHandle") + val userHandle:String +) + +public data class CredProps( + @SerializedName("rk") + val rk: Boolean +) + +public data class ClientExtensionResults( + @SerializedName("credProps") + val credProps: CredProps +) \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/request/Request.kt b/auth0/src/main/java/com/auth0/android/request/Request.kt index e6825e9f..287c38e9 100755 --- a/auth0/src/main/java/com/auth0/android/request/Request.kt +++ b/auth0/src/main/java/com/auth0/android/request/Request.kt @@ -56,6 +56,18 @@ public interface Request { */ public fun addParameter(name: String, value: String): Request + + /** + * Add parameter of [Any] type to the request with a given name + * + * @param name of the parameter + * @param value of the parameter + * @return itself + */ + public fun addParameter(name: String,value:Any):Request { + return this + } + /** * Adds an additional header for the request * diff --git a/auth0/src/main/java/com/auth0/android/request/UserMetadataRequest.kt b/auth0/src/main/java/com/auth0/android/request/UserMetadataRequest.kt new file mode 100644 index 00000000..2a1f808c --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/request/UserMetadataRequest.kt @@ -0,0 +1,13 @@ +package com.auth0.android.request + +import com.google.gson.annotations.SerializedName + +/** + * User metadata request used in Passkey authentication + */ +internal data class UserMetadataRequest( + @field:SerializedName("email") val email: String? = null, + @field:SerializedName("phone_number") val phoneNumber: String? = null, + @field:SerializedName("username") val userName: String? = null, + @field:SerializedName("name") val name: String? = null, +) \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/request/internal/BaseAuthenticationRequest.kt b/auth0/src/main/java/com/auth0/android/request/internal/BaseAuthenticationRequest.kt index e9a9c0b9..edd7fceb 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/BaseAuthenticationRequest.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/BaseAuthenticationRequest.kt @@ -17,7 +17,8 @@ import java.util.* internal open class BaseAuthenticationRequest( private val request: Request, - private val clientId: String, baseURL: String) : AuthenticationRequest { + private val clientId: String, baseURL: String +) : AuthenticationRequest { private companion object { private val TAG = BaseAuthenticationRequest::class.java.simpleName @@ -28,8 +29,10 @@ internal open class BaseAuthenticationRequest( @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal var validateClaims = false + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal var idTokenVerificationLeeway: Int? = null + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal var idTokenVerificationIssuer: String = baseURL @@ -121,6 +124,11 @@ internal open class BaseAuthenticationRequest( return this } + override fun addParameter(name: String, value: Any): AuthenticationRequest { + request.addParameter(name, value) + return this + } + override fun addHeader(name: String, value: String): AuthenticationRequest { request.addHeader(name, value) return this @@ -130,7 +138,7 @@ internal open class BaseAuthenticationRequest( warnClaimValidation() request.start(object : Callback { override fun onSuccess(result: Credentials) { - if(validateClaims) { + if (validateClaims) { try { verifyClaims(result.idToken) } catch (e: AuthenticationException) { @@ -151,7 +159,7 @@ internal open class BaseAuthenticationRequest( override fun execute(): Credentials { warnClaimValidation() val credentials = request.execute() - if(validateClaims) { + if (validateClaims) { verifyClaims(credentials.idToken) } return credentials @@ -162,7 +170,7 @@ internal open class BaseAuthenticationRequest( override suspend fun await(): Credentials { warnClaimValidation() val credentials = request.await() - if(validateClaims) { + if (validateClaims) { verifyClaims(credentials.idToken) } return credentials @@ -199,8 +207,11 @@ internal open class BaseAuthenticationRequest( } private fun warnClaimValidation() { - if(!validateClaims) { - Log.e(TAG, "The request is made without validating claims. Enable claim validation by calling AuthenticationRequest#validateClaims()") + if (!validateClaims) { + Log.e( + TAG, + "The request is made without validating claims. Enable claim validation by calling AuthenticationRequest#validateClaims()" + ) } } } \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/request/internal/BaseRequest.kt b/auth0/src/main/java/com/auth0/android/request/internal/BaseRequest.kt index f0671af1..d4f72025 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/BaseRequest.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/BaseRequest.kt @@ -55,7 +55,7 @@ internal open class BaseRequest( return addParameter(name, anyValue) } - internal fun addParameter(name: String, value: Any): Request { + override fun addParameter(name: String, value: Any): Request { options.parameters[name] = value return this } diff --git a/auth0/src/main/java/com/auth0/android/result/PasskeyChallengeResponse.kt b/auth0/src/main/java/com/auth0/android/result/PasskeyChallengeResponse.kt new file mode 100644 index 00000000..8c49afde --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyChallengeResponse.kt @@ -0,0 +1,22 @@ +package com.auth0.android.result + + +import com.google.gson.annotations.SerializedName + +internal data class PasskeyChallengeResponse( + @SerializedName("auth_session") + val authSession: String, + @SerializedName("authn_params_public_key") + val authParamsPublicKey: AuthParamsPublicKey +) + +internal data class AuthParamsPublicKey( + @SerializedName("challenge") + val challenge: String, + @SerializedName("rpId") + val rpId: String, + @SerializedName("timeout") + val timeout: Int, + @SerializedName("userVerification") + val userVerification: String +) \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationResponse.kt b/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationResponse.kt new file mode 100644 index 00000000..42f778e7 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyRegistrationResponse.kt @@ -0,0 +1,56 @@ +package com.auth0.android.result + + +import com.google.gson.annotations.SerializedName + +internal data class PasskeyRegistrationResponse( + @SerializedName("auth_session") + val authSession: String, + @SerializedName("authn_params_public_key") + val authParamsPublicKey: AuthnParamsPublicKey +) + +internal data class AuthnParamsPublicKey( + @SerializedName("authenticatorSelection") + val authenticatorSelection: AuthenticatorSelection, + @SerializedName("challenge") + val challenge: String, + @SerializedName("pubKeyCredParams") + val pubKeyCredParams: List, + @SerializedName("rp") + val relyingParty: RelyingParty, + @SerializedName("timeout") + val timeout: Long, + @SerializedName("user") + val user: PasskeyUser +) + +internal data class AuthenticatorSelection( + @SerializedName("residentKey") + val residentKey: String, + @SerializedName("userVerification") + val userVerification: String +) + +internal data class PubKeyCredParam( + @SerializedName("alg") + val alg: Int, + @SerializedName("type") + val type: String +) + +internal data class RelyingParty( + @SerializedName("id") + val id: String, + @SerializedName("name") + val name: String +) + +internal data class PasskeyUser( + @SerializedName("displayName") + val displayName: String, + @SerializedName("id") + val id: String, + @SerializedName("name") + val name: String +) \ No newline at end of file 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 8212c218..5830ba43 100755 --- a/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/authentication/AuthenticationAPIClientTest.kt @@ -9,18 +9,31 @@ import com.auth0.android.request.HttpMethod import com.auth0.android.request.NetworkingClient import com.auth0.android.request.RequestOptions import com.auth0.android.request.ServerResponse +import com.auth0.android.request.UserMetadataRequest import com.auth0.android.request.internal.RequestFactory import com.auth0.android.request.internal.ThreadSwitcherShadow -import com.auth0.android.result.* +import com.auth0.android.result.Authentication +import com.auth0.android.result.Challenge +import com.auth0.android.result.Credentials +import com.auth0.android.result.DatabaseUser +import com.auth0.android.result.PasskeyRegistrationResponse +import com.auth0.android.result.UserProfile import com.auth0.android.util.Auth0UserAgent import com.auth0.android.util.AuthenticationAPIMockServer +import com.auth0.android.util.AuthenticationAPIMockServer.Companion.SESSION_ID import com.auth0.android.util.AuthenticationCallbackMatcher import com.auth0.android.util.MockAuthenticationCallback import com.auth0.android.util.SSLTestUtils.testClient import com.google.gson.Gson import com.google.gson.GsonBuilder +import com.google.gson.JsonElement import com.google.gson.reflect.TypeToken -import com.nhaarman.mockitokotlin2.* +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @@ -39,7 +52,7 @@ import java.io.ByteArrayInputStream import java.io.FileReader import java.io.InputStream import java.security.PublicKey -import java.util.* +import java.util.Locale @RunWith(RobolectricTestRunner::class) @Config(shadows = [ThreadSwitcherShadow::class]) @@ -175,6 +188,84 @@ public class AuthenticationAPIClientTest { assertThat(body, Matchers.not(Matchers.hasKey("connection"))) } + @Test + public fun shouldSigninWithPasskey() { + mockAPI.willReturnSuccessfulLogin() + val callback = MockAuthenticationCallback() + val auth0 = auth0 + val client = AuthenticationAPIClient(auth0) + client.signinWithPasskey("auth-session", mock(), emptyMap()) + .start(callback) + ShadowLooper.idleMainLooper() + assertThat( + callback, AuthenticationCallbackMatcher.hasPayloadOfType( + Credentials::class.java + ) + ) + val request = mockAPI.takeRequest() + assertThat( + request.getHeader("Accept-Language"), Matchers.`is`( + defaultLocale + ) + ) + val body = bodyFromRequest(request) + assertThat(request.path, Matchers.equalTo("/oauth/token")) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat( + body, + Matchers.hasEntry("grant_type", "urn:okta:params:oauth:grant-type:webauthn") + ) + assertThat(body, Matchers.hasKey("authn_response")) + assertThat(body, Matchers.hasEntry("auth_session", "auth-session")) + } + + @Test + public fun shouldSignupWithPasskey() { + mockAPI.willReturnSuccessfulPasskeyRegistration() + val auth0 = auth0 + val client = AuthenticationAPIClient(auth0) + val registrationResponse = client.signupWithPasskey( + mock(), + mapOf("realm" to MY_CONNECTION) + ) + .execute() + val request = mockAPI.takeRequest() + assertThat( + request.getHeader("Accept-Language"), Matchers.`is`( + defaultLocale + ) + ) + val body = bodyFromRequest(request) + assertThat(request.path, Matchers.equalTo("/passkey/register")) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat(body, Matchers.hasEntry("realm", MY_CONNECTION)) + assertThat(body, Matchers.hasKey("user_profile")) + assertThat(registrationResponse, Matchers.`is`(Matchers.notNullValue())) + assertThat(registrationResponse.authSession, Matchers.comparesEqualTo(SESSION_ID)) + } + + @Test + public fun shouldGetPasskeyChallenge() { + mockAPI.willReturnSuccessfulPasskeyChallenge() + val auth0 = auth0 + val client = AuthenticationAPIClient(auth0) + val challengeResponse = client.passkeyChallenge(MY_CONNECTION) + .execute() + val request = mockAPI.takeRequest() + assertThat( + request.getHeader("Accept-Language"), Matchers.`is`( + defaultLocale + ) + ) + val body = bodyFromRequest(request) + assertThat(request.path, Matchers.equalTo("/passkey/challenge")) + assertThat(body, Matchers.hasEntry("client_id", CLIENT_ID)) + assertThat(body, Matchers.hasEntry("realm", MY_CONNECTION)) + assertThat(challengeResponse, Matchers.`is`(Matchers.notNullValue())) + assertThat(challengeResponse.authSession, Matchers.comparesEqualTo(SESSION_ID)) + + } + @Test public fun shouldLoginWithMFARecoveryCode() { mockAPI.willReturnSuccessfulLoginWithRecoveryCode() diff --git a/auth0/src/test/java/com/auth0/android/authentication/request/AuthenticationRequestMock.java b/auth0/src/test/java/com/auth0/android/authentication/request/AuthenticationRequestMock.java index f79dce5c..4b5d6d0e 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/request/AuthenticationRequestMock.java +++ b/auth0/src/test/java/com/auth0/android/authentication/request/AuthenticationRequestMock.java @@ -6,6 +6,7 @@ import com.auth0.android.authentication.AuthenticationException; import com.auth0.android.callback.Callback; import com.auth0.android.request.AuthenticationRequest; +import com.auth0.android.request.Request; import com.auth0.android.result.Credentials; import java.util.Map; @@ -48,6 +49,12 @@ public AuthenticationRequest addParameter(@NonNull String name, @NonNull String return this; } + @NonNull + @Override + public Request addParameter(@NonNull String name, @NonNull Object value) { + return this; + } + @NonNull @Override public AuthenticationRequest addHeader(@NonNull String name, @NonNull String value) { diff --git a/auth0/src/test/java/com/auth0/android/authentication/request/RequestMock.java b/auth0/src/test/java/com/auth0/android/authentication/request/RequestMock.java index b2748c54..69f77d67 100644 --- a/auth0/src/test/java/com/auth0/android/authentication/request/RequestMock.java +++ b/auth0/src/test/java/com/auth0/android/authentication/request/RequestMock.java @@ -55,4 +55,10 @@ public void start(@NonNull Callback callback) { public T execute() throws Auth0Exception { return null; } + + @NonNull + @Override + public Request addParameter(@NonNull String name, @NonNull Object value) { + return this; + } } diff --git a/auth0/src/test/java/com/auth0/android/provider/PasskeyManagerTest.kt b/auth0/src/test/java/com/auth0/android/provider/PasskeyManagerTest.kt new file mode 100644 index 00000000..a5f53f74 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/provider/PasskeyManagerTest.kt @@ -0,0 +1,362 @@ +package com.auth0.android.provider + +import android.content.Context +import androidx.credentials.CreateCredentialResponse +import androidx.credentials.CreatePublicKeyCredentialResponse +import androidx.credentials.CredentialManager +import androidx.credentials.CredentialManagerCallback +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.PublicKeyCredential +import androidx.credentials.exceptions.CreateCredentialException +import androidx.credentials.exceptions.CreateCredentialInterruptedException +import androidx.credentials.exceptions.GetCredentialException +import androidx.credentials.exceptions.GetCredentialInterruptedException +import com.auth0.android.authentication.AuthenticationAPIClient +import com.auth0.android.authentication.AuthenticationException +import com.auth0.android.authentication.request.AuthenticationRequestMock +import com.auth0.android.authentication.request.RequestMock +import com.auth0.android.callback.Callback +import com.auth0.android.request.UserMetadataRequest +import com.auth0.android.result.AuthParamsPublicKey +import com.auth0.android.result.AuthenticatorSelection +import com.auth0.android.result.AuthnParamsPublicKey +import com.auth0.android.result.Credentials +import com.auth0.android.result.PasskeyChallengeResponse +import com.auth0.android.result.PasskeyRegistrationResponse +import com.auth0.android.result.PasskeyUser +import com.auth0.android.result.PubKeyCredParam +import com.auth0.android.result.RelyingParty +import com.nhaarman.mockitokotlin2.KArgumentCaptor +import com.nhaarman.mockitokotlin2.any +import com.nhaarman.mockitokotlin2.argumentCaptor +import com.nhaarman.mockitokotlin2.doAnswer +import com.nhaarman.mockitokotlin2.eq +import com.nhaarman.mockitokotlin2.mock +import com.nhaarman.mockitokotlin2.never +import com.nhaarman.mockitokotlin2.verify +import com.nhaarman.mockitokotlin2.whenever +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.Mockito.`when` +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import java.util.Date +import java.util.concurrent.Executor + + +@RunWith(RobolectricTestRunner::class) +public class PasskeyManagerTest { + + private lateinit var passkeyManager: PasskeyManager + + @Mock + private lateinit var callback: Callback + + @Mock + private lateinit var authenticationAPIClient: AuthenticationAPIClient + + @Mock + private lateinit var credentialManager: CredentialManager + + @Mock + private lateinit var context: Context + + private val serialExecutor = Executor { runnable -> runnable.run() } + + private val credentialsCaptor: KArgumentCaptor = argumentCaptor() + private val exceptionCaptor: KArgumentCaptor = argumentCaptor() + + + private val passkeyRegistrationResponse = PasskeyRegistrationResponse( + authSession = "dummyAuthSession", + authParamsPublicKey = AuthnParamsPublicKey( + authenticatorSelection = AuthenticatorSelection( + residentKey = "required", + userVerification = "preferred" + ), + challenge = "dummyChallenge", + pubKeyCredParams = listOf( + PubKeyCredParam( + alg = -7, + type = "public-key" + ) + ), + relyingParty = RelyingParty( + id = "dummyRpId", + name = "dummyRpName" + ), + timeout = 60000L, + user = PasskeyUser( + displayName = "displayName", + id = "userId", + name = "userName" + ) + ) + ) + + private val registrationResponseJSON = """ + { + "id": "id", + "rawId": "rawId", + "response": { + "attestationObject": "attnObject", + "clientDataJSON": "dataJSON" + }, + "type": "public-key" + } + """ + + private val passkeyChallengeResponse = PasskeyChallengeResponse( + authSession = "authSession", + authParamsPublicKey = AuthParamsPublicKey( + challenge = "challenge", + rpId = "RpId", + timeout = 60000, + userVerification = "preferred" + ) + ) + + @Before + public fun setUp() { + MockitoAnnotations.openMocks(this) + passkeyManager = PasskeyManager(authenticationAPIClient, credentialManager) + } + + + @Test + public fun shouldSignUpWithPasskeySuccess() { + val userMetadata: UserMetadataRequest = mock() + val parameters = mapOf("realm" to "testRealm") + + `when`(authenticationAPIClient.signupWithPasskey(userMetadata, parameters)).thenReturn( + RequestMock(passkeyRegistrationResponse, null) + ) + `when`(authenticationAPIClient.signinWithPasskey(any(), any(), any())).thenReturn( + AuthenticationRequestMock( + Credentials( + "expectedIdToken", + "codeAccess", + "codeType", + "codeRefresh", + Date(), + "codeScope" + ), null + ) + ) + + val createResponse: CreatePublicKeyCredentialResponse = mock() + `when`(createResponse.registrationResponseJson).thenReturn( + registrationResponseJSON + ) + + whenever( + credentialManager.createCredentialAsync( + any(), + any(), + any(), + any(), + any() + ) + ).thenAnswer { + (it.arguments[4] as CredentialManagerCallback).onResult( + createResponse + ) + } + + passkeyManager.signup(context, userMetadata, parameters, callback, serialExecutor) + + verify(authenticationAPIClient).signupWithPasskey(userMetadata, parameters) + verify(credentialManager).createCredentialAsync(eq(context), any(), any(), any(), any()) + verify(authenticationAPIClient).signinWithPasskey(any(), any(), any()) + verify(callback).onSuccess(credentialsCaptor.capture()) + Assert.assertEquals("codeAccess", credentialsCaptor.firstValue.accessToken) + Assert.assertEquals("codeScope", credentialsCaptor.firstValue.scope) + + } + + @Test + public fun shouldSignUpWithPasskeyApiFailure() { + val userMetadata: UserMetadataRequest = mock() + val parameters = mapOf("realm" to "testRealm") + val error = AuthenticationException("Signup failed") + `when`( + authenticationAPIClient.signupWithPasskey( + userMetadata, + parameters + ) + ).thenReturn(RequestMock(null, error)) + passkeyManager.signup(context, userMetadata, parameters, callback, serialExecutor) + verify(authenticationAPIClient).signupWithPasskey(userMetadata, parameters) + verify(authenticationAPIClient, never()).signinWithPasskey(any(), any(), any()) + verify(credentialManager, never()).createCredentialAsync( + any(), + any(), + any(), + any(), + any() + ) + verify(callback).onFailure(error) + } + + @Test + public fun shouldSignUpWithPasskeyCreateCredentialFailure() { + val userMetadata: UserMetadataRequest = mock() + val parameters = mapOf("realm" to "testRealm") + `when`( + authenticationAPIClient.signupWithPasskey( + userMetadata, + parameters + ) + ).thenReturn(RequestMock(passkeyRegistrationResponse, null)) + + whenever( + credentialManager.createCredentialAsync( + any(), + any(), + any(), + any(), + any() + ) + ).thenAnswer { + (it.arguments[4] as CredentialManagerCallback).onError( + CreateCredentialInterruptedException() + ) + } + + passkeyManager.signup(context, userMetadata, parameters, callback, serialExecutor) + verify(authenticationAPIClient).signupWithPasskey(userMetadata, parameters) + verify(credentialManager).createCredentialAsync(eq(context), any(), any(), any(), any()) + verify(authenticationAPIClient, never()).signinWithPasskey(any(), any(), any()) + verify(callback).onFailure(exceptionCaptor.capture()) + Assert.assertEquals( + AuthenticationException::class.java, + exceptionCaptor.firstValue.javaClass + ) + Assert.assertEquals( + "Passkey authentication was interrupted. Please retry the call.", + exceptionCaptor.firstValue.message + ) + } + + + @Test + public fun shouldSignInWithPasskeySuccess() { + val parameters = mapOf("realm" to "testRealm") + val credentialResponse: GetCredentialResponse = mock() + + `when`(authenticationAPIClient.passkeyChallenge(parameters["realm"])).thenReturn( + RequestMock(passkeyChallengeResponse, null) + ) + + `when`(credentialResponse.credential).thenReturn( + PublicKeyCredential(registrationResponseJSON) + ) + + `when`(authenticationAPIClient.signinWithPasskey(any(), any(), any())).thenReturn( + AuthenticationRequestMock( + Credentials( + "expectedIdToken", + "codeAccess", + "codeType", + "codeRefresh", + Date(), + "codeScope" + ), null + ) + ) + + doAnswer { + val callback = + it.getArgument>( + 4 + ) + callback.onResult(credentialResponse) + }.`when`(credentialManager) + .getCredentialAsync(any(), any(), any(), any(), any()) + + passkeyManager.signin(context, parameters, callback, serialExecutor) + + verify(authenticationAPIClient).passkeyChallenge(parameters["realm"]) + verify(credentialManager).getCredentialAsync( + any(), + any(), + any(), + any(), + any() + ) + verify(authenticationAPIClient).signinWithPasskey(any(), any(), any()) + verify(callback).onSuccess(credentialsCaptor.capture()) + Assert.assertEquals("codeAccess", credentialsCaptor.firstValue.accessToken) + Assert.assertEquals("codeScope", credentialsCaptor.firstValue.scope) + } + + + @Test + public fun shouldSignInWithPasskeyApiFailure() { + val parameters = mapOf("realm" to "testRealm") + val error = AuthenticationException("Signin failed") + + `when`(authenticationAPIClient.passkeyChallenge(parameters["realm"])).thenReturn( + RequestMock(null, error) + ) + + passkeyManager.signin(context, parameters, callback, serialExecutor) + + verify(authenticationAPIClient).passkeyChallenge(any()) + verify(credentialManager, never()).getCredentialAsync( + any(), + any(), + any(), + any(), + any() + ) + verify(authenticationAPIClient, never()).signinWithPasskey(any(), any(), any()) + verify(callback).onFailure(error) + } + + @Test + public fun shouldSignInWithPasskeyGetCredentialFailure() { + val parameters = mapOf("realm" to "testRealm") + `when`(authenticationAPIClient.passkeyChallenge(parameters["realm"])).thenReturn( + RequestMock(passkeyChallengeResponse, null) + ) + + whenever( + credentialManager.getCredentialAsync( + any(), + any(), + any(), + any(), + any() + ) + ).thenAnswer { + (it.arguments[4] as CredentialManagerCallback).onError( + GetCredentialInterruptedException() + ) + } + + passkeyManager.signin(context, parameters, callback, serialExecutor) + verify(authenticationAPIClient).passkeyChallenge(parameters["realm"]) + verify(credentialManager).getCredentialAsync( + any(), + any(), + any(), + any(), + any() + ) + verify(authenticationAPIClient, never()).signinWithPasskey(any(), any(), any()) + verify(callback).onFailure(exceptionCaptor.capture()) + Assert.assertEquals( + AuthenticationException::class.java, + exceptionCaptor.firstValue.javaClass + ) + Assert.assertEquals( + "Passkey authentication was interrupted. Please retry the call.", + exceptionCaptor.firstValue.message + ) + } +} 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 b61a6c8c..b95822ed 100755 --- a/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/AuthenticationAPIMockServer.kt @@ -47,6 +47,55 @@ internal class AuthenticationAPIMockServer : APIMockServer() { return this } + fun willReturnSuccessfulPasskeyRegistration(): AuthenticationAPIMockServer { + val json = """{ + "authn_params_public_key":{ + "challenge": "$CHALLENGE", + "timeout": 6046456, + "rp": { + "id": "auth0.passkey.com", + "name": "Passkey Test" + }, + "pubKeyCredParams": [ + { + "type": "public-key", + "alg": -7 + }, + { + "type": "public-key", + "alg": -257 + } + ], + "authenticatorSelection": { + "authenticatorAttachment": "platform", + "residentKey": "required" + }, + "user": { + "id": "53b995f8bce68d9fc900099c", + "name": "p", + "displayName": "d" + } + }, + "auth_session": "$SESSION_ID" + }""" + server.enqueue(responseWithJSON(json, 200)) + return this + } + + fun willReturnSuccessfulPasskeyChallenge():AuthenticationAPIMockServer{ + val json = """{ + "authn_params_public_key":{ + "challenge": "$CHALLENGE", + "timeout": 604645, + "rpId": "domain", + "userVerification":"preferred" + }, + "auth_session": "$SESSION_ID" + }""" + server.enqueue(responseWithJSON(json, 200)) + return this + } + fun willReturnSuccessfulLoginWithRecoveryCode(): AuthenticationAPIMockServer { val json = """{ "refresh_token": "$REFRESH_TOKEN", @@ -155,6 +204,8 @@ internal class AuthenticationAPIMockServer : APIMockServer() { const val REFRESH_TOKEN = "REFRESH_TOKEN" const val ID_TOKEN = "ID_TOKEN" const val ACCESS_TOKEN = "ACCESS_TOKEN" + const val SESSION_ID = "SESSION_ID" private const val BEARER = "BEARER" + private const val CHALLENGE = "CHALLENGE" } } \ No newline at end of file diff --git a/proguard/proguard-jetpack.pro b/proguard/proguard-jetpack.pro new file mode 100644 index 00000000..6254a538 --- /dev/null +++ b/proguard/proguard-jetpack.pro @@ -0,0 +1,6 @@ +# Jetpack libraries + +-if class androidx.credentials.CredentialManager +-keep class androidx.credentials.playservices.** { + *; +} \ No newline at end of file diff --git a/sample/build.gradle b/sample/build.gradle index 5b3e0a66..1abd84c3 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -4,11 +4,11 @@ plugins { } android { - compileSdkVersion 31 + compileSdkVersion 34 defaultConfig { minSdkVersion 21 - targetSdkVersion 31 + targetSdkVersion 34 versionCode 1 versionName "1.0" diff --git a/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt b/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt index 08bf0ac5..4546908c 100644 --- a/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt +++ b/sample/src/main/java/com/auth0/sample/DatabaseLoginFragment.kt @@ -17,6 +17,7 @@ import com.auth0.android.authentication.storage.SharedPreferencesStorage import com.auth0.android.callback.Callback import com.auth0.android.management.ManagementException import com.auth0.android.management.UsersAPIClient +import com.auth0.android.provider.PasskeyAuthProvider import com.auth0.android.provider.WebAuthProvider import com.auth0.android.request.DefaultClient import com.auth0.android.result.Credentials @@ -91,6 +92,60 @@ class DatabaseLoginFragment : Fragment() { dbLoginAsync(email, password) } } + + binding.btSignupPasskey.setOnClickListener { + PasskeyAuthProvider.signUp(account) + .setEmail("username@email.com") + .setRealm("Username-Password-Authentication") + .start( + requireActivity(), + object : Callback { + override fun onSuccess(result: Credentials) { + credentialsManager.saveCredentials(result) + Snackbar.make( + requireView(), + "Hello ${result.user.name}", + Snackbar.LENGTH_LONG + ).show() + } + + override fun onFailure(error: AuthenticationException) { + Snackbar.make( + requireView(), + error.getDescription(), + Snackbar.LENGTH_LONG + ) + .show() + } + }) + } + binding.btSignInPasskey.setOnClickListener { + PasskeyAuthProvider + .signIn(account) + .setRealm("Username-Password-Authentication") + .start(requireActivity(), object : Callback { + override fun onSuccess(result: Credentials) { + credentialsManager.saveCredentials(result) + Snackbar.make( + requireView(), + "Hello ${result.user.name}", + Snackbar.LENGTH_LONG + ).show() + } + + override fun onFailure(error: AuthenticationException) { + Snackbar.make( + requireView(), + error.getDescription(), + Snackbar.LENGTH_LONG + ) + .show() + } + + }) + + } + binding.btWebAuth.setOnClickListener { webAuth() } diff --git a/sample/src/main/res/layout/fragment_database_login.xml b/sample/src/main/res/layout/fragment_database_login.xml index af19b8b4..61d1a7bc 100644 --- a/sample/src/main/res/layout/fragment_database_login.xml +++ b/sample/src/main/res/layout/fragment_database_login.xml @@ -20,6 +20,7 @@ android:autofillHints="emailAddress" android:ems="10" android:hint="Email" + android:isCredential="true" android:inputType="textEmailAddress" android:text="asd@asd.asd" android:textSize="14sp" @@ -37,6 +38,7 @@ android:autofillHints="password" android:ems="10" android:hint="Password" + android:isCredential="true" android:inputType="textPassword" android:text="asdasd" android:textSize="14sp" @@ -67,8 +69,45 @@ android:textSize="16sp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/btSignInPasskey" /> + + +