From 415cfbf4feffe44a3bdd4d67cb8ebd47055d143b Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Tue, 3 Jun 2025 01:12:27 +0530 Subject: [PATCH 1/4] Added My Account API for passkey enrollment --- .../authentication/AuthenticationAPIClient.kt | 2 +- .../android/management/UsersAPIClient.kt | 2 +- .../android/myaccount/MyAccountAPIClient.kt | 315 ++++++++++++++++++ .../android/myaccount/MyAccountException.kt | 115 +++++++ .../com/auth0/android/request/JsonAdapter.kt | 3 +- .../android/request/internal/BaseRequest.kt | 2 +- .../android/request/internal/GsonAdapter.kt | 3 +- .../request/internal/RequestFactory.kt | 2 +- .../result/PasskeyAuthenticationMethod.kt | 34 ++ .../result/PasskeyEnrollmentChallenge.kt | 14 + 10 files changed, 486 insertions(+), 6 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt create mode 100644 auth0/src/main/java/com/auth0/android/myaccount/MyAccountException.kt create mode 100644 auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt create mode 100644 auth0/src/main/java/com/auth0/android/result/PasskeyEnrollmentChallenge.kt diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt index 560e649f7..943ca4e10 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -1088,7 +1088,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe override fun fromJsonResponse( statusCode: Int, reader: Reader ): AuthenticationException { - val values = mapAdapter.fromJson(reader) + val values = mapAdapter.fromJson(reader, emptyMap()) return AuthenticationException(values, statusCode) } diff --git a/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt b/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt index 43f23af44..65a5b443c 100755 --- a/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt @@ -216,7 +216,7 @@ public class UsersAPIClient @VisibleForTesting(otherwise = VisibleForTesting.PRI statusCode: Int, reader: Reader ): ManagementException { - val values = mapAdapter.fromJson(reader) + val values = mapAdapter.fromJson(reader, emptyMap()) return ManagementException(values) } diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt new file mode 100644 index 000000000..596461c7a --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -0,0 +1,315 @@ +package com.auth0.android.myaccount + +import androidx.annotation.VisibleForTesting +import com.auth0.android.Auth0 +import com.auth0.android.Auth0Exception +import com.auth0.android.NetworkErrorException +import com.auth0.android.authentication.ParameterBuilder +import com.auth0.android.request.ErrorAdapter +import com.auth0.android.request.JsonAdapter +import com.auth0.android.request.PublicKeyCredentials +import com.auth0.android.request.Request +import com.auth0.android.request.internal.GsonAdapter +import com.auth0.android.request.internal.GsonAdapter.Companion.forMap +import com.auth0.android.request.internal.GsonProvider +import com.auth0.android.request.internal.RequestFactory +import com.auth0.android.request.internal.ResponseUtils.isNetworkError +import com.auth0.android.result.PasskeyAuthenticationMethod +import com.auth0.android.result.PasskeyEnrollmentChallenge +import com.auth0.android.result.PasskeyRegistrationChallenge +import com.google.gson.Gson +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.io.IOException +import java.io.Reader +import java.net.URLDecoder + + +/** + * Auth0 My Account API client for managing the current user's account. + * + * You can use the refresh token to get an access token for the My Account API. Refer to [com.auth0.android.authentication.storage.CredentialsManager.getApiCredentials] + * , or alternatively [com.auth0.android.authentication.AuthenticationAPIClient.renewAuth] if you are not using CredentialsManager. + * + * ## Usage + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val client = MyAccountAPIClient(auth0,accessToken) + * ``` + * + * + */ +public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal constructor( + private val auth0: Auth0, + private val accessToken: String, + private val factory: RequestFactory, + private val gson: Gson +) { + + public val clientId: String + get() = auth0.clientId + + public val baseURL: String + get() = auth0.getDomainUrl() + + /** + * Creates a new MyAccountAPI client instance. + * + * Example usage: + * + * ``` + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val client = MyAccountAPIClient(auth0, accessToken) + * ``` + * @param auth0 account information + */ + public constructor( + auth0: Auth0, + accessToken: String + ) : this( + auth0, + accessToken, + RequestFactory(auth0.networkingClient, createErrorAdapter()), + Gson() + ) + + + /** + * Requests a challenge for enrolling a new passkey. This is the first part of the enrollment flow. + * + * You can specify an optional user identity identifier and an optional database connection name. + * If a connection name is not specified, your tenant's default directory will be used. + * + * ## Availability + * + * This feature is currently available in + * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). + * Please reach out to Auth0 support to get it enabled for your tenant. + * + * ## Scopes Required + * + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * apiClient.passkeyEnrollmentChallenge() + * .start(object : Callback { + * override fun onSuccess(result: PasskeyEnrollmentChallenge) { + * // Use the challenge with Credential Manager API to generate a new passkey credential + * Log.d("MyApp", "Obtained enrollment challenge: $result") + * } + * + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) + * ``` + * Use the challenge with [Google Credential Manager API](https://developer.android.com/identity/sign-in/credential-manager) to generate a new passkey credential. + * + * ``` kotlin + * CreatePublicKeyCredentialRequest( Gson(). + * toJson( passkeyEnrollmentChallenge.authParamsPublicKey )) + * var response: CreatePublicKeyCredentialResponse? + * credentialManager.createCredentialAsync( + * requireContext(), + * request, + * CancellationSignal(), + * Executors.newSingleThreadExecutor(), + * object : + * CredentialManagerCallback { + * override fun onError(e: CreateCredentialException) { + * } + * + * override fun onResult(result: CreateCredentialResponse) { + * response = result as CreatePublicKeyCredentialResponse + * val credentials = Gson().fromJson( + * response?.registrationResponseJson, PublicKeyCredentials::class.java + * ) + * } + * ``` + * + * Then, call ``enroll()`` with the created passkey credential and the challenge to complete + * the enrollment + * + * @param userIdentity Unique identifier of the current user's identity. Needed if the user logged in with a [linked account](https://auth0.com/docs/manage-users/user-accounts/user-account-linking) + * @param connection Name of the database connection where the user is stored + * @return A request to obtain a passkey enrollment challenge + * + * */ + public fun passkeyEnrollmentChallenge( + userIdentity: String? = null, connection: String? = null + ): Request { + + val url = getDomainUrlBuilder() + .addPathSegment(AUTHENTICATION_METHODS) + .build() + + val params = ParameterBuilder.newBuilder().apply { + set(TYPE_KEY, "passkey") + userIdentity?.let { + set(USER_IDENTITY_ID_KEY, userIdentity) + } + connection?.let { + set(CONNECTION_KEY, connection) + } + }.asDictionary() + + val passkeyEnrollmentAdapter: JsonAdapter = + object : JsonAdapter { + override fun fromJson( + reader: Reader, headers: Map> + ): PasskeyEnrollmentChallenge { + val authenticationId = + URLDecoder.decode( + headers[LOCATION_KEY]?.get(0)?.split("/")?.lastOrNull(), + "UTF-8" + ) + + authenticationId ?: throw MyAccountException("Authentication ID not found") + + val passkeyRegistrationChallenge = gson.fromJson( + reader, PasskeyRegistrationChallenge::class.java + ) + return PasskeyEnrollmentChallenge( + authenticationId, + passkeyRegistrationChallenge.authSession, + passkeyRegistrationChallenge.authParamsPublicKey + ) + } + } + val post = factory.post(url.toString(), passkeyEnrollmentAdapter) + .addParameters(params) + .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + + return post + } + + /** + * Enrolls a new passkey credential. This is the last part of the enrollment flow. + * + * ## Availability + * + * This feature is currently available in + * [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). + * Please reach out to Auth0 support to get it enabled for your tenant. + * + * ## Scopes Required + * + * `create:me:authentication_methods` + * + * ## Usage + * + * ```kotlin + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val apiClient = MyAccountAPIClient(auth0, accessToken) + * + * // After obtaining the passkey credential from the [Credential Manager API](https://developer.android.com/identity/sign-in/credential-manager) + * apiClient.enroll(publicKeyCredentials, enrollmentChallenge) + * .start(object : Callback { + * override fun onSuccess(result: AuthenticationMethodVerified) { + * Log.d("MyApp", "Enrolled passkey: $result") + * } + * + * override fun onFailure(error: MyAccountException) { + * Log.e("MyApp", "Failed with: ${error.message}") + * } + * }) + * ``` + * + * @param credentials The passkey credentials obtained from the [Credential Manager API](https://developer.android.com/identity/sign-in/credential-manager). + * @param challenge The enrollment challenge obtained from the `passkeyEnrollmentChallenge()` method. + * @return A request to enroll the passkey credential. + */ + public fun enroll( + credentials: PublicKeyCredentials, challenge: PasskeyEnrollmentChallenge + ): Request { + val authMethodId = challenge.authenticationMethodId + val url = + getDomainUrlBuilder() + .addPathSegment(AUTHENTICATION_METHODS) + .addPathSegment(authMethodId) + .addPathSegment(VERIFY) + .build() + + val authenticatorResponse = mapOf( + "authenticatorAttachment" to "platform", + "clientExtensionResults" to credentials.clientExtensionResults, + "id" to credentials.id, + "rawId" to credentials.rawId, + "type" to "public-key", + "response" to mapOf( + "clientDataJSON" to credentials.response.clientDataJSON, + "attestationObject" to credentials.response.attestationObject + ) + ) + + val params = ParameterBuilder.newBuilder().apply { + set(AUTH_SESSION_KEY, challenge.authSession) + }.asDictionary() + + val passkeyAuthenticationAdapter = GsonAdapter( + PasskeyAuthenticationMethod::class.java + ) + + val request = factory.post( + url.toString(), passkeyAuthenticationAdapter + ).addParameters(params) + .addParameter(AUTHN_RESPONSE_KEY, authenticatorResponse) + .addHeader(AUTHORIZATION_KEY, "Bearer $accessToken") + return request + } + + private fun getDomainUrlBuilder(): HttpUrl.Builder { + return auth0.getDomainUrl().toHttpUrl().newBuilder() + .addPathSegment(ME_PATH) + .addPathSegment(API_VERSION) + } + + + private companion object { + private const val AUTHENTICATION_METHODS = "authentication-methods" + private const val VERIFY = "verify" + private const val API_VERSION = "v1" + private const val ME_PATH = "me" + private const val TYPE_KEY = "type" + private const val USER_IDENTITY_ID_KEY = "identity_user_id" + private const val CONNECTION_KEY = "connection" + private const val AUTHORIZATION_KEY = "Authorization" + private const val LOCATION_KEY = "Location" + private const val AUTH_SESSION_KEY = "auth_session" + private const val AUTHN_RESPONSE_KEY = "authn_response" + private fun createErrorAdapter(): ErrorAdapter { + val mapAdapter = forMap(GsonProvider.gson) + return object : ErrorAdapter { + override fun fromRawResponse( + statusCode: Int, bodyText: String, headers: Map> + ): MyAccountException { + return MyAccountException(bodyText, statusCode) + } + + @Throws(IOException::class) + override fun fromJsonResponse( + statusCode: Int, reader: Reader + ): MyAccountException { + val values = mapAdapter.fromJson(reader, emptyMap()) + return MyAccountException(values) + } + + override fun fromException(cause: Throwable): MyAccountException { + if (isNetworkError(cause)) { + return MyAccountException( + "Failed to execute the network request", NetworkErrorException(cause) + ) + } + return MyAccountException( + "Something went wrong", Auth0Exception("Something went wrong", cause) + ) + } + } + } + } +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountException.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountException.kt new file mode 100644 index 000000000..8f8ec6630 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountException.kt @@ -0,0 +1,115 @@ +package com.auth0.android.myaccount + +import com.auth0.android.Auth0Exception +import com.auth0.android.NetworkErrorException + +public class MyAccountException @JvmOverloads constructor( + message: String, + exception: Auth0Exception? = null +) : Auth0Exception(message, exception) { + + private var code: String? = null + private var description: String? = null + + + /** + * Http Response status code. Can have value of 0 if not set. + * + * @return the status code. + */ + public var statusCode: Int = 0 + private set + private var values: Map? = null + + + public val type: String? + get() = values?.get("type") as? String + public var status: Int = 0 + private set + public val title: String? + get() = values?.get("title") as? String + public val detail: String? + get() = values?.get("detail") as? String + public val validationErrors: List? + get() = (values?.get("validation_errors") as? List>)?.map { + ValidationError( + it["detail"], + it["field"], + it["pointer"], + it["source"] + ) + } + + public constructor(payload: String?, statusCode: Int) : this(DEFAULT_MESSAGE) { + code = if (payload != null) NON_JSON_ERROR else EMPTY_BODY_ERROR + description = payload ?: EMPTY_RESPONSE_BODY_DESCRIPTION + this.statusCode = statusCode + } + + public constructor(values: Map) : this(DEFAULT_MESSAGE) { + this.values = values + val codeValue = + (if (values.containsKey(ERROR_KEY)) values[ERROR_KEY] else values[CODE_KEY]) as String? + code = codeValue ?: UNKNOWN_ERROR + description = + (if (values.containsKey(DESCRIPTION_KEY)) values[DESCRIPTION_KEY] else values[ERROR_DESCRIPTION_KEY]) as String? + } + + /** + * Auth0 error code if the server returned one or an internal library code (e.g.: when the server could not be reached) + * + * @return the error code. + */ + @Suppress("MemberVisibilityCanBePrivate") + public fun getCode(): String { + return if (code != null) code!! else UNKNOWN_ERROR + } + + /** + * Description of the error. + * important: You should avoid displaying description to the user, it's meant for debugging only. + * + * @return the error description. + */ + @Suppress("unused") + public fun getDescription(): String { + if (description != null) { + return description!! + } + return if (UNKNOWN_ERROR == getCode()) { + String.format("Received error with code %s", getCode()) + } else "Failed with unknown error" + } + + /** + * Returns a value from the error map, if any. + * + * @param key key of the value to return + * @return the value if found or null + */ + public fun getValue(key: String): Any? { + return values?.get(key) + } + + // When the request failed due to network issues + public val isNetworkError: Boolean + get() = cause is NetworkErrorException + + + public data class ValidationError( + val detail: String?, + val field: String?, + val pointer: String?, + val source: String? + ) + + private companion object { + private const val ERROR_KEY = "error" + private const val CODE_KEY = "code" + private const val DESCRIPTION_KEY = "description" + private const val ERROR_DESCRIPTION_KEY = "error_description" + private const val DEFAULT_MESSAGE = + "An error occurred when trying to authenticate with the server." + } + +} \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/request/JsonAdapter.kt b/auth0/src/main/java/com/auth0/android/request/JsonAdapter.kt index ac02d7c6d..7e3bdfdda 100644 --- a/auth0/src/main/java/com/auth0/android/request/JsonAdapter.kt +++ b/auth0/src/main/java/com/auth0/android/request/JsonAdapter.kt @@ -11,10 +11,11 @@ public interface JsonAdapter { /** * Converts the JSON input given in the Reader to the instance. * @param reader the reader that contains the JSON encoded string. + * @param headers the headers that were received with the response. * @throws IOException could be thrown to signal that the input was invalid. * @return the parsed result */ @Throws(IOException::class) - public fun fromJson(reader: Reader): T + public fun fromJson(reader: Reader, headers: Map>): T } \ 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 d4f72025a..9e48edb78 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 @@ -129,7 +129,7 @@ internal open class BaseRequest( if (response.isSuccess()) { //2. Successful scenario. Response of type T return try { - resultAdapter.fromJson(reader) + resultAdapter.fromJson(reader,response.headers) } catch (exception: Exception) { //multi catch IOException and JsonParseException (including JsonIOException) //3. Network exceptions, timeouts, etc reading response body diff --git a/auth0/src/main/java/com/auth0/android/request/internal/GsonAdapter.kt b/auth0/src/main/java/com/auth0/android/request/internal/GsonAdapter.kt index 7ed9eac0a..f815c9dc0 100644 --- a/auth0/src/main/java/com/auth0/android/request/internal/GsonAdapter.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/GsonAdapter.kt @@ -4,6 +4,7 @@ import com.auth0.android.request.JsonAdapter import com.google.gson.Gson import com.google.gson.TypeAdapter import com.google.gson.reflect.TypeToken +import okhttp3.Headers import java.io.Reader /** @@ -54,7 +55,7 @@ internal class GsonAdapter private constructor(private val adapter: TypeAdapt gson: Gson = supplyDefaultGson() ) : this(gson.getAdapter(tTypeToken)) - override fun fromJson(reader: Reader): T { + override fun fromJson(reader: Reader,headers: Map>): T { return adapter.fromJson(reader) } } \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/request/internal/RequestFactory.kt b/auth0/src/main/java/com/auth0/android/request/internal/RequestFactory.kt index 6f0ad39f3..5a4ce0142 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/RequestFactory.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/RequestFactory.kt @@ -33,7 +33,7 @@ internal class RequestFactory internal constructor( fun post(url: String): Request = this.post(url, object : JsonAdapter { - override fun fromJson(reader: Reader): Void? { + override fun fromJson(reader: Reader, headers: Map>): Void? { return null } }) diff --git a/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt b/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt new file mode 100644 index 000000000..748cef3ca --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt @@ -0,0 +1,34 @@ +package com.auth0.android.result + + +import com.google.gson.annotations.SerializedName + +/** + * A passkey authentication method. + */ +public data class PasskeyAuthenticationMethod( + @SerializedName("created_at") + val createdAt: String, + @SerializedName("credential_backed_up") + val credentialBackedUp: Boolean, + @SerializedName("credential_device_type") + val credentialDeviceType: String, + @SerializedName("id") + val id: String, + @SerializedName("identity_user_id") + val identityUserId: String, + @SerializedName("key_id") + val keyId: String, + @SerializedName("public_key") + val publicKey: String, + @SerializedName("transports") + val transports: List, + @SerializedName("type") + val type: String, + @SerializedName("usage") + val usage: String, + @SerializedName("user_agent") + val userAgent: String, + @SerializedName("user_handle") + val userHandle: String +) \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/result/PasskeyEnrollmentChallenge.kt b/auth0/src/main/java/com/auth0/android/result/PasskeyEnrollmentChallenge.kt new file mode 100644 index 000000000..2aca92352 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyEnrollmentChallenge.kt @@ -0,0 +1,14 @@ +package com.auth0.android.result + +import com.google.gson.annotations.SerializedName + +/** + * Represents the challenge data required for enrolling a passkey. + */ +public data class PasskeyEnrollmentChallenge( + val authenticationMethodId: String, + @SerializedName("auth_session") + val authSession: String, + @SerializedName("authn_params_public_key") + val authParamsPublicKey: AuthnParamsPublicKey +) From d6d80d181fcfa553a1db04e0055037497b8c6799 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Tue, 3 Jun 2025 16:06:01 +0530 Subject: [PATCH 2/4] Addressed review comments and minor changes in the exception handling --- .../authentication/AuthenticationAPIClient.kt | 2 +- .../android/management/UsersAPIClient.kt | 2 +- .../android/myaccount/MyAccountAPIClient.kt | 25 ++++++++------- .../android/myaccount/MyAccountException.kt | 23 +++++++------ .../com/auth0/android/request/JsonAdapter.kt | 4 +-- .../android/request/internal/GsonAdapter.kt | 2 +- .../request/internal/RequestFactory.kt | 2 +- .../result/PasskeyAuthenticationMethod.kt | 2 -- .../internal/BaseAuthenticationRequestTest.kt | 2 +- .../request/internal/BaseRequestTest.kt | 32 +++++++------------ 10 files changed, 45 insertions(+), 51 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt index 943ca4e10..560e649f7 100755 --- a/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/authentication/AuthenticationAPIClient.kt @@ -1088,7 +1088,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe override fun fromJsonResponse( statusCode: Int, reader: Reader ): AuthenticationException { - val values = mapAdapter.fromJson(reader, emptyMap()) + val values = mapAdapter.fromJson(reader) return AuthenticationException(values, statusCode) } diff --git a/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt b/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt index 65a5b443c..43f23af44 100755 --- a/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/management/UsersAPIClient.kt @@ -216,7 +216,7 @@ public class UsersAPIClient @VisibleForTesting(otherwise = VisibleForTesting.PRI statusCode: Int, reader: Reader ): ManagementException { - val values = mapAdapter.fromJson(reader, emptyMap()) + val values = mapAdapter.fromJson(reader) return ManagementException(values) } diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt index 596461c7a..c04e7819a 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -46,12 +46,6 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private val gson: Gson ) { - public val clientId: String - get() = auth0.clientId - - public val baseURL: String - get() = auth0.getDomainUrl() - /** * Creates a new MyAccountAPI client instance. * @@ -133,7 +127,7 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting * ``` * * Then, call ``enroll()`` with the created passkey credential and the challenge to complete - * the enrollment + * the enrollment. * * @param userIdentity Unique identifier of the current user's identity. Needed if the user logged in with a [linked account](https://auth0.com/docs/manage-users/user-accounts/user-account-linking) * @param connection Name of the database connection where the user is stored @@ -161,8 +155,14 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting val passkeyEnrollmentAdapter: JsonAdapter = object : JsonAdapter { override fun fromJson( - reader: Reader, headers: Map> + reader: Reader, metadata: Map ): PasskeyEnrollmentChallenge { + val headers = metadata.mapValues { (_, value) -> + when (value) { + is List<*> -> value.filterIsInstance() + else -> emptyList() + } + } val authenticationId = URLDecoder.decode( headers[LOCATION_KEY]?.get(0)?.split("/")?.lastOrNull(), @@ -279,7 +279,7 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting private const val USER_IDENTITY_ID_KEY = "identity_user_id" private const val CONNECTION_KEY = "connection" private const val AUTHORIZATION_KEY = "Authorization" - private const val LOCATION_KEY = "Location" + private const val LOCATION_KEY = "location" private const val AUTH_SESSION_KEY = "auth_session" private const val AUTHN_RESPONSE_KEY = "authn_response" private fun createErrorAdapter(): ErrorAdapter { @@ -295,8 +295,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting override fun fromJsonResponse( statusCode: Int, reader: Reader ): MyAccountException { - val values = mapAdapter.fromJson(reader, emptyMap()) - return MyAccountException(values) + val values = mapAdapter.fromJson(reader) + return MyAccountException(values, statusCode) } override fun fromException(cause: Throwable): MyAccountException { @@ -306,7 +306,8 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting ) } return MyAccountException( - "Something went wrong", Auth0Exception("Something went wrong", cause) + cause.message ?: "Something went wrong", + Auth0Exception(cause.message ?: "Something went wrong", cause) ) } } diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountException.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountException.kt index 8f8ec6630..a5dcbec96 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountException.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountException.kt @@ -23,13 +23,11 @@ public class MyAccountException @JvmOverloads constructor( public val type: String? - get() = values?.get("type") as? String - public var status: Int = 0 - private set + get() = values?.get(TYPE_KEY) as? String public val title: String? - get() = values?.get("title") as? String + get() = values?.get(TITLE_KEY) as? String public val detail: String? - get() = values?.get("detail") as? String + get() = values?.get(DETAIL_KEY) as? String public val validationErrors: List? get() = (values?.get("validation_errors") as? List>)?.map { ValidationError( @@ -46,13 +44,17 @@ public class MyAccountException @JvmOverloads constructor( this.statusCode = statusCode } - public constructor(values: Map) : this(DEFAULT_MESSAGE) { + public constructor( + values: Map, + statusCode: Int + ) : this(values[TITLE_KEY]?.toString() ?: DEFAULT_MESSAGE) { this.values = values + this.statusCode = statusCode val codeValue = - (if (values.containsKey(ERROR_KEY)) values[ERROR_KEY] else values[CODE_KEY]) as String? + (if (values.containsKey(TITLE_KEY)) values[TITLE_KEY] else values[CODE_KEY]) as String? code = codeValue ?: UNKNOWN_ERROR description = - (if (values.containsKey(DESCRIPTION_KEY)) values[DESCRIPTION_KEY] else values[ERROR_DESCRIPTION_KEY]) as String? + (if (values.containsKey(DETAIL_KEY)) values[DETAIL_KEY] else values[DESCRIPTION_KEY]) as String? } /** @@ -104,10 +106,11 @@ public class MyAccountException @JvmOverloads constructor( ) private companion object { - private const val ERROR_KEY = "error" + private const val TYPE_KEY = "type" private const val CODE_KEY = "code" private const val DESCRIPTION_KEY = "description" - private const val ERROR_DESCRIPTION_KEY = "error_description" + private const val TITLE_KEY = "title" + private const val DETAIL_KEY = "detail" private const val DEFAULT_MESSAGE = "An error occurred when trying to authenticate with the server." } diff --git a/auth0/src/main/java/com/auth0/android/request/JsonAdapter.kt b/auth0/src/main/java/com/auth0/android/request/JsonAdapter.kt index 7e3bdfdda..2410349d2 100644 --- a/auth0/src/main/java/com/auth0/android/request/JsonAdapter.kt +++ b/auth0/src/main/java/com/auth0/android/request/JsonAdapter.kt @@ -11,11 +11,11 @@ public interface JsonAdapter { /** * Converts the JSON input given in the Reader to the instance. * @param reader the reader that contains the JSON encoded string. - * @param headers the headers that were received with the response. + * @param metadata optional metadata that can be passed along . * @throws IOException could be thrown to signal that the input was invalid. * @return the parsed result */ @Throws(IOException::class) - public fun fromJson(reader: Reader, headers: Map>): T + public fun fromJson(reader: Reader, metadata: Map = emptyMap() ): T } \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/request/internal/GsonAdapter.kt b/auth0/src/main/java/com/auth0/android/request/internal/GsonAdapter.kt index f815c9dc0..037f66969 100644 --- a/auth0/src/main/java/com/auth0/android/request/internal/GsonAdapter.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/GsonAdapter.kt @@ -55,7 +55,7 @@ internal class GsonAdapter private constructor(private val adapter: TypeAdapt gson: Gson = supplyDefaultGson() ) : this(gson.getAdapter(tTypeToken)) - override fun fromJson(reader: Reader,headers: Map>): T { + override fun fromJson(reader: Reader,metadata: Map): T { return adapter.fromJson(reader) } } \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/request/internal/RequestFactory.kt b/auth0/src/main/java/com/auth0/android/request/internal/RequestFactory.kt index 5a4ce0142..4e00befe4 100755 --- a/auth0/src/main/java/com/auth0/android/request/internal/RequestFactory.kt +++ b/auth0/src/main/java/com/auth0/android/request/internal/RequestFactory.kt @@ -33,7 +33,7 @@ internal class RequestFactory internal constructor( fun post(url: String): Request = this.post(url, object : JsonAdapter { - override fun fromJson(reader: Reader, headers: Map>): Void? { + override fun fromJson(reader: Reader, metadata: Map): Void? { return null } }) diff --git a/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt b/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt index 748cef3ca..1c15e38ed 100644 --- a/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt @@ -25,8 +25,6 @@ public data class PasskeyAuthenticationMethod( val transports: List, @SerializedName("type") val type: String, - @SerializedName("usage") - val usage: String, @SerializedName("user_agent") val userAgent: String, @SerializedName("user_handle") diff --git a/auth0/src/test/java/com/auth0/android/request/internal/BaseAuthenticationRequestTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/BaseAuthenticationRequestTest.kt index a3e2ace41..76c9d7990 100644 --- a/auth0/src/test/java/com/auth0/android/request/internal/BaseAuthenticationRequestTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/BaseAuthenticationRequestTest.kt @@ -182,7 +182,7 @@ public class BaseAuthenticationRequestTest { val inputStream: InputStream = mock() val credentials: Credentials = mock() whenever(inputStream.read()).thenReturn(123) - whenever(resultAdapter.fromJson(any())).thenReturn(credentials) + whenever(resultAdapter.fromJson(any(), any())).thenReturn(credentials) val response = ServerResponse(200, inputStream, emptyMap()) whenever(client.load(eq(BASE_URL), any())).thenReturn(response) } diff --git a/auth0/src/test/java/com/auth0/android/request/internal/BaseRequestTest.kt b/auth0/src/test/java/com/auth0/android/request/internal/BaseRequestTest.kt index ef344eb21..bfb64de21 100755 --- a/auth0/src/test/java/com/auth0/android/request/internal/BaseRequestTest.kt +++ b/auth0/src/test/java/com/auth0/android/request/internal/BaseRequestTest.kt @@ -1,26 +1,18 @@ package com.auth0.android.request.internal -import androidx.test.espresso.matcher.ViewMatchers.assertThat import com.auth0.android.Auth0Exception import com.auth0.android.callback.Callback import com.auth0.android.request.* import com.google.gson.Gson import com.google.gson.JsonIOException import com.nhaarman.mockitokotlin2.* -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestCoroutineDispatcher -import kotlinx.coroutines.test.TestDispatcher import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.withContext -import net.bytebuddy.matcher.ElementMatchers.`is` import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.hamcrest.collection.IsMapContaining import org.hamcrest.collection.IsMapWithSize -import org.hamcrest.core.Is import org.hamcrest.core.IsCollectionContaining import org.junit.Before import org.junit.Test @@ -29,7 +21,6 @@ import org.mockito.Mock import org.mockito.Mockito import org.mockito.MockitoAnnotations import org.mockito.internal.verification.VerificationModeFactory -import org.powermock.api.mockito.PowerMockito.verifyPrivate import org.robolectric.RobolectricTestRunner import org.robolectric.android.util.concurrent.PausedExecutorService import org.robolectric.shadows.ShadowLooper @@ -38,7 +29,6 @@ import java.io.IOException import java.io.InputStream import java.io.Reader import java.util.* -import kotlin.coroutines.ContinuationInterceptor @RunWith(RobolectricTestRunner::class) public class BaseRequestTest { @@ -221,7 +211,7 @@ public class BaseRequestTest { val networkError = JsonIOException("Network error") Mockito.`when`( resultAdapter.fromJson( - any() + any(), any() ) ).thenThrow(networkError) var exception: Exception? = null @@ -307,7 +297,7 @@ public class BaseRequestTest { MatcherAssert.assertThat(exception, Matchers.`is`(Matchers.nullValue())) MatcherAssert.assertThat(result, Matchers.`is`(Matchers.notNullValue())) MatcherAssert.assertThat(result!!.prop, Matchers.`is`("test-value")) - verify(resultAdapter).fromJson(any()) + verify(resultAdapter).fromJson(any(), any()) MatcherAssert.assertThat(wasResponseStreamClosed, Matchers.`is`(true)) verifyNoMoreInteractions(resultAdapter) verifyZeroInteractions(errorAdapter) @@ -367,7 +357,7 @@ public class BaseRequestTest { exception = e } MatcherAssert.assertThat(result, Matchers.`is`(Matchers.nullValue())) - verify(resultAdapter).fromJson(any()) + verify(resultAdapter).fromJson(any(), any()) MatcherAssert.assertThat(exception, Matchers.`is`(wrappingAuth0Exception)) verify(errorAdapter).fromException(networkError) MatcherAssert.assertThat(wasResponseStreamClosed, Matchers.`is`(true)) @@ -473,13 +463,15 @@ public class BaseRequestTest { @Test @ExperimentalCoroutinesApi public fun shouldAwaitOnIODispatcher(): Unit = runTest { - val baseRequest = Mockito.spy(BaseRequest( - HttpMethod.POST, - BASE_URL, - client, - resultAdapter, - errorAdapter - )) + val baseRequest = Mockito.spy( + BaseRequest( + HttpMethod.POST, + BASE_URL, + client, + resultAdapter, + errorAdapter + ) + ) Mockito.doReturn(SimplePojo("")).`when`(baseRequest).switchRequestContext(any(), any()) mockSuccessfulServerResponse() baseRequest.await() From 62d5ca5ad6a71f54bc352e6407b8620c749168b1 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Tue, 3 Jun 2025 18:40:13 +0530 Subject: [PATCH 3/4] Added test cases for MyAccountApi --- .../android/callback/MyAccountCallback.kt | 5 + .../android/myaccount/MyAccountAPIClient.kt | 6 +- .../myaccount/MyAccountAPIClientTest.kt | 243 ++++++++++++++++++ .../com/auth0/android/util/APIMockServer.kt | 12 + .../android/util/MockMyAccountCallback.kt | 27 ++ .../android/util/MyAccountAPIMockServer.kt | 108 ++++++++ .../android/util/MyAccountCallbackMatcher.kt | 95 +++++++ 7 files changed, 493 insertions(+), 3 deletions(-) create mode 100644 auth0/src/main/java/com/auth0/android/callback/MyAccountCallback.kt create mode 100644 auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt create mode 100644 auth0/src/test/java/com/auth0/android/util/MockMyAccountCallback.kt create mode 100644 auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt create mode 100644 auth0/src/test/java/com/auth0/android/util/MyAccountCallbackMatcher.kt diff --git a/auth0/src/main/java/com/auth0/android/callback/MyAccountCallback.kt b/auth0/src/main/java/com/auth0/android/callback/MyAccountCallback.kt new file mode 100644 index 000000000..81326b6ca --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/callback/MyAccountCallback.kt @@ -0,0 +1,5 @@ +package com.auth0.android.callback + +import com.auth0.android.myaccount.MyAccountException + +public interface MyAccountCallback : Callback \ No newline at end of file diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt index c04e7819a..6c1eb4c7b 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -163,14 +163,14 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting else -> emptyList() } } + val locationHeader = headers[LOCATION_KEY]?.get(0)?.split("/")?.lastOrNull() + locationHeader ?: throw MyAccountException("Authentication ID not found") val authenticationId = URLDecoder.decode( - headers[LOCATION_KEY]?.get(0)?.split("/")?.lastOrNull(), + locationHeader, "UTF-8" ) - authenticationId ?: throw MyAccountException("Authentication ID not found") - val passkeyRegistrationChallenge = gson.fromJson( reader, PasskeyRegistrationChallenge::class.java ) diff --git a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt new file mode 100644 index 000000000..c1facdc4e --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -0,0 +1,243 @@ +package com.auth0.android.myaccount + +import com.auth0.android.Auth0 +import com.auth0.android.request.PublicKeyCredentials +import com.auth0.android.request.Response +import com.auth0.android.result.PasskeyAuthenticationMethod +import com.auth0.android.result.PasskeyEnrollmentChallenge +import com.auth0.android.util.AuthenticationAPIMockServer.Companion.SESSION_ID +import com.auth0.android.util.MockMyAccountCallback +import com.auth0.android.util.MyAccountAPIMockServer +import com.auth0.android.util.SSLTestUtils.testClient +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import com.google.gson.reflect.TypeToken +import com.nhaarman.mockitokotlin2.mock +import okhttp3.mockwebserver.RecordedRequest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.MockitoAnnotations +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.util.Map + + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +public class MyAccountAPIClientTest { + + private lateinit var client: MyAccountAPIClient + private lateinit var gson: Gson + private lateinit var mockAPI: MyAccountAPIMockServer + + @Before + public fun setUp() { + mockAPI = MyAccountAPIMockServer() + MockitoAnnotations.openMocks(this) + gson = GsonBuilder().serializeNulls().create() + client = MyAccountAPIClient(auth0, ACCESS_TOKEN) + } + + @After + public fun tearDown() { + mockAPI.shutdown() + } + + @Test + public fun `passkeyEnrollmentChallenge should build correct URL`() { + val callback = MockMyAccountCallback() + client.passkeyEnrollmentChallenge() + .start(callback) + val request = mockAPI.takeRequest() + assertThat(request.path, Matchers.equalTo("/me/v1/authentication-methods")) + } + + @Test + public fun `passkeyEnrollmentChallenge should include correct parameters`() { + val callback = MockMyAccountCallback() + client.passkeyEnrollmentChallenge(userIdentity = USER_IDENTITY, connection = CONNECTION) + .start(callback) + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("type", "passkey")) + assertThat(body, Matchers.hasEntry("identity_user_id", USER_IDENTITY)) + assertThat(body, Matchers.hasEntry("connection", CONNECTION)) + } + + + @Test + public fun `passkeyEnrollmentChallenge should include Authorization header`() { + val callback = MockMyAccountCallback() + client.passkeyEnrollmentChallenge() + .start(callback) + + val request = mockAPI.takeRequest() + val header = request.getHeader("Authorization") + + assertThat( + header, Matchers.`is`( + "Bearer $ACCESS_TOKEN" + ) + ) + } + + @Test + public fun `passkeyEnrollmentChallenge should throw exception if Location header is missing`() { + mockAPI.willReturnPasskeyChallengeWithoutHeader() + var error: MyAccountException? = null + try { + client.passkeyEnrollmentChallenge() + .execute() + } catch (ex: MyAccountException) { + error = ex + } + mockAPI.takeRequest() + assertThat(error, Matchers.notNullValue()) + assertThat(error?.message, Matchers.`is`("Authentication ID not found")) + } + + + @Test + public fun `passkeyEnrollmentChallenge should parse successful response with encoded authentication ID`() { + mockAPI.willReturnPasskeyChallenge() + val response = client.passkeyEnrollmentChallenge() + .execute() + mockAPI.takeRequest() + assertThat(response, Matchers.`is`(Matchers.notNullValue())) + assertThat(response.authSession, Matchers.comparesEqualTo(SESSION_ID)) + assertThat(response.authenticationMethodId, Matchers.comparesEqualTo("passkey|new")) + assertThat(response.authParamsPublicKey.relyingParty.id, Matchers.comparesEqualTo("rpId")) + assertThat( + response.authParamsPublicKey.relyingParty.name, + Matchers.comparesEqualTo("rpName") + ) + } + + @Test + public fun `enroll should build correct URL`() { + val callback = MockMyAccountCallback() + val enrollmentChallenge = PasskeyEnrollmentChallenge( + authenticationMethodId = AUTHENTICATION_ID, + authSession = AUTH_SESSION, + authParamsPublicKey = mock() + ) + + client.enroll(mockPublicKeyCredentials, enrollmentChallenge) + .start(callback) + val request = mockAPI.takeRequest() + assertThat( + request.path, + Matchers.equalTo("/me/v1/authentication-methods/${AUTHENTICATION_ID}/verify") + ) + } + + @Test + public fun `enroll should include correct parameters and authn_response`() { + val callback = MockMyAccountCallback() + val enrollmentChallenge = PasskeyEnrollmentChallenge( + authenticationMethodId = AUTHENTICATION_ID, + authSession = AUTH_SESSION, + authParamsPublicKey = mock() + ) + client.enroll(mockPublicKeyCredentials, enrollmentChallenge) + .start(callback) + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("auth_session", AUTH_SESSION)) + val authnResponse = body["authn_response"] as Map<*, *> + assertThat(authnResponse["authenticatorAttachment"], Matchers.`is`("platform")) + assertThat(authnResponse["id"], Matchers.`is`("id")) + assertThat(authnResponse["rawId"], Matchers.`is`("rawId")) + assertThat(authnResponse["type"], Matchers.`is`("public-key")) + + val responseData = authnResponse["response"] as Map<*, *> + assertThat(responseData.containsKey("clientDataJSON"), Matchers.`is`(true)) + assertThat(responseData.containsKey("attestationObject"), Matchers.`is`(true)) + } + + @Test + public fun `enroll should include Authorization header`() { + + val callback = MockMyAccountCallback() + val enrollmentChallenge = PasskeyEnrollmentChallenge( + authenticationMethodId = AUTHENTICATION_ID, + authSession = AUTH_SESSION, + authParamsPublicKey = mock() + ) + client.enroll(mockPublicKeyCredentials, enrollmentChallenge) + .start(callback) + + val request = mockAPI.takeRequest() + val header = request.getHeader("Authorization") + + assertThat( + header, Matchers.`is`( + "Bearer $ACCESS_TOKEN" + ) + ) + } + + + @Test + public fun `enroll should return PasskeyAuthenticationMethod on success`() { + mockAPI.willReturnPasskeyAuthenticationMethod() + val enrollmentChallenge = PasskeyEnrollmentChallenge( + authenticationMethodId = AUTHENTICATION_ID, + authSession = AUTH_SESSION, + authParamsPublicKey = mock() + ) + val response = client.enroll(mockPublicKeyCredentials, enrollmentChallenge) + .execute() + mockAPI.takeRequest() + assertThat(response, Matchers.`is`(Matchers.notNullValue())) + assertThat(response.id, Matchers.comparesEqualTo("auth_method_123456789")) + assertThat(response.type, Matchers.comparesEqualTo("passkey")) + assertThat(response.credentialDeviceType, Matchers.comparesEqualTo("phone")) + assertThat(response.credentialBackedUp, Matchers.comparesEqualTo(true)) + assertThat(response.publicKey, Matchers.comparesEqualTo("publickey")) + } + + + private fun bodyFromRequest(request: RecordedRequest): kotlin.collections.Map { + val mapType = object : TypeToken?>() {}.type + return gson.fromJson(request.body.readUtf8(), mapType) + } + + private val auth0: Auth0 + get() { + val auth0 = Auth0.getInstance(CLIENT_ID, mockAPI.domain, mockAPI.domain) + auth0.networkingClient = testClient + return auth0 + } + + private val mockPublicKeyCredentials = PublicKeyCredentials( + id = "id", + rawId = "rawId", + type = "public-key", + clientExtensionResults = mock(), + response = Response( + authenticatorData = "authenticatordaya", + clientDataJSON = "eyJ0eXBlIjoiaHR0cHM6Ly9jcmVkZW50aWFscy5leGFtcGxlLm9yZy93ZWJhdXRobi9jcmVhdGUiLCJjaGFsbGVuZ2UiOiJZMmhoYkd4bGJtZGxVbUZ1Wkc5dFFubDBaWE5GYm1OdlpHVmtTVzVDWVhObE5qUT0iLCJvcmlnaW4iOiJleGFtcGxlLmF1dGgwLmNvbSIsImNyb3NzT3JpZ2luIjpmYWxzZX0", + attestationObject = "o2NmbXRmcGFja2VkZ2F0dFN0bXSiY2FsZyZjc2lnWEgwRgIhAI4b7RFy4MnMqD4jDtl8BCpE5vvDmQSMHjZ7xZHlKFYiAiEA0GC_QoOve_71eMHlWAzM-YzQdGfNEZVVx3m_cNCJXAZoYXV0aERhdGFYJKlzaWduYXR1cmVEYXRhX19fX19fX19fX19fX19fX19fX19fUKI", + transports = listOf("str"), + signature = "signature", + userHandle = "user" + ), + authenticatorAttachment = "platform" + ) + + private companion object { + private const val CLIENT_ID = "CLIENTID" + private const val USER_IDENTITY = "user123" + private const val CONNECTION = "passkey-connection" + private const val ACCESS_TOKEN = "accessToken" + private const val AUTHENTICATION_ID = "authId123" + private const val AUTH_SESSION = "session456" + } +} + + diff --git a/auth0/src/test/java/com/auth0/android/util/APIMockServer.kt b/auth0/src/test/java/com/auth0/android/util/APIMockServer.kt index 664d1116d..de8ddd305 100644 --- a/auth0/src/test/java/com/auth0/android/util/APIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/APIMockServer.kt @@ -27,6 +27,18 @@ internal abstract class APIMockServer { .setBody(json) } + fun responseWithJSON(json: String, statusCode: Int, header: Map): MockResponse { + val response = MockResponse() + .setResponseCode(statusCode) + .addHeader("Content-Type", "application/json") + .setBody(json) + + header.forEach { (key, value) -> + response.addHeader(key, value) + } + return response + } + init { server.start() } diff --git a/auth0/src/test/java/com/auth0/android/util/MockMyAccountCallback.kt b/auth0/src/test/java/com/auth0/android/util/MockMyAccountCallback.kt new file mode 100644 index 000000000..a4021443d --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/util/MockMyAccountCallback.kt @@ -0,0 +1,27 @@ +package com.auth0.android.util + +import com.auth0.android.callback.MyAccountCallback +import com.auth0.android.myaccount.MyAccountException +import java.util.concurrent.Callable + +public class MockMyAccountCallback : MyAccountCallback { + + private var error: MyAccountException? = null + private var payload: T? = null + + override fun onSuccess(result: T) { + this.payload = result + } + + override fun onFailure(error: MyAccountException) { + this.error = error + } + + public fun error(): Callable = Callable { error } + + public fun payload(): Callable = Callable { payload } + + public fun getError(): MyAccountException? = error + + public fun getPayload(): T? = payload +} \ No newline at end of file diff --git a/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt b/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt new file mode 100644 index 000000000..551a6524d --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt @@ -0,0 +1,108 @@ +package com.auth0.android.util + +internal class MyAccountAPIMockServer : APIMockServer() { + + + fun willReturnPasskeyChallengeWithoutHeader(): MyAccountAPIMockServer { + val json = """ + { + "auth_session": "$SESSION_ID", + "authn_params_public_key": { + "authenticatorSelection": { + "residentKey": "required", + "userVerification": "preferred" + }, + "challenge": "$CHALLENGE", + "pubKeyCredParams": [ + { + "alg": -7, + "type": "public-key" + }, + { + "alg": -257, + "type": "public-key" + } + ], + "rp": { + "id": "example.auth0.com", + "name": "Example Application" + }, + "timeout": 60000, + "user": { + "displayName": "John Doe", + "id": "dXNlcklkUmFuZG9tQnl0ZXNFbmNvZGVkSW5CYXNlNjQ=", + "name": "johndoe@example.com" + } + } + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 200)) + return this + } + + fun willReturnPasskeyChallenge(): MyAccountAPIMockServer { + val json = """ + { + "auth_session": "$SESSION_ID", + "authn_params_public_key": { + "authenticatorSelection": { + "residentKey": "required", + "userVerification": "preferred" + }, + "challenge": "$CHALLENGE", + "pubKeyCredParams": [ + { + "alg": -7, + "type": "public-key" + }, + { + "alg": -257, + "type": "public-key" + } + ], + "rp": { + "id": "rpId", + "name": "rpName" + }, + "timeout": 60000, + "user": { + "displayName": "John Doe", + "id": "dXNlcklkUmFuZG9tQnl0ZXNFbmNvZGVkSW5CYXNlNjQ=", + "name": "johndoe@example.com" + } + } + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 200, mapOf("location" to "passkey|new"))) + return this + } + + fun willReturnPasskeyAuthenticationMethod(): MyAccountAPIMockServer { + val json = """ + { + "created_at": "2023-06-15T14:30:25.000Z", + "credential_backed_up": true, + "credential_device_type": "phone", + "id": "auth_method_123456789", + "identity_user_id": "user_98765432", + "key_id": "key_abcdef1234567890", + "public_key": "publickey", + "transports": ["internal"], + "type": "passkey", + "user_agent": "Android", + "user_handle": "userHandle" + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 200)) + return this + } + + private companion object { + 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/auth0/src/test/java/com/auth0/android/util/MyAccountCallbackMatcher.kt b/auth0/src/test/java/com/auth0/android/util/MyAccountCallbackMatcher.kt new file mode 100644 index 000000000..ba4635361 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/util/MyAccountCallbackMatcher.kt @@ -0,0 +1,95 @@ +package com.auth0.android.util + +import com.auth0.android.callback.MyAccountCallback +import com.auth0.android.myaccount.MyAccountException +import com.google.gson.reflect.TypeToken +import com.jayway.awaitility.Awaitility.await +import com.jayway.awaitility.core.ConditionTimeoutException +import org.hamcrest.BaseMatcher +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.isA +import org.hamcrest.Matchers.notNullValue +import org.hamcrest.Matchers.nullValue + +public class MyAccountCallbackMatcher( + private val payloadMatcher: Matcher, + private val errorMatcher: Matcher +) : BaseMatcher>() { + + + @Suppress("UNCHECKED_CAST") + override fun matches(item: Any): Boolean { + val callback = item as MockMyAccountCallback + return try { + await().until(callback.payload(), payloadMatcher) + await().until(callback.error(), errorMatcher) + true + } catch (e: ConditionTimeoutException) { + false + } + } + + override fun describeTo(description: Description) { + description.appendText("successful method be called") + } + + public companion object { + @JvmStatic + public fun hasPayloadOfType(tClazz: Class): Matcher> { + return MyAccountCallbackMatcher( + isA(tClazz), + nullValue(MyAccountException::class.java) + ) + } + + @JvmStatic + public fun hasPayloadOfType(typeToken: TypeToken): Matcher> { + return MyAccountCallbackMatcher( + TypeTokenMatcher.isA(typeToken), + nullValue(MyAccountException::class.java) + ) + } + + @JvmStatic + public fun hasPayload(payload: T): Matcher> { + return MyAccountCallbackMatcher( + equalTo(payload), + nullValue(MyAccountException::class.java) + ) + } + + @JvmStatic + public fun hasNoPayloadOfType(tClazz: Class): Matcher> { + return MyAccountCallbackMatcher( + nullValue(tClazz), + notNullValue(MyAccountException::class.java) + ) + } + + @JvmStatic + public fun hasNoPayloadOfType(typeToken: TypeToken): Matcher> { + return MyAccountCallbackMatcher( + TypeTokenMatcher.isA(typeToken), + nullValue(MyAccountException::class.java) + ) + } + + @JvmStatic + public fun hasNoError(): Matcher> { + return MyAccountCallbackMatcher( + notNullValue(Void::class.java), + nullValue(MyAccountException::class.java) + ) + } + + @JvmStatic + public fun hasError(): Matcher> { + return MyAccountCallbackMatcher( + nullValue(Void::class.java), + notNullValue(MyAccountException::class.java) + ) + } + } +} \ No newline at end of file From 8dfbf4c0cd4abac67ea02fe7dbfd07d5512cbf11 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Tue, 3 Jun 2025 21:27:30 +0530 Subject: [PATCH 4/4] Added few more test cases --- .../android/myaccount/MyAccountAPIClient.kt | 2 +- .../result/PasskeyAuthenticationMethod.kt | 2 +- .../myaccount/MyAccountAPIClientTest.kt | 112 +++++++++++++++++- .../android/util/MyAccountAPIMockServer.kt | 58 +++++++-- 4 files changed, 163 insertions(+), 11 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt index 6c1eb4c7b..c257b519b 100644 --- a/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -164,7 +164,7 @@ public class MyAccountAPIClient @VisibleForTesting(otherwise = VisibleForTesting } } val locationHeader = headers[LOCATION_KEY]?.get(0)?.split("/")?.lastOrNull() - locationHeader ?: throw MyAccountException("Authentication ID not found") + locationHeader ?: throw MyAccountException("Authentication method ID not found") val authenticationId = URLDecoder.decode( locationHeader, diff --git a/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt b/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt index 1c15e38ed..8d31a26d4 100644 --- a/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt @@ -22,7 +22,7 @@ public data class PasskeyAuthenticationMethod( @SerializedName("public_key") val publicKey: String, @SerializedName("transports") - val transports: List, + val transports: List?, @SerializedName("type") val type: String, @SerializedName("user_agent") diff --git a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt index c1facdc4e..a61501c60 100644 --- a/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -68,6 +68,19 @@ public class MyAccountAPIClientTest { assertThat(body, Matchers.hasEntry("connection", CONNECTION)) } + @Test + public fun `passkeyEnrollmentChallenge should include only the 'type' parameter by default`() { + val callback = MockMyAccountCallback() + client.passkeyEnrollmentChallenge() + .start(callback) + val request = mockAPI.takeRequest() + val body = bodyFromRequest(request) + assertThat(body, Matchers.hasEntry("type", "passkey")) + assertThat(body.containsKey("identity_user_id"), Matchers.`is`(false)) + assertThat(body.containsKey("connection"), Matchers.`is`(false)) + assertThat(body.size, Matchers.`is`(1)) + } + @Test public fun `passkeyEnrollmentChallenge should include Authorization header`() { @@ -97,7 +110,7 @@ public class MyAccountAPIClientTest { } mockAPI.takeRequest() assertThat(error, Matchers.notNullValue()) - assertThat(error?.message, Matchers.`is`("Authentication ID not found")) + assertThat(error?.message, Matchers.`is`("Authentication method ID not found")) } @@ -117,6 +130,66 @@ public class MyAccountAPIClientTest { ) } + + @Test + public fun `passkeyEnrollmentChallenge should handle 401 unauthorized errors correctly`() { + mockAPI.willReturnUnauthorizedError() + lateinit var error: MyAccountException + try { + client.passkeyEnrollmentChallenge() + .execute() + } catch (e: MyAccountException) { + error = e + } + // Take and verify the request was sent correctly + val request = mockAPI.takeRequest() + assertThat( + request.path, + Matchers.equalTo("/me/v1/authentication-methods") + ) + // Verify error details + assertThat(error, Matchers.notNullValue()) + assertThat(error.statusCode, Matchers.`is`(401)) + assertThat(error.message, Matchers.containsString("Unauthorized")) + assertThat( + error.detail, + Matchers.comparesEqualTo("The access token is invalid or has expired") + ) + + // Verify there are no validation errors in this case + assertThat(error.validationErrors, Matchers.nullValue()) + } + + @Test + public fun `passkeyEnrollmentChallenge should handle 403 forbidden errors correctly`() { + mockAPI.willReturnForbiddenError() + lateinit var error: MyAccountException + try { + client.passkeyEnrollmentChallenge() + .execute() + } catch (e: MyAccountException) { + error = e + } + val request = mockAPI.takeRequest() + assertThat( + request.path, + Matchers.equalTo("/me/v1/authentication-methods") + ) + + // Verify error details + assertThat(error, Matchers.notNullValue()) + assertThat(error.statusCode, Matchers.`is`(403)) + assertThat(error.message, Matchers.comparesEqualTo("Forbidden")) + assertThat( + error.detail, + Matchers.containsString("You do not have permission to perform this operation") + ) + assertThat(error.type, Matchers.equalTo("access_denied")) + + assertThat(error.validationErrors, Matchers.nullValue()) + } + + @Test public fun `enroll should build correct URL`() { val callback = MockMyAccountCallback() @@ -181,7 +254,6 @@ public class MyAccountAPIClientTest { ) } - @Test public fun `enroll should return PasskeyAuthenticationMethod on success`() { mockAPI.willReturnPasskeyAuthenticationMethod() @@ -201,6 +273,42 @@ public class MyAccountAPIClientTest { assertThat(response.publicKey, Matchers.comparesEqualTo("publickey")) } + @Test + public fun `enroll should handle 400 bad request errors correctly`() { + // Mock API to return a validation error response + mockAPI.willReturnErrorForBadRequest() + + // Set up the challenge and credentials for enrollment + val enrollmentChallenge = PasskeyEnrollmentChallenge( + authenticationMethodId = AUTHENTICATION_ID, + authSession = AUTH_SESSION, + authParamsPublicKey = mock() + ) + + lateinit var error: MyAccountException + try { + client.enroll(mockPublicKeyCredentials, enrollmentChallenge) + .execute() + } catch (e: MyAccountException) { + error = e + } + + // Take and verify the request was sent correctly + val request = mockAPI.takeRequest() + assertThat( + request.path, + Matchers.equalTo("/me/v1/authentication-methods/${AUTHENTICATION_ID}/verify") + ) + assertThat(error, Matchers.notNullValue()) + assertThat(error.statusCode, Matchers.`is`(400)) + assertThat(error.message, Matchers.containsString("Bad Request")) + assertThat(error.validationErrors?.size, Matchers.`is`(1)) + assertThat( + error.validationErrors?.get(0)?.detail, + Matchers.`is`("Invalid attestation object format") + ) + } + private fun bodyFromRequest(request: RecordedRequest): kotlin.collections.Map { val mapType = object : TypeToken?>() {}.type diff --git a/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt b/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt index 551a6524d..53cdba370 100644 --- a/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt @@ -2,7 +2,6 @@ package com.auth0.android.util internal class MyAccountAPIMockServer : APIMockServer() { - fun willReturnPasskeyChallengeWithoutHeader(): MyAccountAPIMockServer { val json = """ { @@ -97,12 +96,57 @@ internal class MyAccountAPIMockServer : APIMockServer() { return this } - private companion object { - 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" + fun willReturnErrorForBadRequest(): MyAccountAPIMockServer { + val responseBody = """ + { + "type": "validation_error", + "status": 400, + "title": "Bad Request", + "detail": "The provided data contains validation errors", + "validation_errors": [ + { + "detail": "Invalid attestation object format", + "field": "authn_response.response.attestationObject", + "pointer": "/authn_response/response/attestationObject", + "source": "request" + } + ] + } + """ + server.enqueue(responseWithJSON(responseBody, 400)) + return this + } + + fun willReturnUnauthorizedError(): MyAccountAPIMockServer { + val responseBody = """ + { + "type": "unauthorized_error", + "status": 401, + "title": "Unauthorized", + "detail": "The access token is invalid or has expired", + "validation_errors": null + } + """.trimIndent() + server.enqueue(responseWithJSON(responseBody, 401)) + return this + } + + fun willReturnForbiddenError(): MyAccountAPIMockServer { + val responseBody = """ + { + "type": "access_denied", + "status": 403, + "title": "Forbidden", + "detail": "You do not have permission to perform this operation", + "validation_errors": null + } + """.trimIndent() + server.enqueue(responseWithJSON(responseBody, 403)) + return this + } + + private companion object { + private const val SESSION_ID = "SESSION_ID" private const val CHALLENGE = "CHALLENGE" } } \ No newline at end of file