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 new file mode 100644 index 000000000..c257b519b --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountAPIClient.kt @@ -0,0 +1,316 @@ +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 +) { + + /** + * 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, metadata: Map + ): PasskeyEnrollmentChallenge { + val headers = metadata.mapValues { (_, value) -> + when (value) { + is List<*> -> value.filterIsInstance() + else -> emptyList() + } + } + val locationHeader = headers[LOCATION_KEY]?.get(0)?.split("/")?.lastOrNull() + locationHeader ?: throw MyAccountException("Authentication method ID not found") + val authenticationId = + URLDecoder.decode( + locationHeader, + "UTF-8" + ) + + 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) + return MyAccountException(values, statusCode) + } + + override fun fromException(cause: Throwable): MyAccountException { + if (isNetworkError(cause)) { + return MyAccountException( + "Failed to execute the network request", NetworkErrorException(cause) + ) + } + return MyAccountException( + cause.message ?: "Something went wrong", + Auth0Exception(cause.message ?: "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..a5dcbec96 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/myaccount/MyAccountException.kt @@ -0,0 +1,118 @@ +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_KEY) as? String + public val title: String? + get() = values?.get(TITLE_KEY) as? String + public val detail: String? + get() = values?.get(DETAIL_KEY) 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, + statusCode: Int + ) : this(values[TITLE_KEY]?.toString() ?: DEFAULT_MESSAGE) { + this.values = values + this.statusCode = statusCode + val codeValue = + (if (values.containsKey(TITLE_KEY)) values[TITLE_KEY] else values[CODE_KEY]) as String? + code = codeValue ?: UNKNOWN_ERROR + description = + (if (values.containsKey(DETAIL_KEY)) values[DETAIL_KEY] else values[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 TYPE_KEY = "type" + private const val CODE_KEY = "code" + private const val DESCRIPTION_KEY = "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." + } + +} \ 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..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,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 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): 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/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..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 @@ -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,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 6f0ad39f3..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): 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 new file mode 100644 index 000000000..8d31a26d4 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/result/PasskeyAuthenticationMethod.kt @@ -0,0 +1,32 @@ +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("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 +) 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..a61501c60 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/myaccount/MyAccountAPIClientTest.kt @@ -0,0 +1,351 @@ +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 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`() { + 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 method 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 `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() + 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")) + } + + @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 + 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/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() 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..53cdba370 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/util/MyAccountAPIMockServer.kt @@ -0,0 +1,152 @@ +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 + } + + 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 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